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

KeyMeaning
workflowPath to the workflow JSON, relative to the test file
idUnique within the file. Results are reported by it
titleOptional. Shown in the report
when.triggerwebhook or manual, or { node: <Trigger node name> } to start from a named trigger
when.payloadThe request body. workflow-tester wraps it the way a Webhook node delivers it, so $json.body.… works as it does in n8n
given.pinDataItems a node is taken to have produced, so the run carries on past a node that calls out. See below
thenWhat you expect. See below

A file with one case can put when and then at the top level and leave out cases.

Expectations

ExpectationChecks
execution.status: successHow the run ended
node.<Node name>.output[0].json.<path>: valueA field in a node's output
node.<Node name>.items: 1How 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.

KeyMeaning
given.packsThe packs the mock enables for this case
given.faultsFaults to inject, per service: { status, delayMs, empty, after, once }. Replaced wholesale per case, never inherited
given.seedRecords the mock starts the case with, per service: { <service>: { <collection>: [records] } }
given.snapshotA snapshot the mock replays for this case
then.callsWhat the mock must have seen: a list of { service, method, path, count } (or gte/lte); path takes *
then.noUnmatchedtrue 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 runThe cases that need the mockEvery other caseExit code
workflow-tester runReported as needing the mock, naming the keys and --mock or --live (· in the terminal, skipped in JUnit). Not failedRun as usualFrom the other cases only, --fail-on warn included
workflow-tester run --mock, mock not startedFail with cannot reach integration-mock at <url> — is it started?Run as usual1
workflow-tester run --live, mock not startedFail with cannot reach integration-mock at <url> — is it started?Run as usual1

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.