busybee 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +23 -0
- data/LICENSE.txt +21 -0
- data/README.md +214 -0
- data/docs/grpc.md +106 -0
- data/docs/testing.md +680 -0
- data/lib/busybee/grpc/gateway_pb.rb +91 -0
- data/lib/busybee/grpc/gateway_services_pb.rb +278 -0
- data/lib/busybee/grpc.rb +4 -0
- data/lib/busybee/testing/activated_job.rb +140 -0
- data/lib/busybee/testing/helpers.rb +251 -0
- data/lib/busybee/testing/matchers/have_activated.rb +66 -0
- data/lib/busybee/testing/matchers/have_received_headers.rb +21 -0
- data/lib/busybee/testing/matchers/have_received_variables.rb +21 -0
- data/lib/busybee/testing.rb +54 -0
- data/lib/busybee/version.rb +5 -0
- data/lib/busybee.rb +7 -0
- metadata +94 -0
data/docs/testing.md
ADDED
|
@@ -0,0 +1,680 @@
|
|
|
1
|
+
# Testing BPMN Workflows with Busybee
|
|
2
|
+
|
|
3
|
+
Busybee provides RSpec helpers and matchers for testing BPMN workflows against Zeebe. This testing module makes it easy to write integration tests that deploy processes, create instances, activate jobs, and verify workflow behavior.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
Add busybee to your `Gemfile` test group:
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
group :test do
|
|
11
|
+
gem "busybee"
|
|
12
|
+
end
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Then run:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
bundle install
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Setup
|
|
22
|
+
|
|
23
|
+
In your `spec/spec_helper.rb` or `rails_helper.rb`, require the testing module after RSpec:
|
|
24
|
+
|
|
25
|
+
```ruby
|
|
26
|
+
require "rspec"
|
|
27
|
+
require "busybee/testing"
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
The testing module will automatically include helper methods in all RSpec examples.
|
|
31
|
+
|
|
32
|
+
## Configuration
|
|
33
|
+
|
|
34
|
+
Configure the Zeebe connection. Busybee reads from environment variables by default, or you can configure explicitly:
|
|
35
|
+
|
|
36
|
+
```ruby
|
|
37
|
+
# Use environment variables (recommended)
|
|
38
|
+
# ZEEBE_ADDRESS=localhost:26500
|
|
39
|
+
# ZEEBE_USERNAME=demo
|
|
40
|
+
# ZEEBE_PASSWORD=demo
|
|
41
|
+
|
|
42
|
+
# Or configure explicitly
|
|
43
|
+
Busybee::Testing.configure do |config|
|
|
44
|
+
config.address = "localhost:26500"
|
|
45
|
+
config.username = "demo"
|
|
46
|
+
config.password = "demo"
|
|
47
|
+
config.activate_request_timeout = 2000 # milliseconds, default: 1000
|
|
48
|
+
end
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
### Configuration Options
|
|
52
|
+
|
|
53
|
+
| Option | Environment Variable | Default | Description |
|
|
54
|
+
|--------|---------------------|---------|-------------|
|
|
55
|
+
| `address` | `ZEEBE_ADDRESS` | `"localhost:26500"` | Zeebe gateway gRPC address |
|
|
56
|
+
| `username` | `ZEEBE_USERNAME` | `"demo"` | Zeebe authentication username |
|
|
57
|
+
| `password` | `ZEEBE_PASSWORD` | `"demo"` | Zeebe authentication password |
|
|
58
|
+
| `activate_request_timeout` | - | `1000` | Timeout in milliseconds for job activation requests |
|
|
59
|
+
|
|
60
|
+
## Helper Methods
|
|
61
|
+
|
|
62
|
+
### Process Deployment
|
|
63
|
+
|
|
64
|
+
#### `deploy_process(path, uniquify: nil)`
|
|
65
|
+
|
|
66
|
+
Deploys a BPMN file to Zeebe.
|
|
67
|
+
|
|
68
|
+
**Parameters:**
|
|
69
|
+
- `path` (String) - Path to BPMN file
|
|
70
|
+
- `uniquify` (nil, true, String) - Uniquification strategy:
|
|
71
|
+
- `nil` (default) - Deploy with original process ID from BPMN
|
|
72
|
+
- `true` - Auto-generate unique process ID like `test-process-abc123`
|
|
73
|
+
- String - Use custom process ID
|
|
74
|
+
|
|
75
|
+
**Returns:** Hash with `:process` (GRPC metadata) and `:process_id` (String)
|
|
76
|
+
|
|
77
|
+
**Examples:**
|
|
78
|
+
|
|
79
|
+
```ruby
|
|
80
|
+
# Deploy with original process ID (most common)
|
|
81
|
+
result = deploy_process("spec/fixtures/order_process.bpmn")
|
|
82
|
+
result[:process_id] #=> "order-fulfillment" (from BPMN file)
|
|
83
|
+
|
|
84
|
+
# Deploy with auto-generated unique ID (for test isolation)
|
|
85
|
+
result = deploy_process("spec/fixtures/order_process.bpmn", uniquify: true)
|
|
86
|
+
result[:process_id] #=> "test-process-a1b2c3d4"
|
|
87
|
+
|
|
88
|
+
# Deploy with custom ID
|
|
89
|
+
result = deploy_process("spec/fixtures/order_process.bpmn", uniquify: "my-test-order-process")
|
|
90
|
+
result[:process_id] #=> "my-test-order-process"
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
### Process Instance Management
|
|
94
|
+
|
|
95
|
+
#### `with_process_instance(process_id, variables = {})`
|
|
96
|
+
|
|
97
|
+
Creates a process instance, yields its key, and automatically cancels it when the block exits. This ensures cleanup even if tests fail.
|
|
98
|
+
|
|
99
|
+
**Parameters:**
|
|
100
|
+
- `process_id` (String) - BPMN process ID
|
|
101
|
+
- `variables` (Hash) - Initial process variables (optional)
|
|
102
|
+
|
|
103
|
+
**Yields:** Integer process instance key
|
|
104
|
+
|
|
105
|
+
**Examples:**
|
|
106
|
+
|
|
107
|
+
```ruby
|
|
108
|
+
with_process_instance("order-fulfillment") do |key|
|
|
109
|
+
# Test process behavior
|
|
110
|
+
# Instance is automatically cancelled after block
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# With initial variables
|
|
114
|
+
with_process_instance("order-fulfillment", order_id: "12345", items: 3) do |key|
|
|
115
|
+
job = activate_job("prepare-shipment")
|
|
116
|
+
expect(job.variables["order_id"]).to eq("12345")
|
|
117
|
+
end
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
#### `process_instance_key`
|
|
121
|
+
|
|
122
|
+
Returns the current process instance key within a `with_process_instance` block.
|
|
123
|
+
|
|
124
|
+
**Returns:** Integer or nil
|
|
125
|
+
|
|
126
|
+
```ruby
|
|
127
|
+
with_process_instance("my-process") do
|
|
128
|
+
puts process_instance_key #=> 2251799813685255
|
|
129
|
+
end
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
#### `last_process_instance_key`
|
|
133
|
+
|
|
134
|
+
Returns the process instance key from the most recently completed `with_process_instance` call. Useful for debugging test failures by correlating with ElasticSearch/Operate data.
|
|
135
|
+
|
|
136
|
+
**Returns:** Integer or nil
|
|
137
|
+
|
|
138
|
+
```ruby
|
|
139
|
+
it "processes order" do
|
|
140
|
+
with_process_instance("order-fulfillment") { ... }
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
after do
|
|
144
|
+
puts "Failed process instance: #{last_process_instance_key}" if last_process_instance_key
|
|
145
|
+
end
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
### Job Activation
|
|
149
|
+
|
|
150
|
+
#### `activate_job(type)`
|
|
151
|
+
|
|
152
|
+
Activates a single job of the specified type. Raises `Busybee::Testing::NoJobAvailable` if no job is available.
|
|
153
|
+
|
|
154
|
+
**Parameters:**
|
|
155
|
+
- `type` (String) - Job type to activate
|
|
156
|
+
|
|
157
|
+
**Returns:** `ActivatedJob` instance
|
|
158
|
+
|
|
159
|
+
**Raises:** `NoJobAvailable` if no matching job found
|
|
160
|
+
|
|
161
|
+
**Example:**
|
|
162
|
+
|
|
163
|
+
```ruby
|
|
164
|
+
job = activate_job("process-payment")
|
|
165
|
+
expect(job.variables["amount"]).to eq(99.99)
|
|
166
|
+
job.mark_completed(payment_status: "success")
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
#### `activate_jobs(type, max_jobs:)`
|
|
170
|
+
|
|
171
|
+
Activates multiple jobs of the specified type.
|
|
172
|
+
|
|
173
|
+
**Parameters:**
|
|
174
|
+
- `type` (String) - Job type to activate
|
|
175
|
+
- `max_jobs` (Integer) - Maximum number of jobs to activate
|
|
176
|
+
|
|
177
|
+
**Returns:** Enumerator of `ActivatedJob` instances
|
|
178
|
+
|
|
179
|
+
**Example:**
|
|
180
|
+
|
|
181
|
+
```ruby
|
|
182
|
+
jobs = activate_jobs("send-notification", max_jobs: 5)
|
|
183
|
+
jobs.each do |job|
|
|
184
|
+
recipient = job.variables["email"]
|
|
185
|
+
job.mark_completed(sent_at: Time.now.iso8601)
|
|
186
|
+
end
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
### Message Publishing
|
|
190
|
+
|
|
191
|
+
#### `publish_message(name, correlation_key:, variables: {}, ttl_ms: 5000)`
|
|
192
|
+
|
|
193
|
+
Publishes a message to Zeebe to trigger message intermediate catch events or message start events.
|
|
194
|
+
|
|
195
|
+
**Parameters:**
|
|
196
|
+
- `name` (String) - Message name matching BPMN definition
|
|
197
|
+
- `correlation_key` (String) - Key to correlate message with process instance
|
|
198
|
+
- `variables` (Hash) - Message payload variables (optional, default: `{}`)
|
|
199
|
+
- `ttl_ms` (Integer) - Message time-to-live in milliseconds (optional, default: `5000`)
|
|
200
|
+
|
|
201
|
+
**Example:**
|
|
202
|
+
|
|
203
|
+
```ruby
|
|
204
|
+
# Process waiting for message with correlation
|
|
205
|
+
with_process_instance("approval-workflow", request_id: "req-123") do
|
|
206
|
+
publish_message(
|
|
207
|
+
"approval-granted",
|
|
208
|
+
correlation_key: "req-123",
|
|
209
|
+
variables: { approved_by: "manager", approved_at: Time.now.iso8601 }
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
assert_process_completed!
|
|
213
|
+
end
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
### Variable Management
|
|
217
|
+
|
|
218
|
+
#### `set_variables(scope_key, variables, local: true)`
|
|
219
|
+
|
|
220
|
+
Sets variables on a process scope (process instance or element instance).
|
|
221
|
+
|
|
222
|
+
**Parameters:**
|
|
223
|
+
- `scope_key` (Integer) - Element instance key
|
|
224
|
+
- `variables` (Hash) - Variables to set
|
|
225
|
+
- `local` (Boolean) - Whether variables are local to scope (default: `true`)
|
|
226
|
+
|
|
227
|
+
**Example:**
|
|
228
|
+
|
|
229
|
+
```ruby
|
|
230
|
+
with_process_instance("data-processing") do |key|
|
|
231
|
+
set_variables(key, { processed_count: 100, status: "in_progress" })
|
|
232
|
+
|
|
233
|
+
job = activate_job("validate-data")
|
|
234
|
+
expect(job.variables["processed_count"]).to eq(100)
|
|
235
|
+
end
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
### Process Completion
|
|
239
|
+
|
|
240
|
+
#### `assert_process_completed!(wait: 0.25)`
|
|
241
|
+
|
|
242
|
+
Asserts that the current process instance has completed. Useful for verifying end-to-end workflow execution.
|
|
243
|
+
|
|
244
|
+
**Parameters:**
|
|
245
|
+
- `wait` (Float) - Seconds to wait before checking (default: `0.25`)
|
|
246
|
+
|
|
247
|
+
**Raises:** RuntimeError if process is still running
|
|
248
|
+
|
|
249
|
+
**Example:**
|
|
250
|
+
|
|
251
|
+
```ruby
|
|
252
|
+
with_process_instance("simple-workflow") do
|
|
253
|
+
job = activate_job("single-task")
|
|
254
|
+
job.mark_completed
|
|
255
|
+
|
|
256
|
+
assert_process_completed! # Verifies workflow reached end event
|
|
257
|
+
end
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
### Zeebe Availability
|
|
261
|
+
|
|
262
|
+
#### `zeebe_available?(timeout: 5)`
|
|
263
|
+
|
|
264
|
+
Checks if Zeebe is available and responsive.
|
|
265
|
+
|
|
266
|
+
**Parameters:**
|
|
267
|
+
- `timeout` (Integer) - Timeout in seconds (default: `5`)
|
|
268
|
+
|
|
269
|
+
**Returns:** Boolean
|
|
270
|
+
|
|
271
|
+
**Example:**
|
|
272
|
+
|
|
273
|
+
```ruby
|
|
274
|
+
before(:all) do
|
|
275
|
+
skip "Zeebe not running" unless zeebe_available?
|
|
276
|
+
end
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
## ActivatedJob API
|
|
280
|
+
|
|
281
|
+
The `ActivatedJob` class wraps Zeebe's GRPC job response with a fluent API for testing.
|
|
282
|
+
|
|
283
|
+
### Accessors
|
|
284
|
+
|
|
285
|
+
```ruby
|
|
286
|
+
job = activate_job("my-task")
|
|
287
|
+
|
|
288
|
+
job.key #=> 2251799813685263 (job key)
|
|
289
|
+
job.process_instance_key #=> 2251799813685255 (process instance key)
|
|
290
|
+
job.variables #=> {"order_id" => "123", "total" => 99.99}
|
|
291
|
+
job.headers #=> {"priority" => "high"}
|
|
292
|
+
job.retries #=> 3
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
### Expectation Methods
|
|
296
|
+
|
|
297
|
+
These methods verify job state and return `self` for chaining:
|
|
298
|
+
|
|
299
|
+
#### `expect_variables(expected)`
|
|
300
|
+
|
|
301
|
+
Asserts that job variables include the expected key-value pairs.
|
|
302
|
+
|
|
303
|
+
**Parameters:**
|
|
304
|
+
- `expected` (Hash) - Expected variable subset (symbol or string keys)
|
|
305
|
+
|
|
306
|
+
**Returns:** self
|
|
307
|
+
|
|
308
|
+
**Raises:** `RSpec::Expectations::ExpectationNotMetError` if not matched
|
|
309
|
+
|
|
310
|
+
```ruby
|
|
311
|
+
job.expect_variables(order_id: "123", total: 99.99)
|
|
312
|
+
.and_complete
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
#### `expect_headers(expected)`
|
|
316
|
+
|
|
317
|
+
Asserts that job headers include the expected key-value pairs.
|
|
318
|
+
|
|
319
|
+
**Parameters:**
|
|
320
|
+
- `expected` (Hash) - Expected header subset (symbol or string keys)
|
|
321
|
+
|
|
322
|
+
**Returns:** self
|
|
323
|
+
|
|
324
|
+
**Raises:** `RSpec::Expectations::ExpectationNotMetError` if not matched
|
|
325
|
+
|
|
326
|
+
```ruby
|
|
327
|
+
job.expect_headers(priority: "high", batch_id: "batch-42")
|
|
328
|
+
.and_complete
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
### Terminal Methods
|
|
332
|
+
|
|
333
|
+
These methods complete the job lifecycle. All return `self` for chaining.
|
|
334
|
+
|
|
335
|
+
#### `mark_completed(variables = {})`
|
|
336
|
+
|
|
337
|
+
Completes the job successfully with optional output variables.
|
|
338
|
+
|
|
339
|
+
**Alias:** `and_complete`
|
|
340
|
+
|
|
341
|
+
**Parameters:**
|
|
342
|
+
- `variables` (Hash) - Output variables to merge into process state
|
|
343
|
+
|
|
344
|
+
**Example:**
|
|
345
|
+
|
|
346
|
+
```ruby
|
|
347
|
+
job.mark_completed(payment_id: "pay-789", charged_amount: 99.99)
|
|
348
|
+
|
|
349
|
+
# Fluent chaining style
|
|
350
|
+
activate_job("process-payment")
|
|
351
|
+
.expect_variables(amount: 99.99)
|
|
352
|
+
.and_complete(payment_id: "pay-789")
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
#### `mark_failed(message = nil, retries: 0)`
|
|
356
|
+
|
|
357
|
+
Fails the job with an error message and retry count.
|
|
358
|
+
|
|
359
|
+
**Alias:** `and_fail`
|
|
360
|
+
|
|
361
|
+
**Parameters:**
|
|
362
|
+
- `message` (String, nil) - Error message
|
|
363
|
+
- `retries` (Integer) - Number of retries remaining (default: `0`)
|
|
364
|
+
|
|
365
|
+
**Example:**
|
|
366
|
+
|
|
367
|
+
```ruby
|
|
368
|
+
job.mark_failed("Payment gateway timeout", retries: 2)
|
|
369
|
+
|
|
370
|
+
# Fluent style
|
|
371
|
+
activate_job("external-api-call")
|
|
372
|
+
.and_fail("Service unavailable", retries: 3)
|
|
373
|
+
```
|
|
374
|
+
|
|
375
|
+
#### `throw_error_event(code, message = nil)`
|
|
376
|
+
|
|
377
|
+
Throws a BPMN error event that can be caught by error boundary events.
|
|
378
|
+
|
|
379
|
+
**Alias:** `and_throw_error_event`
|
|
380
|
+
|
|
381
|
+
**Parameters:**
|
|
382
|
+
- `code` (String) - BPMN error code
|
|
383
|
+
- `message` (String, nil) - Error message
|
|
384
|
+
|
|
385
|
+
**Example:**
|
|
386
|
+
|
|
387
|
+
```ruby
|
|
388
|
+
job.throw_error_event("VALIDATION_FAILED", "Invalid order data")
|
|
389
|
+
|
|
390
|
+
# Fluent style
|
|
391
|
+
activate_job("validate-order")
|
|
392
|
+
.and_throw_error_event("INVALID_ITEMS", "Item count mismatch")
|
|
393
|
+
```
|
|
394
|
+
|
|
395
|
+
#### `update_retries(count)`
|
|
396
|
+
|
|
397
|
+
Updates the job's retry count without completing or failing it.
|
|
398
|
+
|
|
399
|
+
**Parameters:**
|
|
400
|
+
- `count` (Integer) - New retry count
|
|
401
|
+
|
|
402
|
+
**Example:**
|
|
403
|
+
|
|
404
|
+
```ruby
|
|
405
|
+
job.update_retries(5)
|
|
406
|
+
```
|
|
407
|
+
|
|
408
|
+
## RSpec Matchers
|
|
409
|
+
|
|
410
|
+
### `have_received_variables`
|
|
411
|
+
|
|
412
|
+
Matches activated jobs with expected variable values.
|
|
413
|
+
|
|
414
|
+
**Example:**
|
|
415
|
+
|
|
416
|
+
```ruby
|
|
417
|
+
job = activate_job("my-task")
|
|
418
|
+
expect(job).to have_received_variables(order_id: "123")
|
|
419
|
+
expect(job).to have_received_variables("order_id" => "123", "total" => 99.99)
|
|
420
|
+
```
|
|
421
|
+
|
|
422
|
+
### `have_received_headers`
|
|
423
|
+
|
|
424
|
+
Matches activated jobs with expected header values.
|
|
425
|
+
|
|
426
|
+
**Example:**
|
|
427
|
+
|
|
428
|
+
```ruby
|
|
429
|
+
job = activate_job("my-task")
|
|
430
|
+
expect(job).to have_received_headers(priority: "high")
|
|
431
|
+
expect(job).to have_received_headers("workflow_version" => "2")
|
|
432
|
+
```
|
|
433
|
+
|
|
434
|
+
### `have_activated`
|
|
435
|
+
|
|
436
|
+
Flexible matcher supporting chained expectations and terminal actions. Can be used standalone or with chains.
|
|
437
|
+
|
|
438
|
+
**Chains:**
|
|
439
|
+
- `.with_variables(hash)` - Assert expected variables
|
|
440
|
+
- `.with_headers(hash)` - Assert expected headers
|
|
441
|
+
- `.and_complete(vars)` - Complete job with output
|
|
442
|
+
- `.and_fail(message, retries:)` - Fail job
|
|
443
|
+
- `.and_throw_error_event(code, message)` - Throw error
|
|
444
|
+
|
|
445
|
+
**Examples:**
|
|
446
|
+
|
|
447
|
+
```ruby
|
|
448
|
+
# Basic activation check
|
|
449
|
+
job = activate_job("my-task")
|
|
450
|
+
expect(job).to have_activated
|
|
451
|
+
|
|
452
|
+
# With variable assertions
|
|
453
|
+
expect(job).to have_activated.with_variables(order_id: "123")
|
|
454
|
+
|
|
455
|
+
# Complete workflow with chaining
|
|
456
|
+
expect(activate_job("process-order"))
|
|
457
|
+
.to have_activated
|
|
458
|
+
.with_variables(order_id: "123", total: 99.99)
|
|
459
|
+
.with_headers(priority: "high")
|
|
460
|
+
.and_complete(processed: true, processed_at: Time.now.iso8601)
|
|
461
|
+
```
|
|
462
|
+
|
|
463
|
+
## Complete Workflow Example
|
|
464
|
+
|
|
465
|
+
Here's a complete example testing an order fulfillment workflow:
|
|
466
|
+
|
|
467
|
+
```ruby
|
|
468
|
+
# spec/workflows/order_fulfillment_spec.rb
|
|
469
|
+
require "spec_helper"
|
|
470
|
+
|
|
471
|
+
RSpec.describe "Order Fulfillment Workflow" do
|
|
472
|
+
let(:bpmn_path) { File.expand_path("../fixtures/order_fulfillment.bpmn", __dir__) }
|
|
473
|
+
let(:process_id) { deploy_process(bpmn_path, uniquify: true)[:process_id] }
|
|
474
|
+
let(:order_id) { SecureRandom.uuid }
|
|
475
|
+
|
|
476
|
+
context "when order is approved" do
|
|
477
|
+
it "processes payment and ships order" do
|
|
478
|
+
with_process_instance(process_id, order_id: order_id, items_count: 3) do
|
|
479
|
+
# Verify payment processing job
|
|
480
|
+
expect(activate_job("process-payment"))
|
|
481
|
+
.to have_activated
|
|
482
|
+
.with_variables(order_id: order_id, items_count: 3)
|
|
483
|
+
.and_complete(payment_id: "pay-#{SecureRandom.hex(4)}", amount_charged: 149.99)
|
|
484
|
+
|
|
485
|
+
# Verify shipment preparation
|
|
486
|
+
expect(activate_job("prepare-shipment"))
|
|
487
|
+
.to have_activated
|
|
488
|
+
.with_variables(order_id: order_id, payment_id: /^pay-/)
|
|
489
|
+
.and_complete(tracking_number: "TRACK123", carrier: "FedEx")
|
|
490
|
+
|
|
491
|
+
# Verify notification sent
|
|
492
|
+
notification_job = activate_job("send-confirmation-email")
|
|
493
|
+
expect(notification_job).to have_received_variables(
|
|
494
|
+
order_id: order_id,
|
|
495
|
+
tracking_number: "TRACK123"
|
|
496
|
+
)
|
|
497
|
+
notification_job.mark_completed(email_sent: true)
|
|
498
|
+
|
|
499
|
+
# Assert workflow completed
|
|
500
|
+
assert_process_completed!
|
|
501
|
+
end
|
|
502
|
+
end
|
|
503
|
+
end
|
|
504
|
+
|
|
505
|
+
context "when payment fails" do
|
|
506
|
+
it "handles payment error and notifies customer" do
|
|
507
|
+
with_process_instance(process_id, order_id: order_id, items_count: 2) do
|
|
508
|
+
# Payment fails with error event
|
|
509
|
+
activate_job("process-payment")
|
|
510
|
+
.expect_variables(order_id: order_id)
|
|
511
|
+
.and_throw_error_event("PAYMENT_DECLINED", "Insufficient funds")
|
|
512
|
+
|
|
513
|
+
# Error boundary catches and triggers notification
|
|
514
|
+
expect(activate_job("send-payment-failed-email"))
|
|
515
|
+
.to have_activated
|
|
516
|
+
.with_variables(order_id: order_id)
|
|
517
|
+
.and_complete
|
|
518
|
+
|
|
519
|
+
assert_process_completed!
|
|
520
|
+
end
|
|
521
|
+
end
|
|
522
|
+
end
|
|
523
|
+
|
|
524
|
+
context "when shipment needs approval" do
|
|
525
|
+
it "waits for approval message" do
|
|
526
|
+
correlation_key = "approval-#{order_id}"
|
|
527
|
+
|
|
528
|
+
with_process_instance(process_id, order_id: order_id, correlation_id: correlation_key) do
|
|
529
|
+
# Complete initial jobs
|
|
530
|
+
activate_job("process-payment").and_complete(payment_id: "pay-123")
|
|
531
|
+
activate_job("check-shipment-requirements")
|
|
532
|
+
.and_complete(requires_approval: true)
|
|
533
|
+
|
|
534
|
+
# Process waits at message intermediate catch event
|
|
535
|
+
# Publish approval message
|
|
536
|
+
publish_message(
|
|
537
|
+
"shipment-approved",
|
|
538
|
+
correlation_key: correlation_key,
|
|
539
|
+
variables: { approved_by: "manager@example.com", approved_at: Time.now.iso8601 }
|
|
540
|
+
)
|
|
541
|
+
|
|
542
|
+
# Verify shipment proceeds
|
|
543
|
+
expect(activate_job("prepare-shipment"))
|
|
544
|
+
.to have_activated
|
|
545
|
+
.with_variables(
|
|
546
|
+
approved_by: "manager@example.com",
|
|
547
|
+
requires_approval: true
|
|
548
|
+
)
|
|
549
|
+
.and_complete(tracking_number: "TRACK456")
|
|
550
|
+
|
|
551
|
+
activate_job("send-confirmation-email").and_complete
|
|
552
|
+
|
|
553
|
+
assert_process_completed!
|
|
554
|
+
end
|
|
555
|
+
end
|
|
556
|
+
end
|
|
557
|
+
end
|
|
558
|
+
```
|
|
559
|
+
|
|
560
|
+
## Composing Shared Workflow Contexts
|
|
561
|
+
|
|
562
|
+
For complex workflows with many test scenarios, extract common setup into shared contexts:
|
|
563
|
+
|
|
564
|
+
```ruby
|
|
565
|
+
# spec/support/workflow_contexts.rb
|
|
566
|
+
RSpec.shared_context "deployed order workflow" do
|
|
567
|
+
let(:bpmn_path) { File.expand_path("../../integration/fixtures/order_fulfillment.bpmn", __dir__) }
|
|
568
|
+
let(:process_id) { deploy_process(bpmn_path, uniquify: true)[:process_id] }
|
|
569
|
+
let(:order_id) { SecureRandom.uuid }
|
|
570
|
+
let(:base_variables) { { order_id: order_id, customer_id: "cust-123" } }
|
|
571
|
+
|
|
572
|
+
def complete_payment_step(payment_status: "success")
|
|
573
|
+
activate_job("process-payment")
|
|
574
|
+
.expect_variables(order_id: order_id)
|
|
575
|
+
.and_complete(
|
|
576
|
+
payment_id: "pay-#{SecureRandom.hex(4)}",
|
|
577
|
+
payment_status: payment_status
|
|
578
|
+
)
|
|
579
|
+
end
|
|
580
|
+
|
|
581
|
+
def complete_shipment_step
|
|
582
|
+
activate_job("prepare-shipment")
|
|
583
|
+
.and_complete(tracking_number: "TRACK#{rand(1000..9999)}")
|
|
584
|
+
end
|
|
585
|
+
end
|
|
586
|
+
|
|
587
|
+
# Use in specs
|
|
588
|
+
RSpec.describe "Order edge cases" do
|
|
589
|
+
include_context "deployed order workflow"
|
|
590
|
+
|
|
591
|
+
it "handles partial shipments" do
|
|
592
|
+
with_process_instance(process_id, base_variables.merge(partial_shipment: true)) do
|
|
593
|
+
complete_payment_step
|
|
594
|
+
complete_shipment_step
|
|
595
|
+
# Additional steps...
|
|
596
|
+
assert_process_completed!
|
|
597
|
+
end
|
|
598
|
+
end
|
|
599
|
+
end
|
|
600
|
+
```
|
|
601
|
+
|
|
602
|
+
## Testing Best Practices
|
|
603
|
+
|
|
604
|
+
### 1. Use Unique Process IDs for Isolation
|
|
605
|
+
|
|
606
|
+
Deploy processes with unique IDs when tests might interfere:
|
|
607
|
+
|
|
608
|
+
```ruby
|
|
609
|
+
# Good: Each test gets isolated process
|
|
610
|
+
let(:process_id) { deploy_process(bpmn_path, uniquify: true)[:process_id] }
|
|
611
|
+
|
|
612
|
+
# Avoid: Shared process might cause cross-test pollution
|
|
613
|
+
before(:all) { @process_id = deploy_process(bpmn_path)[:process_id] }
|
|
614
|
+
```
|
|
615
|
+
|
|
616
|
+
### 2. Always Clean Up Process Instances
|
|
617
|
+
|
|
618
|
+
Use `with_process_instance` which automatically cancels instances:
|
|
619
|
+
|
|
620
|
+
```ruby
|
|
621
|
+
# Good: Automatic cleanup
|
|
622
|
+
with_process_instance(process_id) do |key|
|
|
623
|
+
# test code
|
|
624
|
+
end
|
|
625
|
+
|
|
626
|
+
# Avoid: Manual instance management
|
|
627
|
+
key = create_instance(process_id)
|
|
628
|
+
# ... test code ...
|
|
629
|
+
cancel_instance(key) # Easy to forget in error paths
|
|
630
|
+
```
|
|
631
|
+
|
|
632
|
+
### 3. Verify Job Variables Before Completing
|
|
633
|
+
|
|
634
|
+
Assert expected inputs before completing jobs:
|
|
635
|
+
|
|
636
|
+
```ruby
|
|
637
|
+
# Good: Verify then complete
|
|
638
|
+
job = activate_job("send-email")
|
|
639
|
+
expect(job).to have_received_variables(
|
|
640
|
+
recipient: "user@example.com",
|
|
641
|
+
template: "order_confirmation"
|
|
642
|
+
)
|
|
643
|
+
job.mark_completed(sent_at: Time.now.iso8601)
|
|
644
|
+
|
|
645
|
+
# Also Good: fluent style
|
|
646
|
+
activate_job("send-email")
|
|
647
|
+
.expect_variables(recipient: "user@example.com")
|
|
648
|
+
.and_complete(sent_at: Time.now.iso8601)
|
|
649
|
+
```
|
|
650
|
+
|
|
651
|
+
## Troubleshooting
|
|
652
|
+
|
|
653
|
+
If you are running the entire Camunda Platform, you can debug your workflow by checking the Operate UI to inspect process state. The `last_process_instance_key` helper can be used to help you find the process instance in question.
|
|
654
|
+
|
|
655
|
+
### "Zeebe is not running"
|
|
656
|
+
|
|
657
|
+
Ensure Zeebe is started **and healthy** before running your tests.
|
|
658
|
+
|
|
659
|
+
### "No job of type 'my-task' available"
|
|
660
|
+
|
|
661
|
+
Common causes:
|
|
662
|
+
- Process instance hasn't reached that task yet
|
|
663
|
+
- Job type name doesn't match BPMN definition
|
|
664
|
+
- Job already activated by another worker
|
|
665
|
+
- Process completed or failed before reaching task
|
|
666
|
+
|
|
667
|
+
### "Process instance still running"
|
|
668
|
+
|
|
669
|
+
When `assert_process_completed!` fails:
|
|
670
|
+
- Verify all jobs were activated and completed
|
|
671
|
+
- Check for message intermediate catch events waiting for messages
|
|
672
|
+
- Look for timer events that haven't fired
|
|
673
|
+
- Use `last_process_instance_key` to find instance in Operate
|
|
674
|
+
|
|
675
|
+
### Variables Not Available in Job
|
|
676
|
+
|
|
677
|
+
Ensure variables are:
|
|
678
|
+
- Set in start variables: `with_process_instance(id, my_var: "value")`
|
|
679
|
+
- Returned from previous job: `job.mark_completed(output_var: "value")`
|
|
680
|
+
- Correctly I/O-mapped on each service task
|