Testing API
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 from | Exports |
|---|---|
@identity-flow/testing | workflowTest, WorkflowTestError, createHookTestHost, createFakeIdentityProvider, createFakeAssigneeResolver, and their types |
@identity-flow/testing/vitest | defineWorkflowTestSetup 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;}| Option | Meaning |
|---|---|
workflow | The Workflow Definition under test. It runs even without draft: false. |
workflows | Further definitions the test deploys, such as child workflows started with flow.start. |
accounts | Accounts 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 throwsBinding 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 ownvi.fnmocks 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.
| Call | Result | Behavior |
|---|---|---|
flow.actAs(account) | WorkflowTestActor | Selects the account that acts. |
actor.start(params) | the new instance | Starts the workflow under test. Resolves when the start is recorded; later steps may still be running. |
actor.completeActivity(dialog, result) | the instance | Answers 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?) | WorkflowTestWait | Checks the instance repeatedly until the expected state appears. Accepts an instance or its ID. |
wait.toHaveDialog(name) | the waiting dialog | Waits until the named dialog waits for an answer. Pass the result to completeActivity. |
wait.toBeCompleted() | the completed instance | Waits until the instance completed. data has the workflow’s result type. |
wait.toHaveChild(name, definition) | { instance, flow } of the child | Waits 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')- checks every 50 ms
- 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 })- 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.
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.
toHaveChildfinds the child that the namedflow.startstep 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.startcannot create the child, for example because the definition or version is not registered inworkflows, the wait fails withWF_TEST_UNSUPPORTED_CALL. - Passing a definition to
toHaveChildthat is not registered, or not the child’s definition, fails withWF_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.
| Code | Meaning |
|---|---|
WF_TEST_WAIT_TIMEOUT | waitFor did not see the expected state within timeout. Check the step the message names, or raise timeout. |
WF_TEST_MUTATION_REJECTED | start 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_MISSING | The workflow used a binding without a registered fixture. Register it with fixture(...) before start(). |
WF_TEST_UNSUPPORTED_CALL | The 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_MISMATCH | toHaveChild received a definition that is not registered or does not match the child. |
WF_TEST_INSTANCE_ERRORED | The 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;}| Mode | Database | After the run |
|---|---|---|
managed (default) | One PostgreSQL 18.6 container per run, started through Docker. | The container is stopped and removed. |
supplied | The 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 in127.0.0.0/8,::1or a Unix socket path needsallowRemote: true. - The
PG*environment variables are ignored; onlyconnectioncounts. - The setup never falls back to
managed. - The database role must be allowed to create schemas. The setup installs the
citextextension in a schema namedidentity_flow_test_extensionsif it is missing. An installedcitextis refused if its schema belongs to another owner, grantsCREATEtoPUBLIC, 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.
| Member | Behavior |
|---|---|
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.webhooks | The 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 area | The 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.