ruby_reactor 0.3.0 → 0.3.1

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.
@@ -0,0 +1,812 @@
1
+ # Testing with RSpec
2
+
3
+ RubyReactor provides a comprehensive testing framework designed to make testing reactors intuitive and powerful. The testing utilities include a `TestSubject` class for reactor execution and introspection, along with custom RSpec matchers for expressive assertions.
4
+
5
+ ## Setup
6
+
7
+ Add the RubyReactor RSpec configuration to your `spec_helper.rb` or `rails_helper.rb`:
8
+
9
+ ```ruby
10
+ require 'ruby_reactor/rspec'
11
+
12
+ RSpec.configure do |config|
13
+ RubyReactor::RSpec.configure(config)
14
+ end
15
+ ```
16
+
17
+ This will give you access to the `test_reactor` helper method and all custom matchers.
18
+
19
+ ## Basic Usage
20
+
21
+ ### The `test_reactor` Helper
22
+
23
+ The primary interface for testing reactors is the `test_reactor` helper, which returns a `TestSubject` instance:
24
+
25
+ ```ruby
26
+ RSpec.describe MyReactor do
27
+ it "processes successfully" do
28
+ subject = test_reactor(MyReactor, email: "test@example.com")
29
+
30
+ expect(subject).to be_success
31
+ end
32
+ end
33
+ ```
34
+
35
+ ### TestSubject Configuration
36
+
37
+ The `test_reactor` helper accepts the following options:
38
+
39
+ | Option | Type | Description |
40
+ |--------|------|-------------|
41
+ | `inputs` | Hash | The inputs to pass to the reactor |
42
+ | `context` | Hash | Optional context data for the execution |
43
+ | `async` | Boolean | Force async (`true`) or sync (`false`) execution |
44
+ | `process_jobs` | Boolean | Whether to automatically process Sidekiq jobs (default: `true`) |
45
+
46
+ ```ruby
47
+ # Force synchronous execution
48
+ subject = test_reactor(MyReactor, { user_id: 1 }, async: false)
49
+
50
+ # With additional context
51
+ subject = test_reactor(MyReactor, { user_id: 1 }, context: { tenant_id: 42 })
52
+ ```
53
+
54
+ ---
55
+
56
+ ## Execution Control
57
+
58
+ ### Running the Reactor
59
+
60
+ The `TestSubject` automatically runs the reactor when you access introspection methods. You can also run it explicitly:
61
+
62
+ ```ruby
63
+ subject = test_reactor(MyReactor, params)
64
+ subject.run # Explicit execution
65
+
66
+ # Chaining is supported
67
+ subject.run_async(false).run
68
+ ```
69
+
70
+ ### Async Mode Control
71
+
72
+ Control whether the reactor runs asynchronously:
73
+
74
+ ```ruby
75
+ # Force synchronous execution (useful for step-by-step debugging)
76
+ subject = test_reactor(MyReactor, params, async: false)
77
+
78
+ # Or use the fluent API
79
+ subject = test_reactor(MyReactor, params).run_async(false)
80
+ ```
81
+
82
+ ### Sidekiq Job Processing
83
+
84
+ By default, `TestSubject` automatically processes Sidekiq jobs in fake mode. This ensures that async steps complete during the test:
85
+
86
+ ```ruby
87
+ # Jobs are processed automatically
88
+ subject = test_reactor(AsyncReactor, params)
89
+ expect(subject).to be_success # All async steps completed
90
+
91
+ # Disable automatic job processing
92
+ subject = test_reactor(AsyncReactor, params, process_jobs: false)
93
+ ```
94
+
95
+ ---
96
+
97
+ ## Introspection
98
+
99
+ ### Accessing Results
100
+
101
+ Once executed, you can inspect the reactor's result:
102
+
103
+ ```ruby
104
+ subject = test_reactor(MyReactor, params)
105
+
106
+ # Check overall status
107
+ subject.success? # => true/false
108
+ subject.failure? # => true/false
109
+
110
+ # Get the final result object
111
+ subject.result # => Success or Failure
112
+
113
+ # Get any error that occurred
114
+ subject.error # => error message or nil
115
+ ```
116
+
117
+ ### Step Results
118
+
119
+ Access the result of individual steps:
120
+
121
+ ```ruby
122
+ subject = test_reactor(OrderReactor, order_id: 123)
123
+
124
+ # Get a specific step's result
125
+ user = subject.step_result(:validate_user)
126
+ order = subject.step_result(:fetch_order)
127
+ ```
128
+
129
+ ### Reactor Instance
130
+
131
+ Access the underlying reactor instance for advanced introspection:
132
+
133
+ ```ruby
134
+ subject = test_reactor(MyReactor, params)
135
+
136
+ # Access the reactor instance
137
+ subject.reactor_instance
138
+
139
+ # Access context data
140
+ subject.reactor_instance.context.execution_trace
141
+ subject.reactor_instance.context.intermediate_results
142
+ ```
143
+
144
+ ---
145
+
146
+ ## Step Mocking
147
+
148
+ ### Basic Step Mocking
149
+
150
+ Use `mock_step` to intercept and replace step implementations:
151
+
152
+ ```ruby
153
+ subject = test_reactor(PaymentReactor, order_id: 123)
154
+ .mock_step(:charge_card) do |args, context|
155
+ # Custom implementation
156
+ Success({ transaction_id: "test-123" })
157
+ end
158
+
159
+ expect(subject).to be_success
160
+ expect(subject.step_result(:charge_card)).to eq({ transaction_id: "test-123" })
161
+ ```
162
+
163
+ ### Chaining Multiple Mocks
164
+
165
+ You can chain multiple `mock_step` calls to mock several steps in a single fluent expression. This is useful when you need to isolate multiple external service calls:
166
+
167
+ ```ruby
168
+ RSpec.describe MultipleRequestsReactor do
169
+ subject(:reactor) do
170
+ test_reactor(described_class, request_id: 1)
171
+ .mock_step(:call_service_1) { |args| Success(args[:request_id]) }
172
+ .mock_step(:call_service_2) { |args| Success(args[:request_id]) }
173
+ .mock_step(:call_service_3) { |args| Success(args[:request_id]) }
174
+ end
175
+
176
+ it "processes successfully with all mocked services" do
177
+ expect(reactor).to be_success
178
+ end
179
+ end
180
+ ```
181
+
182
+ Each `mock_step` call returns the `TestSubject`, allowing you to chain as many mocks as needed. The mocks are applied in the order specified, and each mocked step will use the provided block instead of its original implementation.
183
+
184
+ ### Wrapping Original Implementations
185
+
186
+ The mock block receives a third parameter that allows calling the original implementation:
187
+
188
+ ```ruby
189
+ subject = test_reactor(MyReactor, params)
190
+ .mock_step(:some_step) do |args, context, original|
191
+ # Modify args before calling original
192
+ modified_args = args.merge(extra: "value")
193
+ result = original.call(modified_args, context)
194
+
195
+ # Post-process the result
196
+ result
197
+ end
198
+ ```
199
+
200
+ ### Simulating Failures
201
+
202
+ #### Using `failing_at`
203
+
204
+ The `failing_at` method provides a simple way to simulate step failures:
205
+
206
+ ```ruby
207
+ subject = test_reactor(OrderReactor, params)
208
+ .failing_at(:process_payment)
209
+
210
+ expect(subject).to be_failure
211
+ ```
212
+
213
+ #### Raising Exceptions in Mocks
214
+
215
+ For more control, raise exceptions within mock blocks:
216
+
217
+ ```ruby
218
+ subject = test_reactor(PaymentReactor, params)
219
+ .mock_step(:charge_card) do |args, context|
220
+ raise PaymentDeclinedError, "Card was declined"
221
+ end
222
+
223
+ expect(subject).to be_failure
224
+ expect(subject.error).to include("Card was declined")
225
+ ```
226
+
227
+ ---
228
+
229
+ ## Nested Reactor Testing
230
+
231
+ ### Testing Composed Reactors
232
+
233
+ When testing reactors that use `compose`, you can mock steps within the composed reactor:
234
+
235
+ ```ruby
236
+ # Parent reactor composes ChildReactor at :process_child step
237
+ subject = test_reactor(ParentReactor, params)
238
+ .composed(:process_child)
239
+ .mock_step(:inner_step) do |args, context|
240
+ Success("mocked inner result")
241
+ end
242
+
243
+ expect(subject).to be_success
244
+ ```
245
+
246
+ ### Traversing Composed Results
247
+
248
+ After execution, traverse into composed reactor results:
249
+
250
+ ```ruby
251
+ subject = test_reactor(ParentReactor, params)
252
+ subject.run
253
+
254
+ # Get the composed reactor's TestSubject
255
+ child_subject = subject.composed(:process_child)
256
+ expect(child_subject.step_result(:inner_step)).to eq("expected value")
257
+ ```
258
+
259
+ ### Testing Map Steps
260
+
261
+ For reactors using `map`, you can access individual element results:
262
+
263
+ ```ruby
264
+ subject = test_reactor(BatchProcessor, items: [1, 2, 3])
265
+ subject.run
266
+
267
+ # Access all map element subjects
268
+ elements = subject.map_elements(:process_items)
269
+ expect(elements.length).to eq(3)
270
+
271
+ # Access a specific element by index
272
+ first_element = subject.map_element(:process_items, index: 0)
273
+ expect(first_element).to be_success
274
+
275
+ # Mock within map elements
276
+ subject = test_reactor(BatchProcessor, items: [1, 2, 3])
277
+ .map(:process_items)
278
+ .mock_step(:transform) do |args, context|
279
+ Success(args[:item] * 2)
280
+ end
281
+ ```
282
+
283
+ ---
284
+
285
+ ## Custom RSpec Matchers
286
+
287
+ RubyReactor provides expressive matchers for common assertions:
288
+
289
+ ### Success and Failure Matchers
290
+
291
+ ```ruby
292
+ # Check if reactor succeeded
293
+ expect(subject).to be_success
294
+
295
+ # Check if reactor failed
296
+ expect(subject).to be_failure
297
+ ```
298
+
299
+ The `be_success` matcher provides detailed failure messages when a reactor fails:
300
+
301
+ ```ruby
302
+ # Example failure output:
303
+ # Error: PaymentDeclinedError
304
+ # Card was declined
305
+ # Step: :charge_card
306
+ # File: /app/reactors/payment_reactor.rb:45
307
+ #
308
+ # def charge_card
309
+ # --> PaymentGateway.charge(amount, token)
310
+ # end
311
+ #
312
+ # Backtrace:
313
+ # - /app/reactors/payment_reactor.rb:45
314
+ # - /app/services/payment_gateway.rb:12
315
+ ```
316
+
317
+ ### Step Execution Matchers
318
+
319
+ #### `have_run_step`
320
+
321
+ Verify that a specific step was executed:
322
+
323
+ ```ruby
324
+ expect(subject).to have_run_step(:validate_user)
325
+ ```
326
+
327
+ #### With Return Value
328
+
329
+ ```ruby
330
+ expect(subject).to have_run_step(:validate_user).returning({ id: 1, name: "Alice" })
331
+
332
+ # Works with regex for partial matching
333
+ expect(subject).to have_run_step(:generate_token).returning(/^token_/)
334
+ ```
335
+
336
+ #### With Execution Order
337
+
338
+ ```ruby
339
+ expect(subject).to have_run_step(:send_email).after(:create_user)
340
+ ```
341
+
342
+ #### Combined Assertions
343
+
344
+ ```ruby
345
+ expect(subject).to have_run_step(:process_payment)
346
+ .returning({ status: "completed" })
347
+ .after(:validate_order)
348
+ ```
349
+
350
+ ### Retry Matchers
351
+
352
+ #### `have_retried_step`
353
+
354
+ Verify that a step was retried:
355
+
356
+ ```ruby
357
+ expect(subject).to have_retried_step(:flaky_api_call)
358
+
359
+ # With specific retry count
360
+ expect(subject).to have_retried_step(:flaky_api_call).times(3)
361
+ ```
362
+
363
+ ### Validation Matchers
364
+
365
+ #### `have_validation_error`
366
+
367
+ Check for input validation errors:
368
+
369
+ ```ruby
370
+ subject = test_reactor(UserReactor, email: "invalid", age: 10)
371
+
372
+ expect(subject).to be_failure
373
+ expect(subject).to have_validation_error(:email)
374
+ expect(subject).to have_validation_error(:age)
375
+ ```
376
+
377
+ ---
378
+
379
+ ## Testing Interrupts
380
+
381
+ RubyReactor provides comprehensive test helpers for testing reactors that use the `interrupt` DSL for pause/resume workflows.
382
+
383
+ ### Interrupt State Introspection
384
+
385
+ #### Checking Paused State
386
+
387
+ ```ruby
388
+ subject = test_reactor(ApprovalWorkflow, request_id: 123)
389
+
390
+ # Check if reactor is paused
391
+ subject.paused? # => true/false
392
+
393
+ # Get the current interrupt step name
394
+ subject.current_step # => :wait_for_approval (Symbol) or nil
395
+ ```
396
+
397
+ #### Getting Ready Interrupt Steps
398
+
399
+ When a reactor has multiple concurrent interrupts (e.g., parallel approvals), use `ready_interrupt_steps` to see all steps that are ready to be resumed:
400
+
401
+ ```ruby
402
+ subject = test_reactor(MultiApprovalWorkflow, params)
403
+
404
+ # Get all ready interrupt steps
405
+ subject.ready_interrupt_steps # => [:manager_approval, :director_approval]
406
+ ```
407
+
408
+ ### Resuming Paused Reactors
409
+
410
+ Use `resume` to continue execution with a payload:
411
+
412
+ ```ruby
413
+ # Single interrupt - step is automatically detected
414
+ subject = test_reactor(ApprovalWorkflow, request_id: 123)
415
+ expect(subject).to be_paused
416
+
417
+ subject.resume(payload: { approved: true, approver: "manager" })
418
+
419
+ expect(subject).to be_success
420
+ ```
421
+
422
+ #### Multiple Concurrent Interrupts
423
+
424
+ When multiple interrupts are ready, you **must** specify which step to resume:
425
+
426
+ ```ruby
427
+ subject = test_reactor(MultiApprovalWorkflow, params)
428
+
429
+ # This will raise an error - ambiguous which interrupt to resume
430
+ # subject.resume(payload: { status: "approved" }) # => Error!
431
+
432
+ # Specify the step explicitly
433
+ subject.resume(step: :manager_approval, payload: { status: "approved" })
434
+ subject.resume(step: :director_approval, payload: { status: "approved" })
435
+
436
+ expect(subject).to be_success
437
+ ```
438
+
439
+ ### Interrupt Matchers
440
+
441
+ #### `be_paused`
442
+
443
+ Assert that a reactor is in paused state:
444
+
445
+ ```ruby
446
+ expect(subject).to be_paused
447
+ expect(subject).not_to be_paused
448
+ ```
449
+
450
+ #### `be_paused_at`
451
+
452
+ Assert that a reactor is paused with specific interrupt(s) ready:
453
+
454
+ ```ruby
455
+ # Single interrupt
456
+ expect(subject).to be_paused_at(:wait_for_approval)
457
+
458
+ # Check if specific interrupt is among the ready ones
459
+ expect(subject).to be_paused_at(:manager_approval)
460
+
461
+ # Check multiple interrupts are ready
462
+ expect(subject).to be_paused_at(:manager_approval, :director_approval)
463
+ ```
464
+
465
+ #### `have_ready_interrupts`
466
+
467
+ Assert the exact set of ready interrupt steps:
468
+
469
+ ```ruby
470
+ # Assert exactly these interrupts are ready
471
+ expect(subject).to have_ready_interrupts(:manager_approval, :director_approval)
472
+ ```
473
+
474
+ ### Complete Interrupt Testing Example
475
+
476
+ ```ruby
477
+ RSpec.describe ApprovalWorkflow do
478
+ describe "single approval" do
479
+ it "pauses at approval step and resumes" do
480
+ subject = test_reactor(ApprovalWorkflow, request_id: 123)
481
+
482
+ # Verify paused state
483
+ expect(subject).to be_paused
484
+ expect(subject).to be_paused_at(:wait_for_approval)
485
+ expect(subject.current_step).to eq(:wait_for_approval)
486
+
487
+ # Step results before interrupt should be available
488
+ expect(subject.step_result(:prepare_request)).to be_present
489
+
490
+ # Resume with approval payload
491
+ subject.resume(payload: { approved: true, approver: "manager" })
492
+
493
+ # Should complete after resume
494
+ expect(subject).to be_success
495
+ expect(subject.step_result(:finalize)).to include(approved: true)
496
+ end
497
+
498
+ it "stays paused with invalid payload" do
499
+ subject = test_reactor(ApprovalWorkflow, request_id: 123)
500
+
501
+ # Resume with invalid payload (fails validation)
502
+ expect {
503
+ subject.resume(payload: { invalid: "data" })
504
+ }.to raise_error(RubyReactor::Error::ValidationError)
505
+ end
506
+ end
507
+
508
+ describe "multiple approvals" do
509
+ it "handles concurrent approval requirements" do
510
+ subject = test_reactor(MultiApprovalWorkflow, request_id: 456)
511
+
512
+ # Verify multiple interrupts are ready
513
+ expect(subject).to be_paused
514
+ expect(subject).to have_ready_interrupts(:manager_approval, :director_approval)
515
+
516
+ # Resume first approval
517
+ subject.resume(step: :manager_approval, payload: { status: "approved" })
518
+
519
+ # Still paused, waiting for second approval
520
+ expect(subject).to be_paused
521
+ expect(subject.ready_interrupt_steps).to eq([:director_approval])
522
+
523
+ # Complete second approval
524
+ subject.resume(step: :director_approval, payload: { status: "approved" })
525
+
526
+ expect(subject).to be_success
527
+ end
528
+
529
+ it "requires step specification for multiple interrupts" do
530
+ subject = test_reactor(MultiApprovalWorkflow, request_id: 456)
531
+
532
+ # Without step: parameter, raises error
533
+ expect {
534
+ subject.resume(payload: { status: "approved" })
535
+ }.to raise_error(
536
+ RubyReactor::Error::ValidationError,
537
+ /multiple interrupt steps are ready/
538
+ )
539
+ end
540
+ end
541
+ end
542
+ ```
543
+
544
+ ---
545
+
546
+ ## Complete Examples
547
+
548
+ ### Testing a Payment Workflow
549
+
550
+ ```ruby
551
+ RSpec.describe PaymentWorkflow do
552
+ describe "successful payment" do
553
+ it "processes payment and creates invoice" do
554
+ subject = test_reactor(PaymentWorkflow,
555
+ order_id: 123,
556
+ amount: 99.99,
557
+ card_token: "tok_visa"
558
+ )
559
+
560
+ expect(subject).to be_success
561
+ expect(subject).to have_run_step(:validate_order)
562
+ expect(subject).to have_run_step(:charge_card).after(:validate_order)
563
+ expect(subject).to have_run_step(:create_invoice).after(:charge_card)
564
+
565
+ expect(subject.step_result(:charge_card)).to include(
566
+ status: "succeeded"
567
+ )
568
+ end
569
+ end
570
+
571
+ describe "payment failure" do
572
+ it "handles declined cards gracefully" do
573
+ subject = test_reactor(PaymentWorkflow,
574
+ order_id: 123,
575
+ amount: 99.99,
576
+ card_token: "tok_declined"
577
+ ).mock_step(:charge_card) do |args, context|
578
+ Failure("Card declined", code: "card_declined")
579
+ end
580
+
581
+ expect(subject).to be_failure
582
+ expect(subject.error).to include("Card declined")
583
+ end
584
+ end
585
+
586
+ describe "with retries" do
587
+ it "retries on transient failures" do
588
+ attempt = 0
589
+
590
+ subject = test_reactor(PaymentWorkflow, params)
591
+ .mock_step(:charge_card) do |args, context, original|
592
+ attempt += 1
593
+ if attempt < 3
594
+ raise NetworkError, "Connection timeout"
595
+ end
596
+ original.call(args, context)
597
+ end
598
+
599
+ expect(subject).to be_success
600
+ expect(subject).to have_retried_step(:charge_card).times(2)
601
+ end
602
+ end
603
+ end
604
+ ```
605
+
606
+ ### Testing Composed Reactors
607
+
608
+ ```ruby
609
+ RSpec.describe OrderProcessor do
610
+ it "processes order through composed payment reactor" do
611
+ subject = test_reactor(OrderProcessor, order_id: 123)
612
+ .composed(:process_payment)
613
+ .mock_step(:validate_card) do |args, context|
614
+ Success({ valid: true })
615
+ end
616
+
617
+ expect(subject).to be_success
618
+
619
+ # Inspect the composed reactor
620
+ payment = subject.composed(:process_payment)
621
+ expect(payment.step_result(:validate_card)).to eq({ valid: true })
622
+ end
623
+ end
624
+ ```
625
+
626
+ ### Testing Map Operations
627
+
628
+ ```ruby
629
+ RSpec.describe BatchEmailSender do
630
+ it "sends emails to all recipients" do
631
+ recipients = ["a@example.com", "b@example.com", "c@example.com"]
632
+
633
+ subject = test_reactor(BatchEmailSender, recipients: recipients)
634
+ .map(:send_emails)
635
+ .mock_step(:deliver) do |args, context|
636
+ Success({ sent_to: args[:recipient] })
637
+ end
638
+
639
+ expect(subject).to be_success
640
+
641
+ elements = subject.map_elements(:send_emails)
642
+ expect(elements.length).to eq(3)
643
+ expect(elements).to all(be_success)
644
+ end
645
+
646
+ it "handles partial failures" do
647
+ recipients = ["good@example.com", "bad@example.com"]
648
+
649
+ subject = test_reactor(BatchEmailSender, recipients: recipients)
650
+ .map(:send_emails)
651
+ .mock_step(:deliver) do |args, context|
652
+ if args[:recipient].include?("bad")
653
+ Failure("Invalid email")
654
+ else
655
+ Success({ sent_to: args[:recipient] })
656
+ end
657
+ end
658
+
659
+ # Inspect individual results
660
+ elements = subject.map_elements(:send_emails)
661
+ expect(elements.first).to be_success
662
+ expect(elements.last).to be_failure
663
+ end
664
+ end
665
+ ```
666
+
667
+ ### Testing Input Validation
668
+
669
+ ```ruby
670
+ RSpec.describe UserRegistration do
671
+ context "with valid inputs" do
672
+ it "creates the user" do
673
+ subject = test_reactor(UserRegistration,
674
+ email: "valid@example.com",
675
+ password: "secure123",
676
+ age: 25
677
+ )
678
+
679
+ expect(subject).to be_success
680
+ end
681
+ end
682
+
683
+ context "with invalid inputs" do
684
+ it "fails with validation errors" do
685
+ subject = test_reactor(UserRegistration,
686
+ email: "",
687
+ password: "x",
688
+ age: 10
689
+ )
690
+
691
+ expect(subject).to be_failure
692
+ expect(subject).to have_validation_error(:email)
693
+ expect(subject).to have_validation_error(:password)
694
+ expect(subject).to have_validation_error(:age)
695
+ end
696
+ end
697
+ end
698
+ ```
699
+
700
+ ---
701
+
702
+ ## Best Practices
703
+
704
+ ### 1. Isolate External Dependencies
705
+
706
+ Mock steps that interact with external services:
707
+
708
+ ```ruby
709
+ # Good: Mock external service calls
710
+ subject = test_reactor(PaymentReactor, params)
711
+ .mock_step(:call_stripe_api) { Success(mock_response) }
712
+
713
+ # Avoid: Letting tests hit real APIs
714
+ ```
715
+
716
+ ### 2. Test Compensation Logic
717
+
718
+ Verify that compensation runs correctly on failure:
719
+
720
+ ```ruby
721
+ it "refunds payment when shipping fails" do
722
+ subject = test_reactor(OrderReactor, params)
723
+ .failing_at(:create_shipment)
724
+
725
+ expect(subject).to be_failure
726
+ # Verify compensation occurred through side effects or mocks
727
+ end
728
+ ```
729
+
730
+ ### 3. Use Descriptive Assertions
731
+
732
+ Combine matchers for clear, intention-revealing tests:
733
+
734
+ ```ruby
735
+ # Good: Clear intent
736
+ expect(subject).to have_run_step(:notify_user).after(:create_account)
737
+
738
+ # Less clear: Manual inspection
739
+ trace = subject.reactor_instance.context.execution_trace
740
+ expect(trace.any? { |t| t[:step] == :notify_user }).to be true
741
+ ```
742
+
743
+ ### 4. Force Synchronous Execution for Debugging
744
+
745
+ When debugging async issues, force synchronous execution:
746
+
747
+ ```ruby
748
+ subject = test_reactor(AsyncReactor, params, async: false)
749
+ ```
750
+
751
+ ### 5. Keep Tests Focused
752
+
753
+ Test one aspect per test case:
754
+
755
+ ```ruby
756
+ # Good: Focused tests
757
+ it "validates the order" do
758
+ expect(subject).to have_run_step(:validate_order)
759
+ end
760
+
761
+ it "charges the card after validation" do
762
+ expect(subject).to have_run_step(:charge_card).after(:validate_order)
763
+ end
764
+
765
+ # Avoid: Testing everything in one spec
766
+ it "does the entire workflow correctly" do
767
+ # 50 lines of assertions...
768
+ end
769
+ ```
770
+
771
+ ---
772
+
773
+ ## API Reference
774
+
775
+ ### TestSubject Methods
776
+
777
+ | Method | Description |
778
+ |--------|-------------|
779
+ | `run` | Execute the reactor (auto-called by introspection methods) |
780
+ | `run_async(bool)` | Set async execution mode |
781
+ | `mock_step(name, *nested, &block)` | Mock a step's implementation |
782
+ | `failing_at(name, *nested)` | Simulate failure at a step |
783
+ | `map(step_name)` | Get proxy for mocking map step internals |
784
+ | `composed(step_name)` | Get proxy or traverse composed reactor |
785
+ | `result` | Get the final result (Success/Failure/InterruptResult) |
786
+ | `success?` | Check if reactor succeeded |
787
+ | `failure?` | Check if reactor failed |
788
+ | `paused?` | Check if reactor is paused at an interrupt |
789
+ | `current_step` | Get the current interrupt step name (Symbol or nil) |
790
+ | `ready_interrupt_steps` | Get all ready interrupt step names (Array of Symbols) |
791
+ | `resume(payload:, step:)` | Resume a paused reactor with payload; `step:` required for multiple interrupts |
792
+ | `step_result(name)` | Get a specific step's result |
793
+ | `error` | Get the error message if failed |
794
+ | `map_elements(step_name)` | Get all map element subjects |
795
+ | `map_element(step_name, index:)` | Get specific map element subject |
796
+ | `reactor_instance` | Access the underlying reactor instance |
797
+
798
+ ### RSpec Matchers
799
+
800
+ | Matcher | Description |
801
+ |---------|-------------|
802
+ | `be_success` | Assert reactor completed successfully |
803
+ | `be_failure` | Assert reactor failed |
804
+ | `be_paused` | Assert reactor is paused at an interrupt |
805
+ | `be_paused_at(*steps)` | Assert reactor is paused with specific interrupt(s) ready |
806
+ | `have_ready_interrupts(*steps)` | Assert exact set of ready interrupt steps |
807
+ | `have_run_step(name)` | Assert step was executed |
808
+ | `.returning(value)` | Chain: assert step returned value |
809
+ | `.after(step)` | Chain: assert step ran after another |
810
+ | `have_retried_step(name)` | Assert step was retried |
811
+ | `.times(count)` | Chain: assert retry count |
812
+ | `have_validation_error(field)` | Assert input validation error on field |