Skip to content

Test workflows

Workflow Test as your accounts Real instance with fixtures Passing run exit code 0 starts checked
Workflow Test as your accounts Real instance with fixtures Passing run exit code 0 starts checked
A Workflow Test runs a real instance as your declared accounts, and a passing run exits with code 0.

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:

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 executable identity-flow-test does 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:

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

These 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:

FilePurpose
workflow-test.setup.tsStarts and stops the test database for a run.
vitest.config.tsTells Vitest which files are tests and registers the setup file.
directory.binding.tsThe binding the workflow uses.
approval.workflow.tsThe Workflow Definition under test.
approval.workflow.case.tsThe Workflow Test.
tsconfig.jsonTypeScript 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']
  1. other names are skipped
  2. 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 the flow your workflow function receives. flow.actAs(account) selects who acts; start and completeActivity start 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.
TEST FILE POSTGRESQL Test flow.actAs(…) Fixture before start() IdentityFlow engine per test Your workflow flow.use(…) Schema one per test Factory never called acts as account registers writes runs uses
TEST FILE POSTGRESQL Test flow.actAs(…) Fixture before start() IdentityFlow engine per test Your workflow flow.use(…) Schema one per test Factory never called acts as account registers writes runs uses
Each test drives its own IdentityFlow engine and database schema as a declared account, and the workflow gets the fixture, never the real factory.
fixture(Directory, { lookup });
  1. before start()
  2. 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:

Terminal window
npx tsc --noEmit

Then run the tests. Start the Workflow Test distribution’s identity-flow-test executable from your project directory with --run:

Terminal window
# 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 --run

The 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 in data;
  • .toHaveChild(name, definition) waits for the child workflow started by the named flow.start step.

flow.waitFor waits at most 4,000 milliseconds by default and checks every 50 milliseconds:

Observe instance, children Unsupported call? anywhere in tree Instance ERRORED? instance status Expected state? dialog, child, done Timeout passed? default 4,000 ms WF_TEST_UNSUPPORTED_CALL names API and step WF_TEST_INSTANCE_ERRORED or fixture missing Resolves returns what it found WF_TEST_WAIT_TIMEOUT names the step then no no no yes yes yes yes no, again
Observe instance, children Unsupported call? anywhere in tree Instance ERRORED? instance status Expected state? dialog, child, done Timeout passed? default 4,000 ms WF_TEST_UNSUPPORTED_CALL names API and step WF_TEST_INSTANCE_ERRORED or fixture missing Resolves returns what it found WF_TEST_WAIT_TIMEOUT names the step then no no no yes yes yes yes no, again
Every check looks for an unsupported call and an ERRORED instance before the expected state, so a wait that cannot succeed fails with a named code instead of running into its timeout.

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 citext extension must be installed or installable.
  • A host other than localhost, 127.x.x.x or ::1 needs allowRemote: 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.request is not supported. A test that reaches a request step, also in a child workflow, fails with WF_TEST_UNSUPPORTED_CALL.
  • flow.sleep waits 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 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.

Every WorkflowTestError has a code. The Testing API lists all codes and what they mean.