Skip to content

Testing API

Test setup runtime and fixtures Test driver start and answer Observations result and history
Test setup runtime and fixtures Test driver start and answer Observations result and history
Set up the test runtime, drive a real instance, and inspect its result, steps and events.

Entry points · Test driver · Setup · Errors · Hook tests

This page lists everything @identity-flow/testing exports, with signatures, defaults and error codes. For a step-by-step introduction, read Test workflows.

Workflow Tests require the release’s Workflow Test distribution; its version must match your @identity-flow/* packages. Hook tests run under plain Vitest. Test workflows provides the first runnable example.

Runtime requirements and npm-only hook tests

Workflow Tests need the Workflow Test distribution. The npm package @identity-flow/testing contains the types, editor support and the hook test host. workflowTest and defineWorkflowTestSetup from the npm package throw The Workflow Test runtime is not included in the public @identity-flow/testing package. The Workflow Test distribution, delivered with each release next to the IdentityFlow program, replaces them with the runtime when you run identity-flow-test --run in your project directory. The distribution and all @identity-flow/* packages must have the same version.

createHookTestHost, createFakeIdentityProvider and createFakeAssigneeResolver work with the npm package alone, under plain Vitest, the JavaScript test runner the tests are written for.

import { WorkflowTestError, workflowTest } from '@identity-flow/testing';
Import fromExports
@identity-flow/testingworkflowTest, WorkflowTestError, createHookTestHost, createFakeIdentityProvider, createFakeAssigneeResolver, and their types
@identity-flow/testing/vitestdefineWorkflowTestSetup and its option types

Code blocks on this page are copied from the package source and checked on every documentation build. In TypeScript, readonly marks a field you do not change, ? marks an optional field, A | B means either type, and Promise<T> is a value you await to get a T.

export function workflowTest<
Workflow extends WorkflowTestDefinition,
const Accounts extends WorkflowTestAccountCatalog,
>(options: WorkflowTestOptions<Workflow, Accounts>): WorkflowTestAPI<Workflow, Accounts> {

workflowTest(options) declares the workflow under test and its accounts. It returns Vitest’s test function, extended with three per-test values: flow, accounts and fixture. describe, beforeEach, test.each and the rest of Vitest work as usual. Workflow and Accounts are type parameters, similar to Go generics; TypeScript infers them from options.

export interface WorkflowTestAccountFixture {
readonly providerId: string;
readonly principals: readonly string[];
}
export type WorkflowTestAccountCatalog = Readonly<Record<string, WorkflowTestAccountFixture>>;
export interface WorkflowTestAccount<Name extends string = string> {
readonly name: Name;
}
export type WorkflowTestAccounts<Accounts extends WorkflowTestAccountCatalog> = {
readonly [Name in keyof Accounts]: WorkflowTestAccount<Name & string>;
};
export interface WorkflowTestOptions<
Workflow extends WorkflowTestDefinition,
Accounts extends WorkflowTestAccountCatalog,
> {
readonly workflow: Workflow;
readonly workflows?: readonly WorkflowTestDefinition[];
readonly accounts: Accounts;
}
OptionMeaning
workflowThe Workflow Definition under test. It runs even without draft: false.
workflowsFurther definitions the test deploys, such as child workflows started with flow.start.
accountsAccounts by name. Each has a providerId and the principals (subjects) that provider reports for it. The names become accounts.<name>.

Declared principals pass through the normal provider check, and dialog assignees are enforced as on the host: an account that is not an assignee is refused. The principals stay the same for the whole test.

Every test runs in its own database schema with a fresh engine, its own accounts and its own fixtures, so tests do not see each other’s data.

export interface WorkflowTestContext<
Workflow extends WorkflowTestDefinition,
Accounts extends WorkflowTestAccountCatalog,
> {
readonly flow: WorkflowTestFlow<Workflow>;
readonly accounts: WorkflowTestAccounts<Accounts>;
readonly fixture: BindingFixture;
}

accounts.<name> is an opaque handle for a declared account. It exposes the name only, no account ID. Pass it to flow.actAs. For a handle from another test, flow.actAs throws WF_TEST_MUTATION_REJECTED at once.

export type BindingFixture = <Binding extends NamedBinding<unknown, string>>(
binding: Binding,
value: ReturnType<Binding>,
) => void;

fixture(binding, value) registers value as the replacement for binding in this test. value must have the type the binding’s factory returns.

  • Register fixtures before the first start(). Registering later throws Binding Fixtures must be registered before start().
  • The binding must come from defineBinding(name, factory). The fixture is found by that name, not by the JavaScript function.
  • One fixture per binding name; a second registration throws.
  • Unused fixtures are allowed.
  • Every flow.use(binding) in the test returns the same fixture object. Your own vi.fn mocks record calls and results as usual.
  • A binding without a fixture fails at its first use with WF_TEST_BINDING_FIXTURE_MISSING. The real factory never runs.
export interface WorkflowTestActor<Workflow extends WorkflowTestDefinition> {
start(
params: WorkflowTestParams<Workflow>,
): Promise<WorkflowTestInstance<WorkflowTestResult<Workflow>>>;
completeActivity<Result extends Serializable<Result>>(
activity: WorkflowTestPendingDialog,
result: Result,
): Promise<WorkflowTestInstance<WorkflowTestResult<Workflow>>>;
}
export interface WorkflowTestChild<Workflow extends WorkflowTestDefinition> {
readonly instance: WorkflowTestInstance<WorkflowTestResult<Workflow>>;
readonly flow: WorkflowTestFlow<Workflow>;
}
export interface WorkflowTestWait<Workflow extends WorkflowTestDefinition> {
toHaveChild<Child extends WorkflowTestDefinition>(
name: string,
workflow: Child,
): Promise<WorkflowTestChild<Child>>;
toHaveDialog(name: string): Promise<WorkflowTestPendingDialog>;
toBeCompleted(): Promise<WorkflowTestCompletedInstance<Workflow>>;
}
export interface WorkflowTestObservation<Workflow extends WorkflowTestDefinition> {
readonly instance: WorkflowTestInstance<WorkflowTestResult<Workflow>>;
readonly steps: readonly WorkflowTestStep[];
readonly events: readonly WorkflowTestEvent[];
}
export interface WorkflowTestFlow<Workflow extends WorkflowTestDefinition> {
actAs(account: WorkflowTestAccount): WorkflowTestActor<Workflow>;
waitFor(
instance: WorkflowTestInstance<WorkflowTestResult<Workflow>> | WorkflowInstanceId,
options?: WorkflowTestWaitOptions,
): WorkflowTestWait<Workflow>;
observe(
instance: WorkflowTestInstance<WorkflowTestResult<Workflow>> | WorkflowInstanceId,
): Promise<WorkflowTestObservation<Workflow>>;
}

The test’s flow is a WorkflowTestFlow: a driver that acts on the workflow from outside, as a person or client would. It is not the flow your workflow function receives, and it is not an API for your own UI; use the GraphQL API for that.

CallResultBehavior
flow.actAs(account)WorkflowTestActorSelects the account that acts.
actor.start(params)the new instanceStarts the workflow under test. Resolves when the start is recorded; later steps may still be running.
actor.completeActivity(dialog, result)the instanceAnswers a waiting dialog. Refused with WF_TEST_MUTATION_REJECTED if the account is not an assignee, or if the answer fails the dialog’s schema (cause code WF_COMMAND_INPUT); a refused answer leaves the dialog waiting.
flow.waitFor(instance, options?)WorkflowTestWaitChecks the instance repeatedly until the expected state appears. Accepts an instance or its ID.
wait.toHaveDialog(name)the waiting dialogWaits until the named dialog waits for an answer. Pass the result to completeActivity.
wait.toBeCompleted()the completed instanceWaits until the instance completed. data has the workflow’s result type.
wait.toHaveChild(name, definition){ instance, flow } of the childWaits for the child workflow the named flow.start step created. See Child workflows.
flow.observe(instance){ instance, steps, events }Reads the current state once, without waiting.

A wait from Withdraw a dialog after a deadline:

flow.waitFor(instance).toHaveDialog('approve vacation')
  1. checks every 50 ms
  2. until the dialog is PENDING
export interface WorkflowTestWaitOptions {
/** Milliseconds between checks. @default 50 */
readonly interval?: number;
/**
* Milliseconds before the wait fails with `WF_TEST_WAIT_TIMEOUT`. @default 4000,
* below Vitest's default 5,000 ms `testTimeout`, so the named diagnostic still
* reports first.
*/
readonly timeout?: number;
}

