Testing Flows Locally Without Side Effects
flowforger run is a live run. With --auth or a token, every connector and HTTP action reaches the real service, so a loop that updates rows updates them. This page covers the run flags that control which calls go out:
| Flag | What it does |
|---|---|
--stop-before <Action> | Runs everything before the action live, then ends the run before it and prints the inputs the action would have been called with |
--mock <Action>=<file.json> | The action does not run; its outputs are the file's JSON |
--mocks <file.json> | One JSON object mapping action names to outputs |
--live <Action>[,<Action>] | Deny by default: every connector, HTTP or child-flow action must be mocked or listed, otherwise the run stops before it |
There is no --dry-run flag. A dry run is a set of mocks for the writes plus --live for the reads (see The dry-run workflow below).
Walkthrough: the local-testing example
The local-testing example has one flow, invoice-reminders.ff.ts, with one external read and one external write:
Get_exchange_rate: an HTTP GET (the read).Rate,Overdue_invoices,Reminders: a Compose, a Filter array and a Select that price the overdue invoices in USD.Send_reminders: an HTTP POST with the reminders (the write).Respond: a Response.
Both calls go to httpbin.org, which echoes what it receives, so you can run every step below without an account. The commands run from the repository root. npx flowforger, flowforger (global install) and node packages/cli/dist/index.js (from source) are interchangeable.
1. A plain run is a live run
npx flowforger run examples/local-testing/invoice-reminders.ff.ts --in examples/local-testing/payloads/invoices.json
Both HTTP calls go out. The exit code is 0 when the run Succeeded and 1 otherwise.
2. Stop before the write
npx flowforger run examples/local-testing/invoice-reminders.ff.ts \
--in examples/local-testing/payloads/invoices.json \
--stop-before Send_reminders
Everything before Send_reminders runs live. The action itself does not. The run ends with status Cancelled and exit code 3, and prints the request the action would have sent: URL, headers and the body with the two priced reminders.
Details:
- The name must be an action in the flow, at any depth. A name that does not exist is rejected before the run starts. A name that exists but is never reached (an untaken branch, for example) prints a
[WARN]and the flow runs to the end. - Connector, HTTP and child-flow actions get their inputs resolved before the stop, so you see the exact parameters. For every other action the run stops before its inputs are evaluated.
- No token is needed for the connector of the stopped action, because no call is made.
- If the action is inside a scope, condition or loop, every enclosing block ends
Cancelledtoo.
3. Mock the read, run offline
npx flowforger run examples/local-testing/invoice-reminders.ff.ts \
--in examples/local-testing/payloads/invoices.json \
--mock Get_exchange_rate=examples/local-testing/mocks/exchange-rate.json \
--stop-before Send_reminders
--mock <Action>=<file> replaces the action's outputs with the file's JSON:
- The action does not run, and no token is needed for its connector.
- Its inputs are still resolved and recorded, so the trace shows what it would have sent.
- Its status is
Succeeded, and later expressions read the mocked value. - The trace entry carries
mocked: true. The pretty trace shows(mocked).
The mock is applied at every depth and on every loop iteration. A mocked scope, condition, switch or loop is replaced whole: its body does not run.
The value is the full outputs
The file holds the action's outputs, not just its body. For an HTTP, connector or child-flow action that is the { statusCode, body } shape, because ctx.body('X') reads .body:
{
"statusCode": 200,
"body": { "args": { "base": "EUR", "quote": "USD", "rate": "1.25" } }
}
For a Compose the mock is the value itself. A mock for a write usually needs only what later actions read. For example, a SharePoint create answers 201 with the item including its ID, and a Dataverse create answers 201 with the row including its <table>id column. See connector outputs and failures for the shapes.
A mock always succeeds. To run a flow's error path with a recorded failure, replay a failed cloud run instead (see Cloud runs): there a failed action fails locally with the cloud's code and error.
Mocks are also useful for reads: mock a query with an empty list, or with an error body, to see what the flow does with an answer you would never get on demand.
4. Mock everything
npx flowforger run examples/local-testing/invoice-reminders.ff.ts \
--in examples/local-testing/payloads/invoices.json \
--mocks examples/local-testing/mocks/all.json
--mocks <file> takes one JSON object mapping action names to outputs:
{
"Get_exchange_rate": {
"statusCode": 200,
"body": { "args": { "base": "EUR", "quote": "USD", "rate": "1.25" } }
},
"Send_reminders": {
"statusCode": 200,
"body": { "json": { "reminders": [], "sentAt": "mocked" } }
}
}
With both calls mocked, the run touches nothing and ends Succeeded. The trace still carries the resolved inputs of every mocked action, which is what the flow would have sent. List them from the JSON result:
npx flowforger run examples/local-testing/invoice-reminders.ff.ts \
--in examples/local-testing/payloads/invoices.json \
--mocks examples/local-testing/mocks/all.json --json > dry-run.json
jq '[.. | objects | select(.mocked) | {name, inputs}]' dry-run.json
Rules for combining the flags:
--mockis repeatable.--mockscan be given once.- An action may appear in
--mocksor in a--mockflag, not both. - An action name that is not in the flow is rejected.
- A mock whose action is never reached prints a
[WARN]. --stop-beforeon a mocked action still stops before it.
5. Deny by default: the live guard
npx flowforger run examples/local-testing/invoice-reminders.ff.ts \
--in examples/local-testing/payloads/invoices.json \
--live Get_exchange_rate
--live <Action>[,<Action>] makes the run deny-by-default for everything that leaves the machine. Every connector, HTTP or child-flow action must either be mocked or listed as live. The first one that is neither ends the run before it, exactly like --stop-before: exit code 3, its inputs printed. Here the read is allowed and the run stops at Send_reminders.
--liveis repeatable, and each value may be a comma-separated list.- An action cannot be both live and mocked.
- Composes, variables, conditions and data operations are local and always run.
ctx.connectorWebhook()actions (approvals) are simulated locally, never call out, and are not guarded. Mock them when later actions read their outcome (see Approvals).
A write you forgot to classify cannot slip through: it stops the run.
Exit codes and the JSON result
| Exit code | Meaning |
|---|---|
| 0 | The run Succeeded |
| 1 | The run Failed or was Cancelled |
| 2 | Usage error (for example an unknown flag, or a mock for an action that does not exist) |
| 3 | The run was stopped before an action by --stop-before, --live or the replay guard |
When the output is piped, or with --json, run prints the full result { status, trace, artifacts }. A stopped run also has stopped: true and stoppedBefore, here with the request body shortened:
{
"status": "Cancelled",
"stopped": true,
"stoppedBefore": {
"nodeId": "act_5",
"name": "Send_reminders",
"inputs": {
"uri": "https://httpbin.org/post",
"method": "POST",
"headers": { "Content-Type": "application/json" },
"body": { "reminders": [], "sentAt": "2026-11-07T08:12:42.0660000Z" }
},
"reason": "stop-before"
}
}
stoppedBefore.reason is "stop-before" when --stop-before stopped the run, and "live" when the live guard did. When both apply to the same action, --stop-before wins.
Each trace entry is { nodeId, name, status, inputs?, outputs?, error?, mocked?, children?, iterations? }. inputs is present for connector, HTTP and child-flow actions, also when they failed. A scope, condition or switch holds its body under children, a loop under iterations[].actions.
The dry-run workflow
A dry run lets the flow's logic run against real data while no write happens. Whether an action reads or writes, and what a plausible answer to a write looks like, depends on the flow, so you decide it per flow (or have an AI agent that reads the flow decide it), and --live catches anything you missed.
-
Classify the actions. Treat as writes:
- connector operations that create, update, upsert, delete, send, reply, forward, upload, copy, move, add, remove, set, check in or check out;
- Dataverse bound and unbound actions, unless you know one is read-only;
- "Send an HTTP request to SharePoint" with a method other than GET;
- every
ctx.http()with a method other than GET or HEAD; - every child flow (
ctx.callWorkflow()), since the child may write.
Reads are the
Get…,List…,Search…,Find…andDownload…operations and GET requests. When unsure, mock it: a mocked read costs nothing. -
Write
mocks.jsonwith the full outputs of each write,{ "statusCode": …, "body": … }. Use recognisable fake ids (00000000-0000-0000-0000-000000000001) so they stand out in the result. Also mock any read that depends on an earlier write: a query for the row you just "created" finds nothing live. -
Run with the reads allowed:
flowforger run flow.ff.ts --in payload.json --auth \
--mocks mocks.json --live Get_items,Get_user --json > dry-run.json- Exit 0: the logic ran through.
- Exit 3 with
stoppedBefore.reason: "live": an action you did not classify would have called out. Add it tomocks.json(a write) or to--live(a read) and run again. - Exit 1: the flow failed. The trace says where.
-
Read the plan. Every entry with
mocked: trueis a write that did not happen, and itsinputsare exactly what the flow would have sent. Check that every write from step 1 appears. -
Keep variants. One mocks file per scenario (happy path, empty list, an error response) next to the flow makes the dry run repeatable.
Going further
- To reproduce a real cloud run, including its failures, use
run --replay-run: the cloud's recorded outputs become the mocks. See Cloud runs. - For breakpoints, stepping and inspecting variables, use the debugger in the VS Code extension or the web app, or let an AI agent drive one through the MCP server.
- The CLI reference lists every
runflag.