Writing tests
Tests are *.test.yaml files under .workflow-tester/tests/, at any depth. To keep them elsewhere, for example beside the workflows, list the directories in .workflow-tester/config.yaml:
testsDirs:
- workflows
workflow: ../../workflows/signup.json
cases:
- id: flat-name
title: no profile object, flat name
when:
trigger: webhook
payload:
email: bob@example.com
name: Bob
then:
execution.status: success
node.Normalize.output[0].json.name: Bob
node.Respond Free.items: 1
The parts of a case
| Key | Meaning |
|---|---|
workflow | Path to the workflow JSON, relative to the test file |
id | Unique within the file. Results are reported by it |
title | Optional. Shown in the report |
when.trigger | webhook or manual, or { node: <Trigger node name> } to start from a named trigger |
when.payload | The request body. workflow-tester wraps it the way a Webhook node delivers it, so $json.body.… works as it does in n8n |
given.pinData | Items a node is taken to have produced, so the run carries on past a node that calls out. See below |
then | What you expect. See below |
A file with one case can put when and then at the top level and leave out cases.
Expectations
| Expectation | Checks |
|---|---|
execution.status: success | How the run ended |
node.<Node name>.output[0].json.<path>: value | A field in a node's output |
node.<Node name>.items: 1 | How many items a node produced |
Keys are flat and dotted. Use the node's name exactly as it appears on the canvas, spaces included.
The mistake this finds first
n8n turns an expression error into undefined and reports nothing.
{{ $json.body.profile.first_name ?? $json.body.name }} still undefined
{{ $json.body.profile?.first_name ?? $json.body.name }} works
When profile is missing, reading .first_name from it throws. n8n swallows the whole expression, the ?? fallback included. Optional chaining (?.) avoids the throw, so the fallback runs.
Nodes that call out
An HTTP Request node, a credentialed node or a Code node that calls out stops the run: its output is only knowable by running it for real. given.pinData supplies that output, the way pinning does in the n8n editor, so everything after the node is still checked.
workflow: ../../workflows/enrich.json
given:
pinData:
Fetch Plan:
- json: { plan: pro, seats: 5 }
cases:
- id: pro-plan
when:
trigger: webhook
payload: { customer: c_1 }
then:
node.Route.output[0].json.tier: paid
- id: free-plan
given:
pinData:
Fetch Plan:
- json: { plan: free, seats: 1 }
when:
trigger: webhook
payload: { customer: c_2 }
then:
node.Route.output[0].json.tier: free
Keys are node names; values are the items the node produced, in n8n's { json: … } form. A file-level given applies to every case, and a case's own pins win. The report lists the nodes that were stood in for. A capture of a real execution supplies the same thing for every case without writing it out.
given.packs, given.faults and given.seed describe a mock; see below.
Running offline against the mock
run --mock keeps the run offline, with no n8n instance, and has a local integration-mock answer HTTP Request nodes:
integration-mock start
workflow-tester run --mock
The walk stops at an HTTP Request node, works out the request it would send for each item, sends it to the mock's POST /serve, and carries on with the reply. given.packs, given.faults and given.seed apply as they do live. then.calls and then.noUnmatched are judged on the calls workflow-tester sent; if any node that calls out was not answered by the mock (a vendor node such as Slack, or one a snapshot or pinData stood in for), the case needs a real run, since a partial count could pass wrongly. --mock <url> names the admin port (default http://127.0.0.1:8081, or INTEGRATION_MOCK_ADMIN). Needs integration-mock 0.4.0 or later.
Answered offline: HTTP Request 4 to 4.5, with query, headers, and JSON, form or raw bodies. Not answered, so the node stays where the walk stops: credentials (none are sent), Retry On Fail, pagination, batching, binary or multipart bodies, file responses, versions below 4. An error reply follows the node's "On Error" setting; the error item carries error only, not n8n's details. A node that errors with no error handling is an ending the workflow does not account for, so the case fails even when it expects the error; route the error to an error output to test it.
given.snapshot names a snapshot file written by integration-mock snapshot, relative to the test file. Offline, with or without --mock, each node's output from the execution saved beside it (<execution id>.export.json) stands in for the nodes workflow-tester cannot run; a node that ran in a loop stands in with its first pass. A case's own pinData wins over the snapshot. Under --live the snapshot is sent to the mock instead.
Running live against the mock
Everything above runs offline and stops at the first node that calls out. run --live sends the cases that need more to your n8n instance, with its outbound calls served by integration-mock, and judges the execution n8n recorded plus the calls the mock saw.
integration-mock start && integration-mock packs enable acme
export N8N_API_URL=https://n8n.example.com N8N_API_KEY=…
workflow-tester run --live
The instance must already reach the mock: proxy mode through the environment integration-mock ca install prints, or URLs swapped with integration-mock creds swap. workflow-tester changes nothing in the workflow but the trigger's webhook path. Each case runs as a throwaway copy of the workflow (created, published, fired, read, deleted), one at a time, since the mock's state is shared.
A case runs live when it has any of these; every other case still runs offline in the same command.
| Key | Meaning |
|---|---|
given.packs | The packs the mock enables for this case |
given.faults | Faults to inject, per service: { status, delayMs, empty, after, once }. Replaced wholesale per case, never inherited |
given.seed | Records the mock starts the case with, per service: { <service>: { <collection>: [records] } } |
given.snapshot | A snapshot the mock replays for this case |
then.calls | What the mock must have seen: a list of { service, method, path, count } (or gte/lte); path takes * |
then.noUnmatched | true fails the case when any call reached the mock that no route answered |
execution.status, execution.errorNode, node.<Name>.items and node.<Name>.output[…] are judged against the real execution. Without --live or --mock these cases are reported as needing the mock, naming the keys. --mock <url> (or INTEGRATION_MOCK_ADMIN, default http://127.0.0.1:8081) names the mock's admin port; INTEGRATION_MOCK_ADMIN_TOKEN its token when it is bound off loopback.
Without integration-mock
workflow-tester does not need integration-mock. Only the cases with the keys above use it, and this is what they do when it is not there:
| You run | The cases that need the mock | Every other case | Exit code |
|---|---|---|---|
workflow-tester run | Reported as needing the mock, naming the keys and --mock or --live (· in the terminal, skipped in JUnit). Not failed | Run as usual | From the other cases only, --fail-on warn included |
workflow-tester run --mock, mock not started | Fail with cannot reach integration-mock at <url> — is it started? | Run as usual | 1 |
workflow-tester run --live, mock not started | Fail with cannot reach integration-mock at <url> — is it started? | Run as usual | 1 |
A case whose only mock key is given.snapshot needs no mock: it runs offline from the files. To leave the mock out of CI, run without --mock or --live. A file with no mock keys never contacts it.
Code nodes
Code nodes run in a sandbox with the same data n8n gives them. A Code node that calls out, for example with $helpers.httpRequest, stops the run at that node.
Let an AI write them
workflow-tester schema > test.schema.json
Give the schema to a model and ask it for test cases. The schema is exact, and a file with an unknown key is rejected with the line number.