- Local Development
- Workflow Testing
Workflow Testing
Ironflow provides an in-memory test client that allows you to unit test your functions without a running server, NATS, or a database. Tests run instantly and deterministically.
Installation
Section titled “Installation”The test client is included in @ironflow/node:
npm install @ironflow/nodeImport from the /test subpath:
import { createTestClient } from "@ironflow/node/test";The test client lives in the ironflowtest package:
import "github.com/sahina/ironflow-go/ironflow/ironflowtest"The test kit ships in the ironflow package as ironflow.testing:
from ironflow.testing import TestClientHow It Works
Section titled “How It Works”The test client replaces the durable step execution engine with a synchronous mock layer. When you call emit(), the client:
- Resolves the function triggered by the event.
- Executes the handler synchronously.
- Replaces
step.run()calls with your registered mocks. - Resolves
step.sleep()andstep.waitForEvent()immediately using pre-registered data. - Returns a
TestRunobject containing the final output and an audit log of all executed steps.
Quick Start
Section titled “Quick Start”import { describe, it, expect } from "vitest";import { createTestClient } from "@ironflow/node/test";import { myWorkflow } from "./workflows";
describe("myWorkflow", () => { it("processes a successful checkout", async () => { const t = createTestClient({ functions: [myWorkflow] });
// 1. Mock the internal steps t.mockStep("validate-order", () => ({ valid: true })); t.mockStep("charge-card", () => ({ txId: "tx_123" }));
// 2. Execute the function const run = await t.emit("order.placed", { orderId: "ord_1" });
// 3. Assert results expect(run.status).toBe("completed"); expect(run.output).toMatchObject({ success: true }); expect(run.stepOutput("charge-card")).toEqual({ txId: "tx_123" }); });});func TestMyWorkflow(t *testing.T) { tc := ironflowtest.NewClient(t, ironflowtest.Config{ Functions: []ironflow.Function{MyWorkflow}, })
// 1. Mock internal steps tc.MockStep("validate-order", func() (any, error) { return map[string]bool{"valid": true}, nil })
// 2. Execute run := tc.Emit(t, "order.placed", map[string]string{"orderId": "ord_1"})
// 3. Assert if run.Status != "completed" { t.Errorf("expected completed, got %s", run.Status) }}import asynciofrom ironflow.testing import TestClientfrom workflows import my_workflow
def test_successful_checkout(): tc = TestClient([my_workflow])
# 1. Mock the internal steps tc.mock_step("validate-order", lambda: {"valid": True}) tc.mock_step("charge-card", lambda: {"tx_id": "tx_123"})
# 2. Execute the function (emit is async) run = asyncio.run(tc.emit("order.placed", {"order_id": "ord_1"}))
# 3. Assert results assert run.status == "completed" assert run.step_output("charge-card") == {"tx_id": "tx_123"}Testing Advanced Patterns
Section titled “Testing Advanced Patterns”Testing step.waitForEvent
Section titled “Testing step.waitForEvent”All three test kits resolve an optional wait immediately when no matching event is queued: use onTimeout: "return-null" in JavaScript, on_timeout="return-null" in Python, or WaitForEventOptional in Go (including branch scopes). Default waits still fail for a missing event fixture. Queueing a null payload creates a real event with null data; it does not simulate a timeout.
To test a workflow that pauses for an event, you must “prime” the test client with the expected event before emitting the trigger.
it("handles approval flow", async () => { const t = createTestClient({ functions: [approvalWorkflow] });
// Pre-register the event that waitForEvent will find t.sendEvent("order.approved", { orderId: "ord_1", approvedBy: "alice" });
const run = await t.emit("order.placed", { orderId: "ord_1" }); expect(run.status).toBe("completed");});In Python, queue the event with send_event:
tc.send_event("order.approved", {"order_id": "ord_1", "approved_by": "alice"})run = asyncio.run(tc.emit("order.placed", {"order_id": "ord_1"}))In Go, queue the event with SendEvent:
tc.SendEvent("order.approved", map[string]string{"orderId": "ord_1", "approvedBy": "alice"})run := tc.Emit(t, "order.placed", map[string]string{"orderId": "ord_1"})Testing step.invoke
Section titled “Testing step.invoke”step.invoke() calls are mocked separately from step.run(), keyed by the target function ID. An unmocked invoke throws.
const t = createTestClient({ functions: [parentWorkflow] });
t.mockInvoke("child-workflow", (data) => ({ processed: true }));
const run = await t.emit("order.placed", { orderId: "ord_1" });expect(run.status).toBe("completed");tc.MockInvoke("child-workflow", func(data any) (any, error) { return map[string]bool{"processed": true}, nil})tc.mock_invoke("child-workflow", lambda data: {"processed": True})Testing Sagas (Compensations)
Section titled “Testing Sagas (Compensations)”The test client automatically tracks which compensations were registered and executes them in reverse order if a terminal failure occurs.
it("runs compensations on failure", async () => { const t = createTestClient({ functions: [orderSaga] });
t.mockStep("charge", () => ({ id: "ch_1" })); t.mockStep("ship", () => { throw new NonRetryableError("Out of stock"); }); // NonRetryableError from "@ironflow/node"
const run = await t.emit("order.placed", {});
expect(run.status).toBe("failed"); // Verify the 'charge' was compensated expect(run.compensationsRan).toContain("charge");});In Python, run.compensations_ran lists the compensations in the order they ran (newest first):
run = asyncio.run(tc.emit("order.placed", {}))assert run.status == "failed"assert "charge" in run.compensations_ranIn Go, run.CompensationsRan lists the step names in the order the compensations ran (newest first):
tc.MockStep("charge", func() (any, error) { return map[string]string{"id": "ch_1"}, nil })tc.MockStep("ship", func() (any, error) { return nil, ironflow.NewNonRetryableError("Out of stock") })
run := tc.Emit(t, "order.placed", map[string]string{})if run.Status != "failed" || run.CompensationsRan[0] != "charge" { t.Errorf("expected a failed run that compensated charge, got %s %v", run.Status, run.CompensationsRan)}Best Practices
Section titled “Best Practices”- Isolation: Use a fresh
createTestClientfor every test case to ensure no mock or event leakage. - Determinism: In JavaScript, parallel branches (
step.parallel) run sequentially in test mode to ensure your assertions are stable. In Python they run concurrently underasyncio.gather, so assert on the results and not on the order of side effects. - Mock Depth: You only need to mock
step.runandstep.invoke.step.invokeAsyncdraws from the samemockInvokeregistry keyed by function ID, so one mock covers both. Other primitives —step.sleep,step.sleepUntil,step.publish— are handled automatically.