Test workflows
A Workflow Test starts a real instance of your
workflow against a real PostgreSQL database, acts as the accounts you declare,
and checks the result. A passing run prints Vitest’s summary, such as
Test Files 1 passed (1), and exits with code 0.
Choose your path:
- You have the starter. It is already set up. Run
npm run test:workflow; it runs every*.case.tsand*.test.tsfile undertests/. Then read The test to write your own. - You set up your own project. Follow What you need, Install, Test setup and Binding and workflow, then write the test and run it. The code blocks are the files of a complete test project; copy them in order.
Workflow Tests run only with the Workflow Test distribution, the test runner
delivered with each IdentityFlow release next to the IdentityFlow program. It is
not published on npm. The npm package @identity-flow/testing contains the types,
editor support and the hook test host; running a Workflow Test with it alone
fails with an error. IdentityFlow is provided to customers directly. If you don’t have the
Workflow Test distribution for your release yet, write to
contact@kenoxa.de.
- The Workflow Test distribution of your release, for macOS on Apple Silicon
or Linux x64. It is a folder of six files, such as
identity-flow-test-v0.3.1-macos-arm64/. Keep the files together; the executableidentity-flow-testdoes not start without the others. On Linux it needs glibc; Alpine and other musl-based systems are not supported. - Docker, running. By default, every test run starts a PostgreSQL 18.6 container and removes it at the end. If you cannot run Docker, use your own PostgreSQL server instead; see Use your own PostgreSQL.
- Node.js 26.8.1 or later within Node 26, for installing the packages and type-checking.
- One version for all
@identity-flow/*packages, the same as the distribution’s. If one package has a different version, fix that before debugging anything else.
Install the packages at exact versions:
pnpm add @identity-flow/api@0.3.1 @identity-flow/binding-graphql@0.3.1 @identity-flow/sdk@0.3.1pnpm add --save-dev @identity-flow/testing@0.3.1 @types/node@26.0.0 vitest@4.1.9 typescript@6.0.3These commands use pnpm. With npm, which the starter uses, install the same
packages with npm install --save-exact, and the development packages with
npm install --save-exact --save-dev.
Vitest is the JavaScript test runner the tests are written
for, comparable to go test. The Workflow Test distribution runs Vitest with the
IdentityFlow engine added; you do not start Vitest yourself.
Your project needs these files. The rest of this page shows each one:
| File | Purpose |
|---|---|
workflow-test.setup.ts | Starts and stops the test database for a run. |
vitest.config.ts | Tells Vitest which files are tests and registers the setup file. |
directory.binding.ts | The binding the workflow uses. |
approval.workflow.ts | The Workflow Definition under test. |
approval.workflow.case.ts | The Workflow Test. |
tsconfig.json | TypeScript settings for the type check. |
Save this as workflow-test.setup.ts:
import { defineWorkflowTestSetup } from '@identity-flow/testing/vitest';
export default defineWorkflowTestSetup();Without options, the setup starts the PostgreSQL container. Save this as
vitest.config.ts to register it:
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'] },});include: ['**/*.case.ts'], globalSetup: ['./workflow-test.setup.ts']- other names are skipped
- starts the database
Workflow Test files must end in .case.ts. A test file with another name is
not run, and Vitest does not warn about it.
Keep one flat configuration. The Workflow Test distribution does not support
Vitest projects: tests in a project fall back to the npm package and fail. If
your project also has hook tests (see Test hooks) and tests that
wait longer than Vitest’s default of five seconds per test, extend the
configuration like this:
Configuration with hook tests and longer timeouts
import { defineConfig } from 'vitest/config';
// Save as vitest.config.ts. One flat configuration: the Workflow Test// distribution does not support Vitest `projects`.export default defineConfig({ test: { // Workflow Tests end in `.case.ts`; hook tests with createHookTestHost in `.test.ts`. include: ['**/*.case.ts', '**/*.test.ts'], globalSetup: ['./workflow-test.setup.ts'], // Vitest's defaults (5 s per test, 10 s per hook) are short for a real database. testTimeout: 60_000, hookTimeout: 120_000, },});The workflow under test looks up a person through a
binding and asks an approver.
Save the binding as directory.binding.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. The test wraps the factory// in a Vitest spy to prove that a Workflow Test never calls it.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);In your own project, pass the factory to defineBinding directly and do not
import vitest in production code. The wrapper here only lets the test prove
that the real factory never runs.
Save the workflow as approval.workflow.ts:
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: 'acme', subject: 'role:approver' }], }), );
return { person, decision }; },);
export default approval;The file exports the definition twice: the named export approval is what the
test imports, and the default export is what the IdentityFlow host deploys.
This definition has no draft: false. A Workflow Test runs it anyway; the
IdentityFlow host would skip it as a draft. Add draft: false before you deploy
a workflow; see Author workflows.
Save the test as approval.workflow.case.ts:
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: 'acme', principals: ['user:requester'] }, approver: { providerId: 'acme', principals: ['role:approver'] }, blocked: { providerId: 'acme', 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();});workflowTest(options) declares the workflow under test and the
accounts it uses. Each
account has a provider ID and the principals that provider reports for it. The
function returns a Vitest test function that hands every test three values:
flow, a driver that acts on the workflow from outside, as a person or a client would. It is not theflowyour workflow function receives.flow.actAs(account)selects who acts;startandcompleteActivitystart the workflow and answer a dialog as that account.accounts, handles for the declared accounts.fixture, which registers a fixture that replaces a binding for this test.
fixture(Directory, { lookup });- before start()
- found by binding name
The test checks three things. The blocked account’s answer is refused, and the
test asserts that. The lookup fixture was called exactly once, although the
workflow ran again after the answer. The last line proves the real directory
factory never ran; without it, a missing fixture could go unnoticed.
Type-check the project first. A type error is easier to find here than in a
failing test. The type check needs a tsconfig.json in the project directory;
the one the example project uses turns on the DOM library types that the
packages need:
{ "compilerOptions": { "target": "ES2024", "lib": ["ESNext", "DOM", "DOM.Iterable"], "module": "preserve", "moduleResolution": "bundler", "strict": true, "noEmit": true, "skipLibCheck": false, "types": ["node"], "verbatimModuleSyntax": true, "isolatedModules": true }, "include": ["*.ts"]}Then run it:
npx tsc --noEmitThen run the tests. Start the Workflow Test distribution’s identity-flow-test
executable from your project directory with --run:
# Run from your project directory. Use the folder where you unpacked the# Workflow Test distribution of your release and platform./opt/identity-flow/identity-flow-test-v0.3.1-linux-x64/identity-flow-test --runThe starter’s macOS setup asks before removing download quarantine from
bin/.
No environment variables are needed. The command prints Vitest’s usual summary,
such as Test Files 1 passed (1), and exits with code 0 when all tests pass.
Docker must be running.
Running plain vitest run instead first prints No test files found, then
fails with The Workflow Test runtime is not included in the public @identity-flow/testing package. Use the customer-authorized IdentityFlow Test distribution for this release. and exits with code 1. The first line is
misleading: the files were found, but the setup file stopped the run.
start and completeActivity return as soon as IdentityFlow has recorded the
command. Steps that run afterwards need flow.waitFor(instance), which checks
the instance repeatedly until the expected state appears:
.toHaveDialog(name)waits until the named dialog is waiting for an answer;.toBeCompleted()waits until the instance has completed and returns it, with its result indata;.toHaveChild(name, definition)waits for the child workflow started by the namedflow.startstep.
flow.waitFor waits at most 4,000 milliseconds by default and checks every
50 milliseconds:
When the
workflow sleeps or waits for a retry, pass a larger timeout, as the
retry recipe does:
const completed = await flow.waitFor(instance, { timeout: 30_000 }).toBeCompleted();Keep Vitest’s testTimeout above your longest wait. flow.sleep uses real time
in a Workflow Test, so a sleep longer than the wait fails with
WF_TEST_WAIT_TIMEOUT.
Retries use real time too. A step whose fixture throws is retried with the
defaults, five attempts one minute apart, before it ends as ERRORED. Set
retries explicitly on steps you test with failing fixtures, or the wait
times out long before the step gives up.
A Workflow Test checks assignees
the same way the IdentityFlow host does: an account may answer a dialog only
when one of its principals matches one of the dialog’s assignees. Any other
declared account is refused with a WorkflowTestError whose code is
WF_TEST_MUTATION_REJECTED.
The most useful refused accounts are near misses: a similar subject from the
same provider, and the same subject from another provider. This test for a
workflow saved as w3-who-may-act.workflow.ts checks both, and that a refused
answer leaves the dialog waiting:
import { workflowTest } from '@identity-flow/testing';import { expect } from 'vitest';
import { accessReview } from './w3-who-may-act.workflow';
const test = workflowTest({ workflow: accessReview, accounts: { requester: { providerId: 'acme', principals: ['user:requester'] }, approver: { providerId: 'acme', principals: ['role:approver'] }, // Near misses: a similar subject, and the same subject from another provider. deputy: { providerId: 'acme', principals: ['role:approver-deputy'] }, partner: { providerId: 'globex', principals: ['role:approver'] }, },});
test('only an account holding the assignee principal may answer', async ({ flow, accounts }) => { const instance = await flow.actAs(accounts.requester).start({ employeeId: 'e-2041' }); const dialog = await flow.waitFor(instance).toHaveDialog('review access');
for (const refused of [accounts.requester, accounts.deputy, accounts.partner]) { await expect( flow.actAs(refused).completeActivity(dialog, { keep: true }), ).rejects.toMatchObject({ code: 'WF_TEST_MUTATION_REJECTED' }); }
// A refused answer changes nothing: the dialog is still waiting. const waiting = await flow.observe(instance); expect(waiting.steps).toContainEqual( expect.objectContaining({ name: 'review access', status: 'PENDING' }), );
await flow.actAs(accounts.approver).completeActivity(dialog, { keep: false }); const completed = await flow.waitFor(instance).toBeCompleted(); expect(completed.data).toEqual({ employeeId: 'e-2041', keep: false });});The workflow it tests assigns the dialog to role:approver from provider
acme:
import * as v from '@identity-flow/sdk/valibot';import { defineWorkflow } from '@identity-flow/sdk';
export const accessReview = defineWorkflow( { name: 'access-review', version: '1.0.0', draft: false, schema: v.object({ employeeId: v.string() }), }, async (flow) => { const decision = await flow.dialog( 'review access', { schema: v.object({ keep: v.boolean() }) }, () => ({ params: { employeeId: flow.params.employeeId }, assignees: [{ providerId: 'acme', subject: 'role:approver' }], }), ); return { employeeId: flow.params.employeeId, keep: decision.keep }; },);
export default accessReview;Declared principals are fixed for the whole test. To test how your provider maps a real login to principals, test the identity hook itself; see Test hooks.
Identity hooks, the code that reports principals for an account, are tested
without the Workflow Test distribution and without a database.
createHookTestHost() from @identity-flow/testing loads a hooks definition and
calls it directly. These tests run under plain Vitest, so they run quickly.
Name hook test files *.test.ts, not *.case.ts. The configuration above
includes *.case.ts only, so add '**/*.test.ts' to include, as the
extended configuration shows. The Workflow Test distribution then runs both
kinds. To run hook tests with plain Vitest, give them a separate configuration
without the Workflow Test setup file.
Map a login to principals shows a hook and its test. The Testing API lists everything the hook test host offers.
Pass postgres: { mode: 'supplied', connection } to defineWorkflowTestSetup in
workflow-test.setup.ts to use a PostgreSQL server you run yourself instead of
the Docker container. connection takes host, port, database,
username, password and ssl.
- The server must run PostgreSQL 17 or 18 and accept writes.
- The database must exist; the setup never creates or drops a database. The
database role must be allowed to create schemas in it, and the
citextextension must be installed or installable. - A host other than
localhost,127.x.x.xor::1needsallowRemote: true. - The setup ignores the
PG*environment variables and never falls back to the container.
Each test gets its own schema, which is dropped after the test. A run that crashes can leave one schema behind. The Testing API lists the options.
flow.requestis not supported. A test that reaches a request step, also in a child workflow, fails withWF_TEST_UNSUPPORTED_CALL.flow.sleepwaits in real time; there is no controlled clock.- The driver cannot pause, resume or terminate an instance, reassign a dialog, or restart the engine.
Register every fixture before start(). A binding without a fixture fails
the moment the workflow first uses it; it never falls back to the real factory.
This is the error you get. The message below comes from a separate test whose
workflow, diagnostic missing binding fixture, uses the directory binding
without a fixture. It names the workflow, the step, who was acting, and the
events so far. Parameters, answers, event payloads and secrets are left out, so
you can share the message in an issue:
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 useWorkflow: "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_ERROREDBinding 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.Every WorkflowTestError has a code. The Testing API
lists all codes and what they mean.