Skip to content

Testing Workflows

This is the page to copy from. Every code block below is compiled as part of a real project and compared byte-for-byte with it, so none of it can quietly go stale. If a block here doesn’t work, the build breaks.

One thing to know before you start. The public npm package gives you types and editor support — it is not the engine. The runtime that actually starts PostgreSQL and executes your workflows comes from the IdentityFlow Test distribution you download separately, at the same version. Your own project contains only your code, config, test data, and the public package references.

Gate G3 — package publication. The @identity-flow/* packages belong together at one exact version. If one of them is on a different version, stop and fix that before debugging anything else — the failures it causes are not obvious.

Terminal window
pnpm add @identity-flow/api@0.2.0 @identity-flow/binding-graphql@0.2.0 @identity-flow/sdk@0.2.0
pnpm add --save-dev @identity-flow/testing@0.2.0 @types/node@26.0.0 vitest@4.1.9 typescript@6.0.3

One file, one line. It only does anything under the Test distribution; called from the public package alone it fails immediately rather than pretending to work.

import { defineWorkflowTestSetup } from '@identity-flow/testing/vitest';
export default defineWorkflowTestSetup();

Register the file as a Vitest global setup:

import { defineConfig } from 'vitest/config';
// Workflow Tests use the `*.case.ts` suffix, which Vitest's default `include`
// does not match. Declaring it here is the only supported way to select them.
export default defineConfig({
test: { include: ['**/*.case.ts'], globalSetup: ['./workflow-test.setup.ts'] },
});
import { defineBinding } from '@identity-flow/sdk';
import { vi } from 'vitest';
export interface Directory {
lookup(userId: string): Promise<{ readonly displayName: string }>;
}
// A production project writes the factory directly. This acceptance fixture wraps
// it in a Vitest spy so the tests can prove the Workflow Test never falls back to
// the production Binding.
export const productionDirectory = vi.fn((): Directory => {
throw new Error('The production Directory Binding must not run in a Workflow Test');
});
export const Directory = defineBinding('directory', productionDirectory);
import * as v from '@identity-flow/sdk/valibot';
import { defineWorkflow } from '@identity-flow/sdk';
import { Directory } from './directory.binding';
export const approval = defineWorkflow(
{ name: 'approval', version: '1.0.0', schema: v.object({ userId: v.string() }) },
async (flow) => {
const directory = flow.use(Directory);
const person = await flow.do('load person', () => directory.lookup(flow.params.userId));
const decision = await flow.dialog(
'approve access',
{ schema: v.object({ status: v.literal('approved') }) },
() => ({
params: { displayName: person.displayName },
assignees: [{ providerId: 'siam', subject: 'role:approver' }],
}),
);
return { person, decision };
},
);

workflowTest(options) returns an ordinary Vitest test function. It hands each test three extras: flow to drive the run, accounts for the people you declared, and fixture to swap out a binding.

Read the test below for two things. First, flow.actAs(...) — the blocked account’s attempt to approve is rejected, and that rejection is asserted, not assumed. Second, the last line: it proves the real directory was never called. That assertion is the difference between a test that runs against fakes and a test that only thinks it does.

import { WorkflowTestError, workflowTest } from '@identity-flow/testing';
import { expect, vi } from 'vitest';
import { approval } from './approval.workflow';
import { Directory, productionDirectory } from './directory.binding';
const test = workflowTest({
workflow: approval,
accounts: {
requester: { providerId: 'siam', principals: ['user:requester'] },
approver: { providerId: 'siam', principals: ['role:approver'] },
blocked: { providerId: 'siam', principals: ['user:blocked'] },
},
});
test('approves access', async ({ flow, accounts, fixture }) => {
const lookup = vi.fn(() => Promise.resolve({ displayName: 'Alex Example' }));
fixture(Directory, { lookup });
const instance = await flow.actAs(accounts.requester).start({ userId: 'user-1234' });
const dialog = await flow.waitFor(instance).toHaveDialog('approve access');
await expect(
flow.actAs(accounts.blocked).completeActivity(dialog, { status: 'approved' }),
).rejects.toBeInstanceOf(WorkflowTestError);
await flow.actAs(accounts.approver).completeActivity(dialog, { status: 'approved' });
const completed = await flow.waitFor(instance).toBeCompleted();
const observed = await flow.observe(instance);
expect(completed.data).toEqual({
person: { displayName: 'Alex Example' },
decision: { status: 'approved' },
});
expect(observed.instance.id).toBe(instance.id);
expect(observed.steps).toEqual(
expect.arrayContaining([
expect.objectContaining({ name: 'load person', status: 'COMPLETED' }),
expect.objectContaining({ name: 'approve access', status: 'COMPLETED' }),
]),
);
expect(observed.events.length).toBeGreaterThan(0);
expect(lookup).toHaveBeenCalledOnce();
expect(lookup).toHaveBeenCalledWith('user-1234');
expect(productionDirectory).not.toHaveBeenCalled();
});

This type-checks the whole thing against the real packages — worth running on its own before you go looking for a workflow bug.

Terminal window
pnpm exec tsc --noEmit

Actually executing the tests is the Test distribution’s job; the public package does not implement that command.

Register your test double before start(). Forget, and the run fails the moment the binding is first used — it does not silently fall through to the real implementation.

This is the error you get. It tells you the workflow, the step, who was acting, and the events so far, with every payload, parameter, and secret stripped out — so you can paste it into a ticket without thinking twice:

WorkflowTestError: [WF_TEST_BINDING_FIXTURE_MISSING] An executed Binding has no registered Fixture.
Expected: Binding Fixture "directory" registered before start()
Actual: Binding Fixture "directory" missing at first use
Workflow: "diagnostic missing binding fixture" @ "1.0.0"
Step: "load person" (TASK, ERRORED, attempts 1)
Actor: account "requester"
Cause: (none)
Binding: "directory" (missing)
Events (4 shown, 4 total; chronological, safe metadata tie-break):
- INSTANCE_STARTED
- STEP_STARTED, step "load person", kind TASK, attempt 1
- STEP_ERRORED, step "load person", kind TASK, attempt 1
- INSTANCE_ERRORED
Binding calls: (none)
Next: Register it with fixture("directory", value) before start().
Redaction: params, dialog results, event payloads, Binding arguments/results, secrets, tokens, and raw Error details omitted.

These tests run against real PostgreSQL, one schema per test. There is no in-memory mode, and that is on purpose: how state is written and read back is half of what you are testing.