Both values are in milliseconds and go to Vitest’s vi.waitFor. The defaults are timeout 4,000 ms and interval 50 ms; the timeout stays below Vitest’s default 5,000 ms testTimeout, so a wait that cannot succeed reports WF_TEST_WAIT_TIMEOUT first. Raise timeout when the workflow sleeps or waits for a retry, and keep Vitest’s testTimeout above it:

flow.waitFor(instance, { timeout: 30_000 })
  1. longer than any retry

A wait ends early with an error when the instance is ERRORED (WF_TEST_INSTANCE_ERRORED or WF_TEST_BINDING_FIXTURE_MISSING) or reaches a call Workflow Tests do not support (WF_TEST_UNSUPPORTED_CALL). Otherwise it fails with WF_TEST_WAIT_TIMEOUT when timeout has passed.

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.

flow.observe(instance) returns the instance, its steps and its events as stored. Observations contain real data, including parameters and answers, so keep production secrets out of Workflow Tests.

export type WorkflowTestInstanceStatus =
| 'ACTIVE'
| 'PAUSED'
| 'TERMINATED'
| 'COMPLETED'
| 'ERRORED';
export type WorkflowTestStepStatus =
| 'ACTIVE'
| 'SLEEPING'
| 'PENDING'
| 'REQUESTING'
| 'RELYING'
| 'CONTINUING'
| 'COMPLETED'
| 'CANCELED'
| 'ERRORED';

