Test File
The complete YAML schema for a dolphin test suite: every key of a _test.yaml, field by field.
A test suite is a YAML file describing one flow and the cases that exercise it. It
lives beside the flows it tests: orders.yaml is tested by orders_test.yaml, the way
orders.go is tested by orders_test.go. octo skips _test.yaml when it loads a
directory.
This page is the canonical reference for every key. For worked examples, read writing test cases.
A case is a debug config with
assertions: the input, the mocks and the spied blocks are the same ones
octo invoke --run-debug-config takes.
dolphin schema --format yaml prints this schema, and it is drift-tested against the
Go structs that implement it.
Top-level keys
flow: # the flow under test
inputs: # named messages the cases share
mocks: # blocks to stand in for, in every case
env: # environment variables every case runs with
timeout: # how long any one case may take
cases: # the scenarios| Key | Type | Required | Description |
|---|---|---|---|
flow | string | yes | The flow under test. Every case in the file calls it. |
inputs | object | no | Named messages the cases share, keyed by name. |
mocks | object | no | Blocks to stand in for in every case, unless a case overrides the address or lifts it. Keyed by block address. |
env | object | no | Environment variables every case runs with. |
timeout | string | no (30s) | A Go duration bounding any one case. A case may shorten or lengthen it. |
cases | list | yes | The scenarios. At least one. |
inputs
Named messages, so the body that means a vip order is written once and referred to by name. A case may also write its input inline.
| Field | Type | Required | Description |
|---|---|---|---|
data | any | no | The request body, as a structured value rather than a JSON string. |
vars | object | no | Message variables. |
inputs:
small:
data: { amount: 10 }
a vip order:
data: { orderId: 42, amount: 250 }
vars: { x-api-key: dev-key }Sources are not started under invoke. No cron ticks, no HTTP request arrives, no
queue delivers. vars seeds what a source normally would: the http source copies
request headers into the message variables, so a flow that reads vars["x-api-key"]
needs them supplied here.
cases
Each case is one octo invoke, in its own process, run with the mocks and spies it
asks for.
| Field | Type | Required | Description |
|---|---|---|---|
name | string | yes | What the case proves, in words. The report, the JUnit XML and a person all identify it by this, so it must be unique in the file. |
input | string | object | no | The name of an entry in inputs, or an inline input. |
mocks | object | no | Blocks to stand in for in this case. An address here replaces the file's mock for that block, whole, or null to lift it and run the real block. See un-mocking. |
env | object | no | Environment variables for this case, overriding the file's per variable. |
expect | object | no | What the flow should have done. See expect. |
spies | object | no | Blocks to watch, and what each should have seen. See spies. |
timeout | string | no | A Go duration overriding the file's. |
skip | string | no | A reason (required). The case is reported as skipped and the flow is not called. |
cases:
- name: a declined card takes the flow error path and answers 502
input: large
expect:
vars: { httpStatus: 502 }
that: ['body.error.contains("card declined")']The two merge rules differ on purpose: a case's mocks replace the file's per address,
whole, so the case states exactly what that block will do, while a case's env
overrides per variable because a variable is a scalar.
expect
What the flow should have done. Omitting expect entirely still asserts
something: that the flow completed and did not fail.
| Field | Type | Description |
|---|---|---|
body | any | The body the flow returned, compared exactly. A field that appears in the result and not here is a failure. |
vars | object | Variables the flow set, as a subset: each key listed must be there and equal, and anything else is ignored. |
that | list of string | CEL expressions over the result message, every one of which must be true. |
dropped | bool | The flow filtered the message out and returned nothing. |
error | string | The flow failed, with a message containing this text (a substring match). |
A flow either produces a message, drops it, or fails, so dropped and error are
exclusive with each other and with the message fields; dolphin rejects a case that
asks for two.
body is exact because it is the flow's answer. vars is a subset because variables
are scratch space the engine and the blocks add to. Use that: ['vars.size() == 2']
when you do want them exact.
The CEL in that
The same CEL a mock's when and every block in the flow uses, over
the same variables: body, vars, eventID, correlationID, now. An expression
that evaluates to something other than true or false is rejected rather than
read as truthy.
env is not bound in an assertion. dolphin never loads your config; octo does, in
the child process. Assert on what the flow put in the body or the variables instead.
mocks
How one block is stood in for. Keyed by block address, which works unquoted as a YAML key.
| Field | Type | Description |
|---|---|---|
cases | list | Tried in order; the first whose when holds is applied. |
default | object | What the block does when no case matched. |
At least one of the two is required.
mocks:
charge-flowlevel.call-charge:
cases:
- when: 'body.amount > 100'
error: card declined
default:
body: { id: ch_1, status: captured }A mock case
| Field | Type | Required | Description |
|---|---|---|---|
when | string | yes | A CEL expression over the message the block received: body, vars, env, eventID, correlationID. Omitted on default. |
body | any | no | The body the block returned. A literal value, not an expression. |
vars | object | no | Variables the block set alongside its body, for blocks that report through a variable, such as an http call setting vars.status. Only valid with a body. |
error | string | no | Fail the block with this message. |
drop | bool | no | Filter the message out, as a filter block would. |
Exactly one of body, error and drop per case. when is the only expression a
mock carries; the outcome is a literal.
Without a default, a message matching no case fails the block. It does not fall
through to the real one, which is gone from the flow: the block is replaced, not
wrapped.
Mocking a composite removes everything inside it. You cannot mock an ai-router
and then assert on which route ran. Mock a block inside the composite instead
(charge.resilient-charge[process].build-charge): the composite runs for real, and
only the leaf is stood in for.
Un-mocking a block for one case
A case can lift the file's mock, so that one case runs the real block. Write the
address with a null:
mocks:
progress.accepted: { default: { body: {} } } # every case, except…
cases:
- name: the flow still reaches the emit point
expect:
body: { percent: 100 }
- name: without a live stream the block fails rather than pretending it wrote
mocks:
progress.accepted: null # …this one, which runs the real block
expect:
error: sse-eventIt is for the one case whose point is what the real block does, such as asserting that
it fails loudly. Without it, the file-level mocks: would have to be repeated on every
other case.
Only an address the file actually mocks may be nulled; dolphin refuses the file
otherwise, naming the address. The misspelled progres.accepted: null would otherwise
read as this case runs the real block while the mock it meant to lift stayed on.
mocks: {} is not an un-mock. An empty map contributes nothing, so the file's mocks
all still apply.
spies
What one watched block should have seen. Keyed by block address.
| Field | Type | Description |
|---|---|---|
count | integer ≥ 0 | The exact number of times the block was crossed. |
records | list | The crossings, in order. |
A block inside a fork branch or a foreach body is crossed once per branch or item,
and every crossing is recorded, so the count is the iteration count. count: 0 is
an assertion, not an absence: it says the block never ran.
Records are positional: the first entry is the first crossing. Asserting on fewer crossings than happened is fine; naming more than happened is a failure.
spies:
demo.each-order[body].classify-order:
count: 2
records:
- input: { that: ['vars.order.id == 1'] }
- input: { that: ['vars.order.id == 2'] }A crossing
| Field | Type | Description |
|---|---|---|
input | object | The message the block received. |
output | object | The message the block returned. |
dropped | bool | The block filtered the message out. |
error | string | The block failed, with a message containing this text. |
output, dropped and error are exclusive. input is always available, even for a
block that failed, because it is captured before the block runs.
input and output each take body (exact), vars (subset) and that (CEL), the
same three fields expect uses for a message.
Unknown keys are rejected
dolphin decodes a suite with unknown fields disallowed, and refuses the whole file over
one. A misspelled spys: would otherwise watch nothing, assert nothing, and go green.
The editor's Testing tab reports the same thing
before you run, and disables the form rather than re-serializing a file it cannot fully
read.
Generating the schema
To have your editor complete a suite and a validator check it:
dolphin schema --format yaml # or --format json, the default
dolphin schema --out test.schema.jsonSee also
- Writing test cases: the same format, explained through worked examples.
- Running tests:
dolphin test, its flags, and the exit codes. - CLI reference: the block-address grammar
mocksandspiesare keyed by. - CEL reference: the expression language
thatandwhenuse.