zenspec 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/README.md ADDED
@@ -0,0 +1,637 @@
1
+ # Zenspec
2
+
3
+ A comprehensive RSpec matcher library for testing GraphQL APIs, Interactor service objects, and Rails applications. Includes Shoulda Matchers integration for clean, expressive tests.
4
+
5
+ ## Installation
6
+
7
+ Add this line to your application's Gemfile:
8
+
9
+ ```ruby
10
+ gem "zenspec"
11
+ ```
12
+
13
+ And then execute:
14
+
15
+ ```bash
16
+ bundle install
17
+ ```
18
+
19
+ Or install it yourself as:
20
+
21
+ ```bash
22
+ gem install zenspec
23
+ ```
24
+
25
+ ## Features
26
+
27
+ - **GraphQL Matchers** - Test GraphQL queries, mutations, types, and schemas
28
+ - **Interactor Matchers** - Test service objects with clean syntax
29
+ - **GraphQL Helpers** - Execute queries and mutations with ease
30
+ - **Progress Loader** - Docker-style progress bars for terminal output
31
+ - **Shoulda Matchers** - Automatically configured for Rails models and controllers
32
+ - **Rails Integration** - Automatically loads in Rails applications
33
+ - **Non-Rails Support** - Works in any Ruby project
34
+
35
+ ## Usage
36
+
37
+ ```ruby
38
+ # In your spec/rails_helper.rb or spec/spec_helper.rb
39
+ require "zenspec"
40
+
41
+ # Everything is automatically configured!
42
+ ```
43
+
44
+ ---
45
+
46
+ ## Progress Loader
47
+
48
+ Docker-style progress bars for displaying terminal progress.
49
+
50
+ ### RSpec Formatters
51
+
52
+ Zenspec includes two custom RSpec formatters for displaying test progress:
53
+
54
+ #### Progress Bar Formatter (Recommended)
55
+
56
+ Clean, colorful output with status icons:
57
+
58
+ ```bash
59
+ # Use the progress bar formatter
60
+ rspec --require zenspec/formatters/progress_bar_formatter \
61
+ --format ProgressBarFormatter
62
+
63
+ # Or add to .rspec file:
64
+ # --require zenspec/formatters/progress_bar_formatter
65
+ # --format ProgressBarFormatter
66
+ ```
67
+
68
+ Output example:
69
+ ```
70
+ ✔ user_spec.rb creates a new user successfully [10% 1/10]
71
+ ✔ user_spec.rb validates email presence [20% 2/10]
72
+ ⠿ user_spec.rb --> sends welcome email [30% 3/10]
73
+ ✗ post_spec.rb publishes post with valid data [40% 4/10]
74
+ ⊘ auth_spec.rb authenticates with OAuth [50% 5/10]
75
+ ```
76
+
77
+ Format:
78
+ - **Running tests** show an arrow (-->) between filename and description
79
+ - **Completed tests** (passed/failed/pending) show only a space between filename and description
80
+
81
+ Colors:
82
+ - **Green** ✔ - Passed tests
83
+ - **Red** ✗ - Failed tests
84
+ - **Yellow** ⠿ - Currently running test (with arrow)
85
+ - **Cyan** ⊘ - Pending/skipped tests
86
+
87
+ #### Docker-Style Progress Formatter
88
+
89
+ Alternative formatter with Docker-style progress bars:
90
+
91
+ ```bash
92
+ # Use the Docker-style formatter
93
+ rspec --require zenspec/formatters/progress_formatter \
94
+ --format Zenspec::Formatters::ProgressFormatter
95
+ ```
96
+
97
+ Output example:
98
+ ```
99
+ [=====================> ] 65% 13/20 spec/models/user_spec.rb:42
100
+ ```
101
+
102
+ ### Programmatic Usage
103
+
104
+ Use the progress loader in your code:
105
+
106
+ ```ruby
107
+ # Basic usage
108
+ loader = Zenspec::ProgressLoader.new(total: 100, description: "Processing files")
109
+
110
+ 100.times do |i|
111
+ # Do some work...
112
+ loader.update(i + 1, description: "Processing file #{i + 1}/100")
113
+ sleep(0.1)
114
+ end
115
+
116
+ loader.finish(description: "Processing complete!")
117
+ ```
118
+
119
+ ### Custom Width
120
+
121
+ ```ruby
122
+ # Custom progress bar width (default is 40)
123
+ loader = Zenspec::ProgressLoader.new(
124
+ total: 50,
125
+ width: 60,
126
+ description: "Downloading layers"
127
+ )
128
+
129
+ loader.increment(description: "Layer 1/50")
130
+ loader.increment(description: "Layer 2/50")
131
+ # ...
132
+ loader.finish
133
+ ```
134
+
135
+ ### Time Estimates
136
+
137
+ The progress loader automatically calculates and displays time information:
138
+
139
+ ```ruby
140
+ loader = Zenspec::ProgressLoader.new(total: 1000)
141
+
142
+ loader.update(250)
143
+ # Output: [==========> ] 25% 250/1000 1s/4s
144
+
145
+ loader.update(500)
146
+ # Output: [====================> ] 50% 500/1000 2s/4s
147
+ ```
148
+
149
+ ---
150
+
151
+ ## Interactor Matchers
152
+
153
+ ### Basic Usage
154
+
155
+ ```ruby
156
+ RSpec.describe CreateUser do
157
+ subject(:result) { described_class.call(params) }
158
+
159
+ context "with valid params" do
160
+ let(:params) { { name: "John", email: "john@example.com" } }
161
+
162
+ # Shoulda-style one-liners
163
+ it { is_expected.to succeed }
164
+ it { is_expected.to set_context(:user) }
165
+ it { is_expected.to succeed.with_data(kind_of(User)) }
166
+ end
167
+
168
+ context "with invalid params" do
169
+ let(:params) { { name: "", email: "invalid" } }
170
+
171
+ it { is_expected.to fail_interactor }
172
+ it { is_expected.to fail_interactor.with_error("validation_failed") }
173
+ it { is_expected.to have_error_code("validation_failed") }
174
+ end
175
+ end
176
+ ```
177
+
178
+ ### Available Matchers
179
+
180
+ #### `succeed`
181
+
182
+ ```ruby
183
+ expect(result).to succeed
184
+ expect(result).to succeed.with_data("expected value")
185
+ expect(result).to succeed.with_context(:user_id, 123)
186
+ expect(CreateUser).to succeed # Works with classes too
187
+ ```
188
+
189
+ #### `fail_interactor`
190
+
191
+ ```ruby
192
+ expect(result).to fail_interactor
193
+ expect(result).to fail_interactor.with_error("invalid_input")
194
+ expect(result).to fail_interactor.with_errors("error1", "error2")
195
+ ```
196
+
197
+ #### `have_error_code` / `have_error_codes`
198
+
199
+ ```ruby
200
+ expect(result).to have_error_code("not_found")
201
+ expect(result).to have_error_codes("error1", "error2")
202
+ ```
203
+
204
+ #### `set_context`
205
+
206
+ ```ruby
207
+ expect(result).to set_context(:user)
208
+ expect(result).to set_context(:user_id, 123)
209
+ expect(result).to set_context(:data).to(expected_value)
210
+ ```
211
+
212
+ ---
213
+
214
+ ## GraphQL Response Matchers
215
+
216
+ ### Basic Usage
217
+
218
+ ```ruby
219
+ RSpec.describe "User Queries" do
220
+ subject(:result) { graphql_execute_as(user, query, variables: { id: user.id }) }
221
+
222
+ let(:query) do
223
+ <<~GQL
224
+ query GetUser($id: ID!) {
225
+ user(id: $id) {
226
+ id
227
+ name
228
+ email
229
+ }
230
+ }
231
+ GQL
232
+ end
233
+
234
+ # Shoulda-style
235
+ it { is_expected.to succeed_graphql }
236
+ it { is_expected.to have_graphql_data("user") }
237
+ it { is_expected.to have_graphql_data("user", "name").with_value("John") }
238
+ end
239
+ ```
240
+
241
+ ### Available Matchers
242
+
243
+ #### `have_graphql_data`
244
+
245
+ ```ruby
246
+ # Basic checks
247
+ expect(result).to have_graphql_data
248
+ expect(result).to have_graphql_data("user")
249
+ expect(result).to have_graphql_data("user", "id")
250
+
251
+ # With specific value
252
+ expect(result).to have_graphql_data("user", "name").with_value("John Doe")
253
+
254
+ # Partial matching (subset)
255
+ expect(result).to have_graphql_data("user").matching(
256
+ id: "123",
257
+ name: "John"
258
+ # other fields can exist but aren't checked
259
+ )
260
+
261
+ # Inclusion (contains these values)
262
+ expect(result).to have_graphql_data("user").that_includes(name: "John")
263
+
264
+ # Presence checks
265
+ expect(result).to have_graphql_data("users").that_is_present
266
+ expect(result).to have_graphql_data("deletedAt").that_is_null
267
+
268
+ # Array count
269
+ expect(result).to have_graphql_data("users").with_count(5)
270
+ ```
271
+
272
+ #### `have_graphql_errors`
273
+
274
+ ```ruby
275
+ # Basic error check
276
+ expect(result).to have_graphql_errors
277
+
278
+ # With specific message
279
+ expect(result).to have_graphql_error.with_message("Not found")
280
+
281
+ # With extensions
282
+ expect(result).to have_graphql_error.with_extensions(code: "NOT_FOUND")
283
+
284
+ # At specific path
285
+ expect(result).to have_graphql_error.at_path(["user", "email"])
286
+ ```
287
+
288
+ #### `succeed_graphql`
289
+
290
+ ```ruby
291
+ expect(result).to succeed_graphql
292
+ ```
293
+
294
+ #### `have_graphql_field` / `have_graphql_fields`
295
+
296
+ ```ruby
297
+ data = result.dig("data", "user")
298
+
299
+ expect(data).to have_graphql_field("name")
300
+ expect(data).to have_graphql_field(:email)
301
+
302
+ expect(data).to have_graphql_fields(
303
+ id: "123",
304
+ name: "John Doe",
305
+ email: "john@example.com"
306
+ )
307
+ ```
308
+
309
+ ---
310
+
311
+ ## GraphQL Type Matchers
312
+
313
+ Test your GraphQL schema types, queries, and mutations.
314
+
315
+ ### Type Field Testing
316
+
317
+ ```ruby
318
+ RSpec.describe UserType do
319
+ subject { described_class }
320
+
321
+ # Shoulda-style
322
+ it { is_expected.to have_field(:id).of_type("ID!") }
323
+ it { is_expected.to have_field(:name).of_type("String!") }
324
+ it { is_expected.to have_field(:email).of_type("String") }
325
+ it { is_expected.to have_field(:posts).of_type("[Post!]!") }
326
+
327
+ # With arguments
328
+ it do
329
+ is_expected.to have_field(:posts)
330
+ .with_argument(:limit, "Int")
331
+ .with_argument(:offset, "Int")
332
+ end
333
+ end
334
+ ```
335
+
336
+ ### Enum Testing
337
+
338
+ ```ruby
339
+ RSpec.describe StatusEnum do
340
+ subject { described_class }
341
+
342
+ it { is_expected.to have_enum_values("ACTIVE", "INACTIVE", "PENDING") }
343
+ end
344
+ ```
345
+
346
+ ### Schema Query/Mutation Testing
347
+
348
+ ```ruby
349
+ RSpec.describe AppSchema do
350
+ subject { described_class }
351
+
352
+ # Query fields
353
+ it { is_expected.to have_query(:user).with_argument(:id, "ID!") }
354
+ it { is_expected.to have_query(:users).of_type("[User!]!") }
355
+
356
+ # Mutation fields
357
+ it { is_expected.to have_mutation(:createUser).with_argument(:input, "CreateUserInput!") }
358
+ it { is_expected.to have_mutation(:deleteUser).of_type("Boolean!") }
359
+ end
360
+ ```
361
+
362
+ ### Field Argument Testing
363
+
364
+ ```ruby
365
+ RSpec.describe "UserType posts field" do
366
+ subject(:field) { UserType.fields["posts"] }
367
+
368
+ it { is_expected.to have_argument(:limit).of_type("Int") }
369
+ it { is_expected.to have_argument(:status).of_type("Status!") }
370
+ end
371
+ ```
372
+
373
+ ---
374
+
375
+ ## GraphQL Execution Helpers
376
+
377
+ Execute GraphQL queries and mutations with ease.
378
+
379
+ ### `graphql_execute`
380
+
381
+ ```ruby
382
+ # Basic execution
383
+ result = graphql_execute(query, variables: { id: "123" }, context: { current_user: user })
384
+
385
+ # With custom schema
386
+ result = graphql_execute(query, schema: MySchema)
387
+ ```
388
+
389
+ ### `graphql_execute_as`
390
+
391
+ ```ruby
392
+ # Automatically adds user to context
393
+ result = graphql_execute_as(user, query, variables: { id: "123" })
394
+
395
+ # Custom context key
396
+ result = graphql_execute_as(user, query, context_key: :admin)
397
+ ```
398
+
399
+ ### `graphql_mutate`
400
+
401
+ ```ruby
402
+ # Execute mutations
403
+ result = graphql_mutate(mutation, input: { name: "John" }, context: { current_user: user })
404
+ ```
405
+
406
+ ### `graphql_mutate_as`
407
+
408
+ ```ruby
409
+ # Execute mutation as user
410
+ result = graphql_mutate_as(user, mutation, input: { name: "John" })
411
+ ```
412
+
413
+ ---
414
+
415
+ ## Shoulda Matchers
416
+
417
+ Automatically includes and configures [Shoulda Matchers](https://github.com/thoughtbot/shoulda-matchers) for Rails projects.
418
+
419
+ ### Model Validations
420
+
421
+ ```ruby
422
+ RSpec.describe User do
423
+ subject { build(:user) }
424
+
425
+ # Validations
426
+ it { is_expected.to validate_presence_of(:email) }
427
+ it { is_expected.to validate_uniqueness_of(:email).case_insensitive }
428
+ it { is_expected.to validate_length_of(:password).is_at_least(8) }
429
+
430
+ # Associations
431
+ it { is_expected.to have_many(:posts).dependent(:destroy) }
432
+ it { is_expected.to belong_to(:organization) }
433
+
434
+ # Database columns
435
+ it { is_expected.to have_db_column(:email).of_type(:string) }
436
+ it { is_expected.to have_db_index(:email).unique }
437
+ end
438
+ ```
439
+
440
+ ### Controller Testing
441
+
442
+ ```ruby
443
+ RSpec.describe UsersController do
444
+ describe "GET #index" do
445
+ it { is_expected.to respond_with(:success) }
446
+ it { is_expected.to render_template(:index) }
447
+ end
448
+
449
+ describe "POST #create" do
450
+ it { is_expected.to permit(:name, :email).for(:create) }
451
+ end
452
+ end
453
+ ```
454
+
455
+ ---
456
+
457
+ ## Complete Examples
458
+
459
+ ### Full Interactor Test Suite
460
+
461
+ ```ruby
462
+ RSpec.describe CreateUser do
463
+ subject(:result) { described_class.call(params) }
464
+
465
+ describe "success cases" do
466
+ let(:params) { { name: "John Doe", email: "john@example.com" } }
467
+
468
+ it { is_expected.to succeed }
469
+ it { is_expected.to set_context(:user) }
470
+ it { is_expected.to succeed.with_context(:user, kind_of(User)) }
471
+
472
+ it "creates a user with correct attributes" do
473
+ expect(result).to succeed
474
+ expect(result.user.name).to eq("John Doe")
475
+ expect(result.user.email).to eq("john@example.com")
476
+ end
477
+ end
478
+
479
+ describe "failure cases" do
480
+ context "with blank name" do
481
+ let(:params) { { name: "", email: "john@example.com" } }
482
+
483
+ it { is_expected.to fail_interactor }
484
+ it { is_expected.to fail_interactor.with_error("blank_name") }
485
+ it { is_expected.to have_error_code("blank_name") }
486
+ end
487
+
488
+ context "with duplicate email" do
489
+ before { create(:user, email: "john@example.com") }
490
+
491
+ let(:params) { { name: "John", email: "john@example.com" } }
492
+
493
+ it { is_expected.to fail_interactor.with_error("duplicate_email") }
494
+ end
495
+ end
496
+ end
497
+ ```
498
+
499
+ ### Full GraphQL Test Suite
500
+
501
+ ```ruby
502
+ RSpec.describe "User Queries" do
503
+ let(:user) { create(:user, name: "John Doe", email: "john@example.com") }
504
+
505
+ describe "user query" do
506
+ subject(:result) { graphql_execute_as(user, query, variables: { id: user.id }) }
507
+
508
+ let(:query) do
509
+ <<~GQL
510
+ query GetUser($id: ID!) {
511
+ user(id: $id) {
512
+ id
513
+ name
514
+ email
515
+ posts {
516
+ id
517
+ title
518
+ }
519
+ }
520
+ }
521
+ GQL
522
+ end
523
+
524
+ it { is_expected.to succeed_graphql }
525
+ it { is_expected.to have_graphql_data("user") }
526
+
527
+ it "returns correct user data" do
528
+ expect(result).to have_graphql_data("user").matching(
529
+ id: user.id,
530
+ name: "John Doe",
531
+ email: "john@example.com"
532
+ )
533
+ end
534
+
535
+ it { is_expected.to have_graphql_data("user", "posts").that_is_present }
536
+
537
+ context "when user has posts" do
538
+ before { create_list(:post, 3, user: user) }
539
+
540
+ it { is_expected.to have_graphql_data("user", "posts").with_count(3) }
541
+ end
542
+
543
+ context "when not authenticated" do
544
+ subject(:result) { graphql_execute(query, variables: { id: user.id }) }
545
+
546
+ it { is_expected.to have_graphql_error.with_message("Authentication required") }
547
+ it { is_expected.to have_graphql_error.with_extensions(code: "UNAUTHENTICATED") }
548
+ end
549
+ end
550
+ end
551
+ ```
552
+
553
+ ### Full GraphQL Type Test Suite
554
+
555
+ ```ruby
556
+ RSpec.describe Types::UserType do
557
+ subject { described_class }
558
+
559
+ # Basic fields
560
+ it { is_expected.to have_field(:id).of_type("ID!") }
561
+ it { is_expected.to have_field(:name).of_type("String!") }
562
+ it { is_expected.to have_field(:email).of_type("String") }
563
+ it { is_expected.to have_field(:createdAt).of_type("ISO8601DateTime!") }
564
+
565
+ # Associations
566
+ it { is_expected.to have_field(:posts).of_type("[Post!]!") }
567
+ it { is_expected.to have_field(:organization).of_type("Organization") }
568
+
569
+ # Fields with arguments
570
+ describe "posts field" do
571
+ subject(:field) { described_class.fields["posts"] }
572
+
573
+ it { is_expected.to have_argument(:limit).of_type("Int") }
574
+ it { is_expected.to have_argument(:offset).of_type("Int") }
575
+ it { is_expected.to have_argument(:status).of_type("PostStatus") }
576
+ end
577
+ end
578
+
579
+ RSpec.describe Types::PostStatusEnum do
580
+ subject { described_class }
581
+
582
+ it { is_expected.to have_enum_values("DRAFT", "PUBLISHED", "ARCHIVED") }
583
+ end
584
+
585
+ RSpec.describe AppSchema do
586
+ subject { described_class }
587
+
588
+ describe "queries" do
589
+ it { is_expected.to have_query(:user).with_argument(:id, "ID!") }
590
+ it { is_expected.to have_query(:users).of_type("[User!]!") }
591
+ it { is_expected.to have_query(:currentUser).of_type("User") }
592
+ end
593
+
594
+ describe "mutations" do
595
+ it { is_expected.to have_mutation(:createUser).with_argument(:input, "CreateUserInput!") }
596
+ it { is_expected.to have_mutation(:updateUser).with_argument(:input, "UpdateUserInput!") }
597
+ it { is_expected.to have_mutation(:deleteUser).of_type("Boolean!") }
598
+ end
599
+ end
600
+ ```
601
+
602
+ ---
603
+
604
+ ## Configuration
605
+
606
+ Zenspec works out of the box with sensible defaults. For Rails applications, it automatically configures itself through a Railtie.
607
+
608
+ ### Manual Configuration (Non-Rails)
609
+
610
+ If you're not using Rails, the matchers are still automatically included when you require zenspec:
611
+
612
+ ```ruby
613
+ # spec/spec_helper.rb
614
+ require "zenspec"
615
+
616
+ # That's it! All matchers and helpers are available
617
+ ```
618
+
619
+ ---
620
+
621
+ ## Development
622
+
623
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
624
+
625
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
626
+
627
+ ## Contributing
628
+
629
+ Bug reports and pull requests are welcome on GitHub at https://github.com/zyxzen/zenspec. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/zyxzen/zenspec/blob/main/CODE_OF_CONDUCT.md).
630
+
631
+ ## License
632
+
633
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
634
+
635
+ ## Code of Conduct
636
+
637
+ Everyone interacting in the Zenspec project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/zyxzen/zenspec/blob/main/CODE_OF_CONDUCT.md).
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ require "rubocop/rake_task"
9
+
10
+ RuboCop::RakeTask.new
11
+
12
+ task default: %i[spec rubocop]