Concepts explains the instance and step statuses. Each event has a type, such as STEP_ERRORED, and a message.

Register child definitions in workflows: workflowTest({ workflow: parent, workflows: [child], accounts }). Then flow.waitFor(parentInstance).toHaveChild(stepName, child) returns the child instance and a driver typed for the child. The child’s driver has the same actAs, completeActivity, waitFor(…).toBeCompleted() and observe, and children of children work the same way. Accounts and fixtures are shared with the parent.

  • toHaveChild finds the child that the named flow.start step created, even if the child has already finished.
  • After the child finishes, the parent continues on its own. Wait for the parent’s completion separately.
  • If the child returns a refusal as data, the parent receives it as a normal result. If the child throws, waiting on the parent fails with WF_TEST_INSTANCE_ERRORED.
  • If flow.start cannot create the child, for example because the definition or version is not registered in workflows, the wait fails with WF_TEST_UNSUPPORTED_CALL.
  • Passing a definition to toHaveChild that is not registered, or not the child’s definition, fails with WF_TEST_CHILD_DEFINITION_MISMATCH.
export type WorkflowTestErrorCode =
| 'WF_TEST_WAIT_TIMEOUT'
| 'WF_TEST_MUTATION_REJECTED'
| 'WF_TEST_BINDING_FIXTURE_MISSING'
| 'WF_TEST_UNSUPPORTED_CALL'
| 'WF_TEST_CHILD_DEFINITION_MISMATCH'
| 'WF_TEST_INSTANCE_ERRORED';

Every failure of the driver is a WorkflowTestError with a code. Its message names the workflow, step, acting account and the events so far, and leaves out parameters, answers, event payloads, binding arguments and results, account fields, principal subjects, secrets and raw errors.

CodeMeaning
WF_TEST_WAIT_TIMEOUTwaitFor did not see the expected state within timeout. Check the step the message names, or raise timeout.
WF_TEST_MUTATION_REJECTEDstart or completeActivity was refused: the account is not an assignee, or the start parameters or the dialog answer fail their schema (cause code WF_COMMAND_INPUT). flow.actAs throws it for an account handle from another test.
WF_TEST_BINDING_FIXTURE_MISSINGThe workflow used a binding without a registered fixture. Register it with fixture(...) before start().
WF_TEST_UNSUPPORTED_CALLThe workflow, or one of its children, reached flow.request, or flow.start could not create a child. The message names the API and the step.
WF_TEST_CHILD_DEFINITION_MISMATCHtoHaveChild received a definition that is not registered or does not match the child.
WF_TEST_INSTANCE_ERROREDThe instance ended as ERRORED while the test waited, for example after an uncaught error.

Check the code in a test with rejects.toMatchObject({ code: 'WF_TEST_MUTATION_REJECTED' }).

