ruby_reactor 0.3.0 → 0.3.2
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 +145 -9
- data/documentation/README.md +20 -8
- data/documentation/async_reactors.md +46 -34
- data/documentation/core_concepts.md +75 -61
- data/documentation/examples/inventory_management.md +2 -3
- data/documentation/examples/order_processing.md +92 -77
- data/documentation/examples/payment_processing.md +28 -117
- data/documentation/getting_started.md +112 -94
- data/documentation/interrupts.md +9 -7
- data/documentation/locks_and_semaphores.md +459 -0
- data/documentation/retry_configuration.md +19 -14
- data/documentation/testing.md +994 -0
- data/lib/ruby_reactor/configuration.rb +19 -2
- data/lib/ruby_reactor/context.rb +13 -5
- data/lib/ruby_reactor/context_serializer.rb +55 -4
- data/lib/ruby_reactor/dsl/lockable.rb +130 -0
- 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 +27 -2
- data/lib/ruby_reactor/executor/retry_manager.rb +15 -7
- data/lib/ruby_reactor/executor/step_executor.rb +29 -99
- data/lib/ruby_reactor/executor.rb +148 -15
- data/lib/ruby_reactor/lock.rb +92 -0
- 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/period.rb +67 -0
- data/lib/ruby_reactor/rate_limit.rb +74 -0
- data/lib/ruby_reactor/reactor.rb +175 -16
- data/lib/ruby_reactor/rspec/helpers.rb +17 -0
- data/lib/ruby_reactor/rspec/matchers.rb +423 -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/semaphore.rb +58 -0
- data/lib/ruby_reactor/{async_router.rb → sidekiq_adapter.rb} +10 -5
- data/lib/ruby_reactor/sidekiq_workers/worker.rb +69 -9
- data/lib/ruby_reactor/step/compose_step.rb +0 -1
- data/lib/ruby_reactor/step/map_step.rb +11 -18
- data/lib/ruby_reactor/storage/redis_adapter.rb +2 -0
- data/lib/ruby_reactor/storage/redis_locking.rb +251 -0
- data/lib/ruby_reactor/version.rb +1 -1
- data/lib/ruby_reactor/web/api.rb +32 -24
- data/lib/ruby_reactor.rb +119 -10
- metadata +16 -3
|
@@ -0,0 +1,994 @@
|
|
|
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
|
+
> For reactors that use `with_lock`, `with_semaphore`, `with_rate_limit`, or `with_period`, see [Testing Coordination Primitives](#testing-coordination-primitives) — it covers the `be_skipped`, `be_locked`, `have_available_tokens`, `have_held_tokens`, `have_rate_limit_count`, and `be_period_marked` matchers, plus patterns for testing async snooze and escalation.
|
|
20
|
+
|
|
21
|
+
## Basic Usage
|
|
22
|
+
|
|
23
|
+
### The `test_reactor` Helper
|
|
24
|
+
|
|
25
|
+
The primary interface for testing reactors is the `test_reactor` helper, which returns a `TestSubject` instance:
|
|
26
|
+
|
|
27
|
+
```ruby
|
|
28
|
+
RSpec.describe MyReactor do
|
|
29
|
+
it "processes successfully" do
|
|
30
|
+
subject = test_reactor(MyReactor, email: "test@example.com")
|
|
31
|
+
|
|
32
|
+
expect(subject).to be_success
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
### TestSubject Configuration
|
|
38
|
+
|
|
39
|
+
The `test_reactor` helper accepts the following options:
|
|
40
|
+
|
|
41
|
+
| Option | Type | Description |
|
|
42
|
+
|--------|------|-------------|
|
|
43
|
+
| `inputs` | Hash | The inputs to pass to the reactor |
|
|
44
|
+
| `context` | Hash | Optional context data for the execution |
|
|
45
|
+
| `async` | Boolean | Force async (`true`) or sync (`false`) execution |
|
|
46
|
+
| `process_jobs` | Boolean | Whether to automatically process Sidekiq jobs (default: `true`) |
|
|
47
|
+
|
|
48
|
+
```ruby
|
|
49
|
+
# Force synchronous execution
|
|
50
|
+
subject = test_reactor(MyReactor, { user_id: 1 }, async: false)
|
|
51
|
+
|
|
52
|
+
# With additional context
|
|
53
|
+
subject = test_reactor(MyReactor, { user_id: 1 }, context: { tenant_id: 42 })
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
---
|
|
57
|
+
|
|
58
|
+
## Execution Control
|
|
59
|
+
|
|
60
|
+
### Running the Reactor
|
|
61
|
+
|
|
62
|
+
The `TestSubject` automatically runs the reactor when you access introspection methods. You can also run it explicitly:
|
|
63
|
+
|
|
64
|
+
```ruby
|
|
65
|
+
subject = test_reactor(MyReactor, params)
|
|
66
|
+
subject.run # Explicit execution
|
|
67
|
+
|
|
68
|
+
# Chaining is supported
|
|
69
|
+
subject.run_async(false).run
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### Async Mode Control
|
|
73
|
+
|
|
74
|
+
Control whether the reactor runs asynchronously:
|
|
75
|
+
|
|
76
|
+
```ruby
|
|
77
|
+
# Force synchronous execution (useful for step-by-step debugging)
|
|
78
|
+
subject = test_reactor(MyReactor, params, async: false)
|
|
79
|
+
|
|
80
|
+
# Or use the fluent API
|
|
81
|
+
subject = test_reactor(MyReactor, params).run_async(false)
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### Sidekiq Job Processing
|
|
85
|
+
|
|
86
|
+
By default, `TestSubject` automatically processes Sidekiq jobs in fake mode. This ensures that async steps complete during the test:
|
|
87
|
+
|
|
88
|
+
```ruby
|
|
89
|
+
# Jobs are processed automatically
|
|
90
|
+
subject = test_reactor(AsyncReactor, params)
|
|
91
|
+
expect(subject).to be_success # All async steps completed
|
|
92
|
+
|
|
93
|
+
# Disable automatic job processing
|
|
94
|
+
subject = test_reactor(AsyncReactor, params, process_jobs: false)
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
---
|
|
98
|
+
|
|
99
|
+
## Introspection
|
|
100
|
+
|
|
101
|
+
### Accessing Results
|
|
102
|
+
|
|
103
|
+
Once executed, you can inspect the reactor's result:
|
|
104
|
+
|
|
105
|
+
```ruby
|
|
106
|
+
subject = test_reactor(MyReactor, params)
|
|
107
|
+
|
|
108
|
+
# Check overall status
|
|
109
|
+
subject.success? # => true/false
|
|
110
|
+
subject.failure? # => true/false
|
|
111
|
+
|
|
112
|
+
# Get the final result object
|
|
113
|
+
subject.result # => Success or Failure
|
|
114
|
+
|
|
115
|
+
# Get any error that occurred
|
|
116
|
+
subject.error # => error message or nil
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
### Step Results
|
|
120
|
+
|
|
121
|
+
Access the result of individual steps:
|
|
122
|
+
|
|
123
|
+
```ruby
|
|
124
|
+
subject = test_reactor(OrderReactor, order_id: 123)
|
|
125
|
+
|
|
126
|
+
# Get a specific step's result
|
|
127
|
+
user = subject.step_result(:validate_user)
|
|
128
|
+
order = subject.step_result(:fetch_order)
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
### Reactor Instance
|
|
132
|
+
|
|
133
|
+
Access the underlying reactor instance for advanced introspection:
|
|
134
|
+
|
|
135
|
+
```ruby
|
|
136
|
+
subject = test_reactor(MyReactor, params)
|
|
137
|
+
|
|
138
|
+
# Access the reactor instance
|
|
139
|
+
subject.reactor_instance
|
|
140
|
+
|
|
141
|
+
# Access context data
|
|
142
|
+
subject.reactor_instance.context.execution_trace
|
|
143
|
+
subject.reactor_instance.context.intermediate_results
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
---
|
|
147
|
+
|
|
148
|
+
## Step Mocking
|
|
149
|
+
|
|
150
|
+
### Basic Step Mocking
|
|
151
|
+
|
|
152
|
+
Use `mock_step` to intercept and replace step implementations:
|
|
153
|
+
|
|
154
|
+
```ruby
|
|
155
|
+
subject = test_reactor(PaymentReactor, order_id: 123)
|
|
156
|
+
.mock_step(:charge_card) do |args, context|
|
|
157
|
+
# Custom implementation
|
|
158
|
+
Success({ transaction_id: "test-123" })
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
expect(subject).to be_success
|
|
162
|
+
expect(subject.step_result(:charge_card)).to eq({ transaction_id: "test-123" })
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
### Chaining Multiple Mocks
|
|
166
|
+
|
|
167
|
+
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:
|
|
168
|
+
|
|
169
|
+
```ruby
|
|
170
|
+
RSpec.describe MultipleRequestsReactor do
|
|
171
|
+
subject(:reactor) do
|
|
172
|
+
test_reactor(described_class, request_id: 1)
|
|
173
|
+
.mock_step(:call_service_1) { |args| Success(args[:request_id]) }
|
|
174
|
+
.mock_step(:call_service_2) { |args| Success(args[:request_id]) }
|
|
175
|
+
.mock_step(:call_service_3) { |args| Success(args[:request_id]) }
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
it "processes successfully with all mocked services" do
|
|
179
|
+
expect(reactor).to be_success
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
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.
|
|
185
|
+
|
|
186
|
+
### Wrapping Original Implementations
|
|
187
|
+
|
|
188
|
+
The mock block receives a third parameter that allows calling the original implementation:
|
|
189
|
+
|
|
190
|
+
```ruby
|
|
191
|
+
subject = test_reactor(MyReactor, params)
|
|
192
|
+
.mock_step(:some_step) do |args, context, original|
|
|
193
|
+
# Modify args before calling original
|
|
194
|
+
modified_args = args.merge(extra: "value")
|
|
195
|
+
result = original.call(modified_args, context)
|
|
196
|
+
|
|
197
|
+
# Post-process the result
|
|
198
|
+
result
|
|
199
|
+
end
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
### Simulating Failures
|
|
203
|
+
|
|
204
|
+
#### Using `failing_at`
|
|
205
|
+
|
|
206
|
+
The `failing_at` method provides a simple way to simulate step failures:
|
|
207
|
+
|
|
208
|
+
```ruby
|
|
209
|
+
subject = test_reactor(OrderReactor, params)
|
|
210
|
+
.failing_at(:process_payment)
|
|
211
|
+
|
|
212
|
+
expect(subject).to be_failure
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
#### Raising Exceptions in Mocks
|
|
216
|
+
|
|
217
|
+
For more control, raise exceptions within mock blocks:
|
|
218
|
+
|
|
219
|
+
```ruby
|
|
220
|
+
subject = test_reactor(PaymentReactor, params)
|
|
221
|
+
.mock_step(:charge_card) do |args, context|
|
|
222
|
+
raise PaymentDeclinedError, "Card was declined"
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
expect(subject).to be_failure
|
|
226
|
+
expect(subject.error).to include("Card was declined")
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
---
|
|
230
|
+
|
|
231
|
+
## Nested Reactor Testing
|
|
232
|
+
|
|
233
|
+
### Testing Composed Reactors
|
|
234
|
+
|
|
235
|
+
When testing reactors that use `compose`, you can mock steps within the composed reactor:
|
|
236
|
+
|
|
237
|
+
```ruby
|
|
238
|
+
# Parent reactor composes ChildReactor at :process_child step
|
|
239
|
+
subject = test_reactor(ParentReactor, params)
|
|
240
|
+
.composed(:process_child)
|
|
241
|
+
.mock_step(:inner_step) do |args, context|
|
|
242
|
+
Success("mocked inner result")
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
expect(subject).to be_success
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
### Traversing Composed Results
|
|
249
|
+
|
|
250
|
+
After execution, traverse into composed reactor results:
|
|
251
|
+
|
|
252
|
+
```ruby
|
|
253
|
+
subject = test_reactor(ParentReactor, params)
|
|
254
|
+
subject.run
|
|
255
|
+
|
|
256
|
+
# Get the composed reactor's TestSubject
|
|
257
|
+
child_subject = subject.composed(:process_child)
|
|
258
|
+
expect(child_subject.step_result(:inner_step)).to eq("expected value")
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
### Testing Map Steps
|
|
262
|
+
|
|
263
|
+
For reactors using `map`, you can access individual element results:
|
|
264
|
+
|
|
265
|
+
```ruby
|
|
266
|
+
subject = test_reactor(BatchProcessor, items: [1, 2, 3])
|
|
267
|
+
subject.run
|
|
268
|
+
|
|
269
|
+
# Access all map element subjects
|
|
270
|
+
elements = subject.map_elements(:process_items)
|
|
271
|
+
expect(elements.length).to eq(3)
|
|
272
|
+
|
|
273
|
+
# Access a specific element by index
|
|
274
|
+
first_element = subject.map_element(:process_items, index: 0)
|
|
275
|
+
expect(first_element).to be_success
|
|
276
|
+
|
|
277
|
+
# Mock within map elements
|
|
278
|
+
subject = test_reactor(BatchProcessor, items: [1, 2, 3])
|
|
279
|
+
.map(:process_items)
|
|
280
|
+
.mock_step(:transform) do |args, context|
|
|
281
|
+
Success(args[:item] * 2)
|
|
282
|
+
end
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
---
|
|
286
|
+
|
|
287
|
+
## Custom RSpec Matchers
|
|
288
|
+
|
|
289
|
+
RubyReactor provides expressive matchers for common assertions:
|
|
290
|
+
|
|
291
|
+
### Success and Failure Matchers
|
|
292
|
+
|
|
293
|
+
```ruby
|
|
294
|
+
# Check if reactor succeeded
|
|
295
|
+
expect(subject).to be_success
|
|
296
|
+
|
|
297
|
+
# Check if reactor failed
|
|
298
|
+
expect(subject).to be_failure
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
The `be_success` matcher provides detailed failure messages when a reactor fails:
|
|
302
|
+
|
|
303
|
+
```ruby
|
|
304
|
+
# Example failure output:
|
|
305
|
+
# Error: PaymentDeclinedError
|
|
306
|
+
# Card was declined
|
|
307
|
+
# Step: :charge_card
|
|
308
|
+
# File: /app/reactors/payment_reactor.rb:45
|
|
309
|
+
#
|
|
310
|
+
# def charge_card
|
|
311
|
+
# --> PaymentGateway.charge(amount, token)
|
|
312
|
+
# end
|
|
313
|
+
#
|
|
314
|
+
# Backtrace:
|
|
315
|
+
# - /app/reactors/payment_reactor.rb:45
|
|
316
|
+
# - /app/services/payment_gateway.rb:12
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
### Step Execution Matchers
|
|
320
|
+
|
|
321
|
+
#### `have_run_step`
|
|
322
|
+
|
|
323
|
+
Verify that a specific step was executed:
|
|
324
|
+
|
|
325
|
+
```ruby
|
|
326
|
+
expect(subject).to have_run_step(:validate_user)
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
#### With Return Value
|
|
330
|
+
|
|
331
|
+
```ruby
|
|
332
|
+
expect(subject).to have_run_step(:validate_user).returning({ id: 1, name: "Alice" })
|
|
333
|
+
|
|
334
|
+
# Works with regex for partial matching
|
|
335
|
+
expect(subject).to have_run_step(:generate_token).returning(/^token_/)
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
#### With Execution Order
|
|
339
|
+
|
|
340
|
+
```ruby
|
|
341
|
+
expect(subject).to have_run_step(:send_email).after(:create_user)
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
#### Combined Assertions
|
|
345
|
+
|
|
346
|
+
```ruby
|
|
347
|
+
expect(subject).to have_run_step(:process_payment)
|
|
348
|
+
.returning({ status: "completed" })
|
|
349
|
+
.after(:validate_order)
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
### Retry Matchers
|
|
353
|
+
|
|
354
|
+
#### `have_retried_step`
|
|
355
|
+
|
|
356
|
+
Verify that a step was retried:
|
|
357
|
+
|
|
358
|
+
```ruby
|
|
359
|
+
expect(subject).to have_retried_step(:flaky_api_call)
|
|
360
|
+
|
|
361
|
+
# With specific retry count
|
|
362
|
+
expect(subject).to have_retried_step(:flaky_api_call).times(3)
|
|
363
|
+
```
|
|
364
|
+
|
|
365
|
+
### Validation Matchers
|
|
366
|
+
|
|
367
|
+
#### `have_validation_error`
|
|
368
|
+
|
|
369
|
+
Check for input validation errors:
|
|
370
|
+
|
|
371
|
+
```ruby
|
|
372
|
+
subject = test_reactor(UserReactor, email: "invalid", age: 10)
|
|
373
|
+
|
|
374
|
+
expect(subject).to be_failure
|
|
375
|
+
expect(subject).to have_validation_error(:email)
|
|
376
|
+
expect(subject).to have_validation_error(:age)
|
|
377
|
+
```
|
|
378
|
+
|
|
379
|
+
---
|
|
380
|
+
|
|
381
|
+
## Testing Interrupts
|
|
382
|
+
|
|
383
|
+
RubyReactor provides comprehensive test helpers for testing reactors that use the `interrupt` DSL for pause/resume workflows.
|
|
384
|
+
|
|
385
|
+
### Interrupt State Introspection
|
|
386
|
+
|
|
387
|
+
#### Checking Paused State
|
|
388
|
+
|
|
389
|
+
```ruby
|
|
390
|
+
subject = test_reactor(ApprovalWorkflow, request_id: 123)
|
|
391
|
+
|
|
392
|
+
# Check if reactor is paused
|
|
393
|
+
subject.paused? # => true/false
|
|
394
|
+
|
|
395
|
+
# Get the current interrupt step name
|
|
396
|
+
subject.current_step # => :wait_for_approval (Symbol) or nil
|
|
397
|
+
```
|
|
398
|
+
|
|
399
|
+
#### Getting Ready Interrupt Steps
|
|
400
|
+
|
|
401
|
+
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:
|
|
402
|
+
|
|
403
|
+
```ruby
|
|
404
|
+
subject = test_reactor(MultiApprovalWorkflow, params)
|
|
405
|
+
|
|
406
|
+
# Get all ready interrupt steps
|
|
407
|
+
subject.ready_interrupt_steps # => [:manager_approval, :director_approval]
|
|
408
|
+
```
|
|
409
|
+
|
|
410
|
+
### Resuming Paused Reactors
|
|
411
|
+
|
|
412
|
+
Use `resume` to continue execution with a payload:
|
|
413
|
+
|
|
414
|
+
```ruby
|
|
415
|
+
# Single interrupt - step is automatically detected
|
|
416
|
+
subject = test_reactor(ApprovalWorkflow, request_id: 123)
|
|
417
|
+
expect(subject).to be_paused
|
|
418
|
+
|
|
419
|
+
subject.resume(payload: { approved: true, approver: "manager" })
|
|
420
|
+
|
|
421
|
+
expect(subject).to be_success
|
|
422
|
+
```
|
|
423
|
+
|
|
424
|
+
#### Multiple Concurrent Interrupts
|
|
425
|
+
|
|
426
|
+
When multiple interrupts are ready, you **must** specify which step to resume:
|
|
427
|
+
|
|
428
|
+
```ruby
|
|
429
|
+
subject = test_reactor(MultiApprovalWorkflow, params)
|
|
430
|
+
|
|
431
|
+
# This will raise an error - ambiguous which interrupt to resume
|
|
432
|
+
# subject.resume(payload: { status: "approved" }) # => Error!
|
|
433
|
+
|
|
434
|
+
# Specify the step explicitly
|
|
435
|
+
subject.resume(step: :manager_approval, payload: { status: "approved" })
|
|
436
|
+
subject.resume(step: :director_approval, payload: { status: "approved" })
|
|
437
|
+
|
|
438
|
+
expect(subject).to be_success
|
|
439
|
+
```
|
|
440
|
+
|
|
441
|
+
### Interrupt Matchers
|
|
442
|
+
|
|
443
|
+
#### `be_paused`
|
|
444
|
+
|
|
445
|
+
Assert that a reactor is in paused state:
|
|
446
|
+
|
|
447
|
+
```ruby
|
|
448
|
+
expect(subject).to be_paused
|
|
449
|
+
expect(subject).not_to be_paused
|
|
450
|
+
```
|
|
451
|
+
|
|
452
|
+
#### `be_paused_at`
|
|
453
|
+
|
|
454
|
+
Assert that a reactor is paused with specific interrupt(s) ready:
|
|
455
|
+
|
|
456
|
+
```ruby
|
|
457
|
+
# Single interrupt
|
|
458
|
+
expect(subject).to be_paused_at(:wait_for_approval)
|
|
459
|
+
|
|
460
|
+
# Check if specific interrupt is among the ready ones
|
|
461
|
+
expect(subject).to be_paused_at(:manager_approval)
|
|
462
|
+
|
|
463
|
+
# Check multiple interrupts are ready
|
|
464
|
+
expect(subject).to be_paused_at(:manager_approval, :director_approval)
|
|
465
|
+
```
|
|
466
|
+
|
|
467
|
+
#### `have_ready_interrupts`
|
|
468
|
+
|
|
469
|
+
Assert the exact set of ready interrupt steps:
|
|
470
|
+
|
|
471
|
+
```ruby
|
|
472
|
+
# Assert exactly these interrupts are ready
|
|
473
|
+
expect(subject).to have_ready_interrupts(:manager_approval, :director_approval)
|
|
474
|
+
```
|
|
475
|
+
|
|
476
|
+
### Complete Interrupt Testing Example
|
|
477
|
+
|
|
478
|
+
```ruby
|
|
479
|
+
RSpec.describe ApprovalWorkflow do
|
|
480
|
+
describe "single approval" do
|
|
481
|
+
it "pauses at approval step and resumes" do
|
|
482
|
+
subject = test_reactor(ApprovalWorkflow, request_id: 123)
|
|
483
|
+
|
|
484
|
+
# Verify paused state
|
|
485
|
+
expect(subject).to be_paused
|
|
486
|
+
expect(subject).to be_paused_at(:wait_for_approval)
|
|
487
|
+
expect(subject.current_step).to eq(:wait_for_approval)
|
|
488
|
+
|
|
489
|
+
# Step results before interrupt should be available
|
|
490
|
+
expect(subject.step_result(:prepare_request)).to be_present
|
|
491
|
+
|
|
492
|
+
# Resume with approval payload
|
|
493
|
+
subject.resume(payload: { approved: true, approver: "manager" })
|
|
494
|
+
|
|
495
|
+
# Should complete after resume
|
|
496
|
+
expect(subject).to be_success
|
|
497
|
+
expect(subject.step_result(:finalize)).to include(approved: true)
|
|
498
|
+
end
|
|
499
|
+
|
|
500
|
+
it "stays paused with invalid payload" do
|
|
501
|
+
subject = test_reactor(ApprovalWorkflow, request_id: 123)
|
|
502
|
+
|
|
503
|
+
# Resume with invalid payload (fails validation)
|
|
504
|
+
expect {
|
|
505
|
+
subject.resume(payload: { invalid: "data" })
|
|
506
|
+
}.to raise_error(RubyReactor::Error::ValidationError)
|
|
507
|
+
end
|
|
508
|
+
end
|
|
509
|
+
|
|
510
|
+
describe "multiple approvals" do
|
|
511
|
+
it "handles concurrent approval requirements" do
|
|
512
|
+
subject = test_reactor(MultiApprovalWorkflow, request_id: 456)
|
|
513
|
+
|
|
514
|
+
# Verify multiple interrupts are ready
|
|
515
|
+
expect(subject).to be_paused
|
|
516
|
+
expect(subject).to have_ready_interrupts(:manager_approval, :director_approval)
|
|
517
|
+
|
|
518
|
+
# Resume first approval
|
|
519
|
+
subject.resume(step: :manager_approval, payload: { status: "approved" })
|
|
520
|
+
|
|
521
|
+
# Still paused, waiting for second approval
|
|
522
|
+
expect(subject).to be_paused
|
|
523
|
+
expect(subject.ready_interrupt_steps).to eq([:director_approval])
|
|
524
|
+
|
|
525
|
+
# Complete second approval
|
|
526
|
+
subject.resume(step: :director_approval, payload: { status: "approved" })
|
|
527
|
+
|
|
528
|
+
expect(subject).to be_success
|
|
529
|
+
end
|
|
530
|
+
|
|
531
|
+
it "requires step specification for multiple interrupts" do
|
|
532
|
+
subject = test_reactor(MultiApprovalWorkflow, request_id: 456)
|
|
533
|
+
|
|
534
|
+
# Without step: parameter, raises error
|
|
535
|
+
expect {
|
|
536
|
+
subject.resume(payload: { status: "approved" })
|
|
537
|
+
}.to raise_error(
|
|
538
|
+
RubyReactor::Error::ValidationError,
|
|
539
|
+
/multiple interrupt steps are ready/
|
|
540
|
+
)
|
|
541
|
+
end
|
|
542
|
+
end
|
|
543
|
+
end
|
|
544
|
+
```
|
|
545
|
+
|
|
546
|
+
---
|
|
547
|
+
|
|
548
|
+
## Testing Coordination Primitives
|
|
549
|
+
|
|
550
|
+
Reactors that declare `with_lock`, `with_semaphore`, `with_rate_limit`, or `with_period` can be tested with both vanilla execution assertions and dedicated state matchers that read the live Redis state via the configured storage adapter.
|
|
551
|
+
|
|
552
|
+
### Test environment requirements
|
|
553
|
+
|
|
554
|
+
The matchers ship with the standard test setup — once `RubyReactor::RSpec.configure(config)` runs in your `spec_helper.rb`, they're available. They require:
|
|
555
|
+
|
|
556
|
+
- A real Redis (the in-memory test mode does not back the primitives).
|
|
557
|
+
- A clean Redis between tests — typically `redis.flushdb` in a `before` block — so leftover lock owners, semaphore tokens, rate-limit counters and period markers don't leak across examples.
|
|
558
|
+
|
|
559
|
+
### The `Skipped` result
|
|
560
|
+
|
|
561
|
+
`RubyReactor::Skipped` is a `Success` subclass returned in two cases: a `with_period` bucket has already been claimed, or a step explicitly returns `RubyReactor.Skipped(reason: "...")` to halt cleanly (no compensation runs). Use `be_skipped` to distinguish it from a plain `Success`:
|
|
562
|
+
|
|
563
|
+
```ruby
|
|
564
|
+
result = MonthlyReportReactor.run(org_id: 7)
|
|
565
|
+
expect(result).to be_skipped # any Skipped
|
|
566
|
+
expect(result).to be_skipped.because(:period) # gate hit
|
|
567
|
+
expect(result).to be_skipped.at_step(:second) # step return
|
|
568
|
+
```
|
|
569
|
+
|
|
570
|
+
`Skipped` still satisfies `success?`, so legacy `if result.success?` callers continue to work; `result.skipped?` discriminates.
|
|
571
|
+
|
|
572
|
+
### Asserting lock state
|
|
573
|
+
|
|
574
|
+
```ruby
|
|
575
|
+
it "releases the lock after a successful run" do
|
|
576
|
+
RefundOrderReactor.run(order_id: 42)
|
|
577
|
+
|
|
578
|
+
expect("order:42").not_to be_locked
|
|
579
|
+
end
|
|
580
|
+
|
|
581
|
+
it "holds the lock for the duration of a long step" do
|
|
582
|
+
thread = Thread.new { LongRefundReactor.run(order_id: 42) }
|
|
583
|
+
# Give the executor a moment to acquire
|
|
584
|
+
sleep 0.05
|
|
585
|
+
|
|
586
|
+
expect("order:42").to be_locked
|
|
587
|
+
expect("order:42").to be_locked.by(thread.value.context.context_id)
|
|
588
|
+
end
|
|
589
|
+
|
|
590
|
+
it "raises when contention is hit inline" do
|
|
591
|
+
redis.hset("lock:order:42", "owner", "someone_else")
|
|
592
|
+
redis.hset("lock:order:42", "count", "1")
|
|
593
|
+
|
|
594
|
+
expect { RefundOrderReactor.run(order_id: 42) }
|
|
595
|
+
.to raise_error(RubyReactor::Lock::AcquisitionError)
|
|
596
|
+
end
|
|
597
|
+
```
|
|
598
|
+
|
|
599
|
+
The `be_locked` matcher takes the **user-provided lock key** (without the internal `lock:` prefix). Use the `.by(owner)` chain to assert ownership — typically the `context_id` of the top-level execution.
|
|
600
|
+
|
|
601
|
+
### Asserting semaphore state
|
|
602
|
+
|
|
603
|
+
```ruby
|
|
604
|
+
it "returns the token to the pool on success" do
|
|
605
|
+
3.times { ApiCallReactor.run }
|
|
606
|
+
|
|
607
|
+
expect("api_limit").to have_available_tokens(5)
|
|
608
|
+
expect("api_limit").to have_held_tokens(0)
|
|
609
|
+
end
|
|
610
|
+
|
|
611
|
+
it "exhausts capacity when held externally" do
|
|
612
|
+
s = RubyReactor::Semaphore.new("api_limit", limit: 2)
|
|
613
|
+
2.times { s.acquire }
|
|
614
|
+
|
|
615
|
+
expect("api_limit").to have_available_tokens(0)
|
|
616
|
+
expect("api_limit").to have_held_tokens(2)
|
|
617
|
+
|
|
618
|
+
expect { ApiCallReactor.run }.to raise_error(RubyReactor::Semaphore::AcquisitionError)
|
|
619
|
+
end
|
|
620
|
+
```
|
|
621
|
+
|
|
622
|
+
Both matchers take the **user-provided semaphore name** (without the `semaphore:` prefix).
|
|
623
|
+
|
|
624
|
+
### Asserting rate-limit state
|
|
625
|
+
|
|
626
|
+
```ruby
|
|
627
|
+
it "counts each call against the per-second window" do
|
|
628
|
+
3.times { ChargeReactor.run(account_id: 42) }
|
|
629
|
+
|
|
630
|
+
expect("stripe:42").to have_rate_limit_count(3).for(:second)
|
|
631
|
+
end
|
|
632
|
+
|
|
633
|
+
it "snoozes inline once the window is full" do
|
|
634
|
+
3.times { ChargeReactor.run(account_id: 42) }
|
|
635
|
+
|
|
636
|
+
expect { ChargeReactor.run(account_id: 42) }
|
|
637
|
+
.to raise_error(RubyReactor::RateLimit::ExceededError) do |e|
|
|
638
|
+
expect(e.period_name).to eq("second")
|
|
639
|
+
expect(e.retry_after_seconds).to be_between(1, 1)
|
|
640
|
+
end
|
|
641
|
+
end
|
|
642
|
+
```
|
|
643
|
+
|
|
644
|
+
`have_rate_limit_count(n).for(period)` looks at the **current** bucket for the given `period` (use the same symbol or integer seconds you passed to `with_rate_limit`). For multi-window limits, assert each window separately:
|
|
645
|
+
|
|
646
|
+
```ruby
|
|
647
|
+
expect("stripe:42").to have_rate_limit_count(3).for(:second)
|
|
648
|
+
expect("stripe:42").to have_rate_limit_count(3).for(:minute)
|
|
649
|
+
```
|
|
650
|
+
|
|
651
|
+
### Asserting period markers
|
|
652
|
+
|
|
653
|
+
```ruby
|
|
654
|
+
it "marks the bucket after the first successful run" do
|
|
655
|
+
MonthlyReportReactor.run(org_id: 7)
|
|
656
|
+
expect("monthly_report:7").to be_period_marked.for(:month)
|
|
657
|
+
end
|
|
658
|
+
|
|
659
|
+
it "does not mark the bucket when the run fails" do
|
|
660
|
+
FailingMonthlyReactor.run(org_id: 7)
|
|
661
|
+
expect("monthly_report:7").not_to be_period_marked.for(:month)
|
|
662
|
+
end
|
|
663
|
+
|
|
664
|
+
it "skips a second call in the same bucket" do
|
|
665
|
+
MonthlyReportReactor.run(org_id: 7)
|
|
666
|
+
result = MonthlyReportReactor.run(org_id: 7)
|
|
667
|
+
|
|
668
|
+
expect(result).to be_skipped.because(:period)
|
|
669
|
+
end
|
|
670
|
+
```
|
|
671
|
+
|
|
672
|
+
`be_period_marked.for(period)` checks the marker at the **current** bucket. To verify the marker's TTL behavior, drop to direct Redis: `redis.ttl(RubyReactor::Period.key("monthly_report:7", :month))`.
|
|
673
|
+
|
|
674
|
+
### Testing async snooze behavior
|
|
675
|
+
|
|
676
|
+
The Sidekiq worker rescues `Lock::AcquisitionError`, `Semaphore::AcquisitionError`, and `RateLimit::ExceededError` and reschedules via `perform_in`. To test that wiring without spinning Sidekiq:
|
|
677
|
+
|
|
678
|
+
```ruby
|
|
679
|
+
it "reschedules with retry_after on rate limit hits" do
|
|
680
|
+
redis.set("rate:stripe:42:second:#{Time.now.to_i}", "999")
|
|
681
|
+
|
|
682
|
+
serialized = RubyReactor::ContextSerializer.serialize(
|
|
683
|
+
RubyReactor::Context.new({ account_id: 42 }, ChargeReactor)
|
|
684
|
+
)
|
|
685
|
+
|
|
686
|
+
expect(RubyReactor::SidekiqWorkers::Worker)
|
|
687
|
+
.to receive(:perform_in)
|
|
688
|
+
.with(a_value_between(1.0, 2.0), serialized, "ChargeReactor", 1)
|
|
689
|
+
|
|
690
|
+
RubyReactor::SidekiqWorkers::Worker.new.perform(serialized, "ChargeReactor")
|
|
691
|
+
end
|
|
692
|
+
```
|
|
693
|
+
|
|
694
|
+
For lock/semaphore the same pattern works — the `perform_in` delay is `lock_snooze_base_delay + rand(0..lock_snooze_jitter)`. Pin those knobs to deterministic values (`jitter = 0`) in tests that assert on the exact delay.
|
|
695
|
+
|
|
696
|
+
### Testing snooze escalation
|
|
697
|
+
|
|
698
|
+
When the snooze cap (`lock_snooze_max_attempts`) is reached, the worker stops rescheduling and marks the context as failed:
|
|
699
|
+
|
|
700
|
+
```ruby
|
|
701
|
+
it "marks the context as failed after the snooze cap" do
|
|
702
|
+
RubyReactor.configuration.lock_snooze_max_attempts = 3
|
|
703
|
+
|
|
704
|
+
redis.hset("lock:order:42", "owner", "other")
|
|
705
|
+
redis.hset("lock:order:42", "count", "1")
|
|
706
|
+
|
|
707
|
+
context = RubyReactor::Context.new({ order_id: 42 }, RefundOrderReactor)
|
|
708
|
+
serialized = RubyReactor::ContextSerializer.serialize(context)
|
|
709
|
+
|
|
710
|
+
expect(RubyReactor::SidekiqWorkers::Worker).not_to receive(:perform_in)
|
|
711
|
+
|
|
712
|
+
RubyReactor::SidekiqWorkers::Worker.new
|
|
713
|
+
.perform(serialized, "RefundOrderReactor", 3)
|
|
714
|
+
end
|
|
715
|
+
```
|
|
716
|
+
|
|
717
|
+
## Complete Examples
|
|
718
|
+
|
|
719
|
+
### Testing a Payment Workflow
|
|
720
|
+
|
|
721
|
+
```ruby
|
|
722
|
+
RSpec.describe PaymentWorkflow do
|
|
723
|
+
describe "successful payment" do
|
|
724
|
+
it "processes payment and creates invoice" do
|
|
725
|
+
subject = test_reactor(PaymentWorkflow,
|
|
726
|
+
order_id: 123,
|
|
727
|
+
amount: 99.99,
|
|
728
|
+
card_token: "tok_visa"
|
|
729
|
+
)
|
|
730
|
+
|
|
731
|
+
expect(subject).to be_success
|
|
732
|
+
expect(subject).to have_run_step(:validate_order)
|
|
733
|
+
expect(subject).to have_run_step(:charge_card).after(:validate_order)
|
|
734
|
+
expect(subject).to have_run_step(:create_invoice).after(:charge_card)
|
|
735
|
+
|
|
736
|
+
expect(subject.step_result(:charge_card)).to include(
|
|
737
|
+
status: "succeeded"
|
|
738
|
+
)
|
|
739
|
+
end
|
|
740
|
+
end
|
|
741
|
+
|
|
742
|
+
describe "payment failure" do
|
|
743
|
+
it "handles declined cards gracefully" do
|
|
744
|
+
subject = test_reactor(PaymentWorkflow,
|
|
745
|
+
order_id: 123,
|
|
746
|
+
amount: 99.99,
|
|
747
|
+
card_token: "tok_declined"
|
|
748
|
+
).mock_step(:charge_card) do |args, context|
|
|
749
|
+
Failure("Card declined", code: "card_declined")
|
|
750
|
+
end
|
|
751
|
+
|
|
752
|
+
expect(subject).to be_failure
|
|
753
|
+
expect(subject.error).to include("Card declined")
|
|
754
|
+
end
|
|
755
|
+
end
|
|
756
|
+
|
|
757
|
+
describe "with retries" do
|
|
758
|
+
it "retries on transient failures" do
|
|
759
|
+
attempt = 0
|
|
760
|
+
|
|
761
|
+
subject = test_reactor(PaymentWorkflow, params)
|
|
762
|
+
.mock_step(:charge_card) do |args, context, original|
|
|
763
|
+
attempt += 1
|
|
764
|
+
if attempt < 3
|
|
765
|
+
raise NetworkError, "Connection timeout"
|
|
766
|
+
end
|
|
767
|
+
original.call(args, context)
|
|
768
|
+
end
|
|
769
|
+
|
|
770
|
+
expect(subject).to be_success
|
|
771
|
+
expect(subject).to have_retried_step(:charge_card).times(2)
|
|
772
|
+
end
|
|
773
|
+
end
|
|
774
|
+
end
|
|
775
|
+
```
|
|
776
|
+
|
|
777
|
+
### Testing Composed Reactors
|
|
778
|
+
|
|
779
|
+
```ruby
|
|
780
|
+
RSpec.describe OrderProcessor do
|
|
781
|
+
it "processes order through composed payment reactor" do
|
|
782
|
+
subject = test_reactor(OrderProcessor, order_id: 123)
|
|
783
|
+
.composed(:process_payment)
|
|
784
|
+
.mock_step(:validate_card) do |args, context|
|
|
785
|
+
Success({ valid: true })
|
|
786
|
+
end
|
|
787
|
+
|
|
788
|
+
expect(subject).to be_success
|
|
789
|
+
|
|
790
|
+
# Inspect the composed reactor
|
|
791
|
+
payment = subject.composed(:process_payment)
|
|
792
|
+
expect(payment.step_result(:validate_card)).to eq({ valid: true })
|
|
793
|
+
end
|
|
794
|
+
end
|
|
795
|
+
```
|
|
796
|
+
|
|
797
|
+
### Testing Map Operations
|
|
798
|
+
|
|
799
|
+
```ruby
|
|
800
|
+
RSpec.describe BatchEmailSender do
|
|
801
|
+
it "sends emails to all recipients" do
|
|
802
|
+
recipients = ["a@example.com", "b@example.com", "c@example.com"]
|
|
803
|
+
|
|
804
|
+
subject = test_reactor(BatchEmailSender, recipients: recipients)
|
|
805
|
+
.map(:send_emails)
|
|
806
|
+
.mock_step(:deliver) do |args, context|
|
|
807
|
+
Success({ sent_to: args[:recipient] })
|
|
808
|
+
end
|
|
809
|
+
|
|
810
|
+
expect(subject).to be_success
|
|
811
|
+
|
|
812
|
+
elements = subject.map_elements(:send_emails)
|
|
813
|
+
expect(elements.length).to eq(3)
|
|
814
|
+
expect(elements).to all(be_success)
|
|
815
|
+
end
|
|
816
|
+
|
|
817
|
+
it "handles partial failures" do
|
|
818
|
+
recipients = ["good@example.com", "bad@example.com"]
|
|
819
|
+
|
|
820
|
+
subject = test_reactor(BatchEmailSender, recipients: recipients)
|
|
821
|
+
.map(:send_emails)
|
|
822
|
+
.mock_step(:deliver) do |args, context|
|
|
823
|
+
if args[:recipient].include?("bad")
|
|
824
|
+
Failure("Invalid email")
|
|
825
|
+
else
|
|
826
|
+
Success({ sent_to: args[:recipient] })
|
|
827
|
+
end
|
|
828
|
+
end
|
|
829
|
+
|
|
830
|
+
# Inspect individual results
|
|
831
|
+
elements = subject.map_elements(:send_emails)
|
|
832
|
+
expect(elements.first).to be_success
|
|
833
|
+
expect(elements.last).to be_failure
|
|
834
|
+
end
|
|
835
|
+
end
|
|
836
|
+
```
|
|
837
|
+
|
|
838
|
+
### Testing Input Validation
|
|
839
|
+
|
|
840
|
+
```ruby
|
|
841
|
+
RSpec.describe UserRegistration do
|
|
842
|
+
context "with valid inputs" do
|
|
843
|
+
it "creates the user" do
|
|
844
|
+
subject = test_reactor(UserRegistration,
|
|
845
|
+
email: "valid@example.com",
|
|
846
|
+
password: "secure123",
|
|
847
|
+
age: 25
|
|
848
|
+
)
|
|
849
|
+
|
|
850
|
+
expect(subject).to be_success
|
|
851
|
+
end
|
|
852
|
+
end
|
|
853
|
+
|
|
854
|
+
context "with invalid inputs" do
|
|
855
|
+
it "fails with validation errors" do
|
|
856
|
+
subject = test_reactor(UserRegistration,
|
|
857
|
+
email: "",
|
|
858
|
+
password: "x",
|
|
859
|
+
age: 10
|
|
860
|
+
)
|
|
861
|
+
|
|
862
|
+
expect(subject).to be_failure
|
|
863
|
+
expect(subject).to have_validation_error(:email)
|
|
864
|
+
expect(subject).to have_validation_error(:password)
|
|
865
|
+
expect(subject).to have_validation_error(:age)
|
|
866
|
+
end
|
|
867
|
+
end
|
|
868
|
+
end
|
|
869
|
+
```
|
|
870
|
+
|
|
871
|
+
---
|
|
872
|
+
|
|
873
|
+
## Best Practices
|
|
874
|
+
|
|
875
|
+
### 1. Isolate External Dependencies
|
|
876
|
+
|
|
877
|
+
Mock steps that interact with external services:
|
|
878
|
+
|
|
879
|
+
```ruby
|
|
880
|
+
# Good: Mock external service calls
|
|
881
|
+
subject = test_reactor(PaymentReactor, params)
|
|
882
|
+
.mock_step(:call_stripe_api) { Success(mock_response) }
|
|
883
|
+
|
|
884
|
+
# Avoid: Letting tests hit real APIs
|
|
885
|
+
```
|
|
886
|
+
|
|
887
|
+
### 2. Test Compensation Logic
|
|
888
|
+
|
|
889
|
+
Verify that compensation runs correctly on failure:
|
|
890
|
+
|
|
891
|
+
```ruby
|
|
892
|
+
it "refunds payment when shipping fails" do
|
|
893
|
+
subject = test_reactor(OrderReactor, params)
|
|
894
|
+
.failing_at(:create_shipment)
|
|
895
|
+
|
|
896
|
+
expect(subject).to be_failure
|
|
897
|
+
# Verify compensation occurred through side effects or mocks
|
|
898
|
+
end
|
|
899
|
+
```
|
|
900
|
+
|
|
901
|
+
### 3. Use Descriptive Assertions
|
|
902
|
+
|
|
903
|
+
Combine matchers for clear, intention-revealing tests:
|
|
904
|
+
|
|
905
|
+
```ruby
|
|
906
|
+
# Good: Clear intent
|
|
907
|
+
expect(subject).to have_run_step(:notify_user).after(:create_account)
|
|
908
|
+
|
|
909
|
+
# Less clear: Manual inspection
|
|
910
|
+
trace = subject.reactor_instance.context.execution_trace
|
|
911
|
+
expect(trace.any? { |t| t[:step] == :notify_user }).to be true
|
|
912
|
+
```
|
|
913
|
+
|
|
914
|
+
### 4. Force Synchronous Execution for Debugging
|
|
915
|
+
|
|
916
|
+
When debugging async issues, force synchronous execution:
|
|
917
|
+
|
|
918
|
+
```ruby
|
|
919
|
+
subject = test_reactor(AsyncReactor, params, async: false)
|
|
920
|
+
```
|
|
921
|
+
|
|
922
|
+
### 5. Keep Tests Focused
|
|
923
|
+
|
|
924
|
+
Test one aspect per test case:
|
|
925
|
+
|
|
926
|
+
```ruby
|
|
927
|
+
# Good: Focused tests
|
|
928
|
+
it "validates the order" do
|
|
929
|
+
expect(subject).to have_run_step(:validate_order)
|
|
930
|
+
end
|
|
931
|
+
|
|
932
|
+
it "charges the card after validation" do
|
|
933
|
+
expect(subject).to have_run_step(:charge_card).after(:validate_order)
|
|
934
|
+
end
|
|
935
|
+
|
|
936
|
+
# Avoid: Testing everything in one spec
|
|
937
|
+
it "does the entire workflow correctly" do
|
|
938
|
+
# 50 lines of assertions...
|
|
939
|
+
end
|
|
940
|
+
```
|
|
941
|
+
|
|
942
|
+
---
|
|
943
|
+
|
|
944
|
+
## API Reference
|
|
945
|
+
|
|
946
|
+
### TestSubject Methods
|
|
947
|
+
|
|
948
|
+
| Method | Description |
|
|
949
|
+
|--------|-------------|
|
|
950
|
+
| `run` | Execute the reactor (auto-called by introspection methods) |
|
|
951
|
+
| `run_async(bool)` | Set async execution mode |
|
|
952
|
+
| `mock_step(name, *nested, &block)` | Mock a step's implementation |
|
|
953
|
+
| `failing_at(name, *nested)` | Simulate failure at a step |
|
|
954
|
+
| `map(step_name)` | Get proxy for mocking map step internals |
|
|
955
|
+
| `composed(step_name)` | Get proxy or traverse composed reactor |
|
|
956
|
+
| `result` | Get the final result (Success/Failure/InterruptResult) |
|
|
957
|
+
| `success?` | Check if reactor succeeded |
|
|
958
|
+
| `failure?` | Check if reactor failed |
|
|
959
|
+
| `paused?` | Check if reactor is paused at an interrupt |
|
|
960
|
+
| `current_step` | Get the current interrupt step name (Symbol or nil) |
|
|
961
|
+
| `ready_interrupt_steps` | Get all ready interrupt step names (Array of Symbols) |
|
|
962
|
+
| `resume(payload:, step:)` | Resume a paused reactor with payload; `step:` required for multiple interrupts |
|
|
963
|
+
| `step_result(name)` | Get a specific step's result |
|
|
964
|
+
| `error` | Get the error message if failed |
|
|
965
|
+
| `map_elements(step_name)` | Get all map element subjects |
|
|
966
|
+
| `map_element(step_name, index:)` | Get specific map element subject |
|
|
967
|
+
| `reactor_instance` | Access the underlying reactor instance |
|
|
968
|
+
|
|
969
|
+
### RSpec Matchers
|
|
970
|
+
|
|
971
|
+
| Matcher | Description |
|
|
972
|
+
|---------|-------------|
|
|
973
|
+
| `be_success` | Assert reactor completed successfully |
|
|
974
|
+
| `be_failure` | Assert reactor failed |
|
|
975
|
+
| `be_paused` | Assert reactor is paused at an interrupt |
|
|
976
|
+
| `be_paused_at(*steps)` | Assert reactor is paused with specific interrupt(s) ready |
|
|
977
|
+
| `have_ready_interrupts(*steps)` | Assert exact set of ready interrupt steps |
|
|
978
|
+
| `have_run_step(name)` | Assert step was executed |
|
|
979
|
+
| `.returning(value)` | Chain: assert step returned value |
|
|
980
|
+
| `.after(step)` | Chain: assert step ran after another |
|
|
981
|
+
| `have_retried_step(name)` | Assert step was retried |
|
|
982
|
+
| `.times(count)` | Chain: assert retry count |
|
|
983
|
+
| `have_validation_error(field)` | Assert input validation error on field |
|
|
984
|
+
| `be_skipped` | Assert result is a `RubyReactor::Skipped` (period gate or step return) |
|
|
985
|
+
| `.because(reason)` | Chain: assert the skip reason matches |
|
|
986
|
+
| `.at_step(name)` | Chain: assert the halting step (step-returned Skipped only) |
|
|
987
|
+
| `be_locked` | Assert an exclusive lock is currently held for the given key |
|
|
988
|
+
| `.by(owner)` | Chain: assert the lock owner (typically a `context_id`) |
|
|
989
|
+
| `have_available_tokens(n)` | Assert `n` semaphore tokens are still in the pool |
|
|
990
|
+
| `have_held_tokens(n)` | Assert `n` semaphore tokens are currently checked out |
|
|
991
|
+
| `have_rate_limit_count(n)` | Assert the current rate-limit bucket count |
|
|
992
|
+
| `.for(period)` | Chain (required): which window to check (`:second`, `:minute`, …, or integer seconds) |
|
|
993
|
+
| `be_period_marked` | Assert a `with_period` bucket has been marked |
|
|
994
|
+
| `.for(period)` | Chain (required): which bucket granularity to check |
|