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.
- checksums.yaml +4 -4
- data/README.md +31 -4
- data/documentation/testing.md +812 -0
- data/lib/ruby_reactor/configuration.rb +1 -1
- data/lib/ruby_reactor/context.rb +13 -5
- data/lib/ruby_reactor/context_serializer.rb +55 -4
- data/lib/ruby_reactor/dsl/reactor.rb +3 -2
- data/lib/ruby_reactor/error/step_failure_error.rb +5 -2
- data/lib/ruby_reactor/executor/result_handler.rb +8 -2
- data/lib/ruby_reactor/executor/retry_manager.rb +15 -7
- data/lib/ruby_reactor/executor/step_executor.rb +24 -99
- data/lib/ruby_reactor/executor.rb +3 -13
- data/lib/ruby_reactor/map/collector.rb +16 -15
- data/lib/ruby_reactor/map/element_executor.rb +90 -104
- data/lib/ruby_reactor/map/execution.rb +2 -1
- data/lib/ruby_reactor/map/helpers.rb +2 -1
- data/lib/ruby_reactor/map/result_enumerator.rb +1 -1
- data/lib/ruby_reactor/reactor.rb +174 -16
- data/lib/ruby_reactor/rspec/helpers.rb +17 -0
- data/lib/ruby_reactor/rspec/matchers.rb +256 -0
- data/lib/ruby_reactor/rspec/step_executor_patch.rb +85 -0
- data/lib/ruby_reactor/rspec/test_subject.rb +625 -0
- data/lib/ruby_reactor/rspec.rb +18 -0
- data/lib/ruby_reactor/{async_router.rb → sidekiq_adapter.rb} +10 -5
- data/lib/ruby_reactor/sidekiq_workers/worker.rb +1 -3
- data/lib/ruby_reactor/step/compose_step.rb +0 -1
- data/lib/ruby_reactor/step/map_step.rb +11 -18
- data/lib/ruby_reactor/version.rb +1 -1
- data/lib/ruby_reactor/web/api.rb +32 -24
- data/lib/ruby_reactor.rb +70 -10
- metadata +9 -3
|
@@ -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 |
|