export function defineWorkflowTestSetup(
options: WorkflowTestSetupOptions = {},
): (project: TestProject) => Promise<() => Promise<void>> {

defineWorkflowTestSetup(options?) from @identity-flow/testing/vitest returns the Vitest global setup that starts and stops the test database. Export its result as the default export of workflow-test.setup.ts and list that file in globalSetup in vitest.config.ts. Select Workflow Tests with include: ['**/*.case.ts']; keep one flat configuration, because the distribution does not support Vitest projects.

export interface WorkflowTestSuppliedPostgresConnection {
readonly host: string;
readonly port?: number;
readonly database: string;
readonly username: string;
readonly password?: string | undefined;
readonly ssl?:
| boolean
| {
readonly ca?: string;
readonly cert?: string;
readonly key?: string;
readonly rejectUnauthorized?: boolean;
};
}
export type WorkflowTestPostgresOptions =
| { readonly mode: 'managed' }
| {
readonly mode: 'supplied';
readonly connection: WorkflowTestSuppliedPostgresConnection;
readonly allowRemote?: true;
};
export interface WorkflowTestSetupOptions {
readonly postgres?: WorkflowTestPostgresOptions;
}
ModeDatabaseAfter the run
managed (default)One PostgreSQL 18.6 container per run, started through Docker.The container is stopped and removed.
suppliedThe server in connection. PostgreSQL 17 or 18, writable; the database must exist.The server and the database stay.

In both modes, every test gets its own schema, which is dropped after the test, also when the test fails. The setup never creates or drops a database.

Rules for supplied:

  • A host other than localhost, an address in 127.0.0.0/8, ::1 or a Unix socket path needs allowRemote: true.
  • The PG* environment variables are ignored; only connection counts.
  • The setup never falls back to managed.
  • The database role must be allowed to create schemas. The setup installs the citext extension in a schema named identity_flow_test_extensions if it is missing. An installed citext is refused if its schema belongs to another owner, grants CREATE to PUBLIC, or is not usable by the role.
  • A run that crashes can leave one test schema behind.

In managed mode, the environment variable IDENTITY_FLOW_TEST_POSTGRES_IMAGE replaces the container image.

export interface HookTestHost {
load(definition: ExportedHookDefinition): void;
readonly commands: ChangeCommandFactories;
readonly identity: {
readonly calls: readonly IdentityProviderCall[];
resolve(providerId: ProviderId, account: Account): Promise<readonly PrincipalRef[]>;
};
readonly assignees: {
readonly calls: readonly AssigneeResolverCall[];
resolve(providerId: ProviderId, input: AssigneeResolverInput): Promise<AssigneeResolution>;
};
readonly authorization: {
readonly calls: readonly PrincipalRelationCall[];
project(
name: string,
anchor: WorkflowInstanceRecipientAnchor,
signal?: AbortSignal,
): Promise<readonly PrincipalRef[]>;
};
readonly http: {
readonly calls: readonly HttpWebhookCall[];
readonly webhooks: readonly RegisteredWebhook[];
map(
hookName: string,
name: string,
input: JSONValue,
): Promise<ChangeEnvelope | readonly ChangeEnvelope[]>;
};
readonly lifecycle: {
readonly submissions: readonly ChangeEnvelope[];
start(signal?: AbortSignal): Promise<void>;
stop(): Promise<void>;
};
}

createHookTestHost() loads hooks definitions and calls their callbacks directly, with no database, no container and no network. It runs under plain Vitest. Name these test files *.test.ts and add '**/*.test.ts' to include if the Workflow Test distribution should run them too.

MemberBehavior
host.load(definition)Registers a defineHooks definition. A second registration of the same provider, resolver or webhook throws.
host.identity.resolve(providerId, account)Calls the provider’s principals callback and returns the principals.
host.assignees.resolve(providerId, input)Calls the assignee resolver.
host.authorization.project(name, anchor)Calls a registered principal relation and returns its principals.
host.http.map(hookName, name, input)Validates a JSON body against the webhook’s schema, calls its map and validates the returned change envelopes. It does not check the HMAC signature.
host.http.webhooksThe registered webhooks.
host.lifecycle.start(signal?) / .stop()Runs the registered startup callbacks; stop() runs the returned cleanup functions in reverse order. Submitted changes appear in host.lifecycle.submissions.
.calls on each areaThe calls, in order. Assignee, relation and webhook calls are validated first.

Map a login to principals shows a complete hook test.

export function createFakeIdentityProvider(
options?: FakeIdentityProviderOptions,
): FakeIdentityProvider {

createFakeIdentityProvider(options?) returns a provider that records each account it is asked about in .calls. options.principals is a fixed list or a function of the account; the default is an empty list.

export function createFakeAssigneeResolver<Context extends JSONValue>(
options: FakeAssigneeResolverOptions<Context>,
): FakeAssigneeResolver<Context> {

createFakeAssigneeResolver({ context, resolution }) returns an assignee resolver that records each call in .calls. context is the schema of the source context; resolution is a fixed result or a resolve function.