Skip to content

Testing

Use the test harness to control step execution and verify workflow behavior.

This guide progresses through: asserting resultsbasic workflow testingadvanced mockingspecialized testing (time, sagas, events).


WHAT: Type-safe utilities to assert and unwrap Result values in tests.

WHY: Vitest assertions don’t narrow TypeScript types - these utilities do, making your tests type-safe.

The awaitly/testing module provides type-safe assertion utilities that work with TypeScript:

import { unwrapOk, unwrapErr, expectOk, expectErr } from 'awaitly/testing';
// Most concise - unwrap returns the value directly
const user = unwrapOk(await fetchUser('123'));
expect(user.name).toBe('Alice');
// Check for expected errors
const error = unwrapErr(await fetchUser('unknown'));
expect(error).toBe('NOT_FOUND');
// Async variants for cleaner code
const user = await unwrapOkAsync(fetchUser('123'));
const error = await unwrapErrAsync(fetchUser('unknown'));

Why use these instead of expect(result.ok).toBe(true)?

Vitest assertions don’t narrow TypeScript types. After expect(result.ok).toBe(true), TypeScript still sees result as Result<T, E> - you can’t safely access result.value. The unwrap* and expect* functions throw on failure AND narrow the type:

// ❌ TypeScript error - result.value might not exist
const result = await fetchUser('123');
expect(result.ok).toBe(true);
expect(result.value.name).toBe('Alice'); // TS error!
// ✅ Works - expectOk narrows the type
const result = await fetchUser('123');
expectOk(result);
expect(result.value.name).toBe('Alice'); // TS knows result is Ok<T>
// ✅ Even cleaner with unwrapOk
const user = unwrapOk(await fetchUser('123'));
expect(user.name).toBe('Alice');
Function Description
expectOk(result) Asserts result is Ok, throws if Err. Narrows type.
expectErr(result) Asserts result is Err, throws if Ok. Narrows type.
unwrapOk(result) Asserts Ok and returns the value T.
unwrapErr(result) Asserts Err and returns the error E.
unwrapOkAsync(promise) Awaits, asserts Ok, returns value.
unwrapErrAsync(promise) Awaits, asserts Err, returns error.

WHAT: Create test harnesses with scripted or dynamic outcomes to control what each step returns.

WHY: Test workflows deterministically without real dependencies - script success, failure, and edge cases.

import { createWorkflowHarness, okOutcome, errOutcome } from 'awaitly/testing';
import { unwrapOk, unwrapErr } from 'awaitly/testing';
import { describe, it, expect } from 'vitest';
describe('checkout workflow', () => {
it('completes when payment succeeds', async () => {
// The harness takes the real deps; outcomes are scripted separately.
const harness = createWorkflowHarness({ fetchOrder, chargeCard });
harness.scriptStep('fetchOrder', okOutcome({ id: '123', total: 100 }));
harness.scriptStep('chargeCard', okOutcome({ txId: 'tx-123' }));
const result = await harness.run(async ({ step, deps }) => {
const order = await step('fetchOrder', () => deps.fetchOrder('123'));
const payment = await step('chargeCard', () => deps.chargeCard(order.total));
return { order, payment };
});
const value = unwrapOk(result);
expect(value.payment.txId).toBe('tx-123');
});
it('fails when payment is declined', async () => {
const harness = createWorkflowHarness({ fetchOrder, chargeCard });
harness.scriptStep('fetchOrder', okOutcome({ id: '123', total: 100 }));
harness.scriptStep('chargeCard', errOutcome('DECLINED'));
const result = await harness.run(async ({ step, deps }) => {
const order = await step('fetchOrder', () => deps.fetchOrder('123'));
const payment = await step('chargeCard', () => deps.chargeCard(order.total));
return { order, payment };
});
const error = unwrapErr(result);
expect(error).toBe('DECLINED');
});
});

Control what each step returns. script() feeds outcomes in invocation order; scriptStep() targets one step by name or key:

const harness = createWorkflowHarness({ fetchUser, sendEmail, badOperation });
// In invocation order
harness.script([
okOutcome({ id: '1', name: 'Alice' }),
errOutcome('EMAIL_FAILED'),
throwOutcome(new Error('Boom')),
]);
// Or per step
harness.scriptStep('fetchUser', okOutcome({ id: '1', name: 'Alice' }));
harness.scriptStep('sendEmail', errOutcome('EMAIL_FAILED'));
harness.scriptStep('badOperation', throwOutcome(new Error('Boom')));

For input-dependent behaviour, pass a plain dep function. It returns a Result, not a scripted outcome:

const harness = createWorkflowHarness({
fetchUser: async (id: string) =>
id === '1' ? ok({ id, name: 'Alice' }) : err('NOT_FOUND'),
});

WHAT: Mock functions that track calls, support call-specific behavior, and enable retry testing.

WHY: Test complex scenarios like retries, call tracking, and conditional responses.

If multiple tests share the same overrides, pre-bind them once with withDeps() (or workflow.withDeps()), then run the same workflow logic with less setup noise.

import { createWorkflow } from 'awaitly';
const workflow = createWorkflow('checkout', {
fetchUser: realFetchUser,
chargeCard: realChargeCard,
sendEmail: realSendEmail,
});
// Fluent form: pre-bind common test deps once
const testWorkflow = workflow.withDeps({
fetchUser: async () => ok({ id: 'u-1', email: 'test@example.com' }),
sendEmail: async () => ok(undefined),
});
const result = await testWorkflow.run(async ({ step, deps }) => {
const user = await step('fetchUser', () => deps.fetchUser('u-1'));
await step('chargeCard', () => deps.chargeCard(100));
await step('sendEmail', () => deps.sendEmail(user.email));
return user;
});

You can still override per test run:

const result = await testWorkflow.run(
async ({ step, deps }) => step('chargeCard', () => deps.chargeCard(100)),
{
deps: {
chargeCard: async () => err('DECLINED'),
},
}
);

Precedence is:

  • createWorkflow(...) deps
  • then withDeps(...) deps
  • then run(..., { deps }) deps (highest precedence)

Use withDeps() for shared baseline mocks, and use per-run deps overrides for one-off test scenarios.

Track calls and change behavior:

import { createMockFn } from 'awaitly/testing';
import { ok } from 'awaitly';
// createMockFn<Value, Error>() — mocks return Results, not scripted outcomes.
const mockFetchUser = createMockFn<{ id: string; name: string }, 'NOT_FOUND'>();
// Set return value
mockFetchUser.returns(ok({ id: '1', name: 'Alice' }));
const harness = createWorkflowHarness({
fetchUser: mockFetchUser,
});
await harness.run(async ({ step, deps }) => {
await step('fetchUser', () => deps.fetchUser('1'));
await step('fetchUser', () => deps.fetchUser('2'));
});
// Check calls
expect(mockFetchUser.getCallCount()).toBe(2);
expect(mockFetchUser.getCalls()[0]).toEqual(['1']);
expect(mockFetchUser.getCalls()[1]).toEqual(['2']);
import { unwrapOk } from 'awaitly/testing';
import { ok, err } from 'awaitly';
const mockFetch = createMockFn<{ data: string }, 'NETWORK_ERROR'>();
// `returnsOnce` queues per-call results; `returns` is the fallback.
mockFetch
.returnsOnce(err('NETWORK_ERROR'))
.returnsOnce(err('NETWORK_ERROR'))
.returns(ok({ data: 'success' }));
const harness = createWorkflowHarness({ fetchData: mockFetch });
const result = await harness.run(async ({ step, deps }) => {
return await step.retry('fetchData', () => deps.fetchData(), { attempts: 3 });
});
const value = unwrapOk(result);
expect(value.data).toBe('success');
expect(mockFetch.getCallCount()).toBe(3);

WHAT: Tools for testing time-dependent workflows, sagas with compensation, event sequences, and debugging.

WHY: Production workflows involve timeouts, compensations, and complex event flows - these utilities make them testable.

Compare workflow behavior across changes:

import { createSnapshot, compareSnapshots } from 'awaitly/testing';
const harness = createWorkflowHarness(mocks);
const result = await harness.run(executor);
const snapshot = createSnapshot(harness.getInvocations(), result);
// Save to file or compare
expect(snapshot).toMatchSnapshot();

Pass createTestClock() as clock so retry delays, step.sleep, step.withTimeout, and circuit-breaker windows do not wait on real time. Advance the clock while the run is pending, await after advance, not before.

import { run, retry, timeout, createCircuitBreaker } from 'awaitly';
import { createTestClock } from 'awaitly/testing';
const clock = createTestClock();
const pending = run(
async ({ step }) => {
await step.sleep('wait', '1s');
return await step.withTimeout('hang', () => new Promise(() => {}), { ms: 500 });
},
{ clock }
);
clock.advance(1000); // sleep completes
clock.advance(500); // timeout fires
const result = await pending;
// Policy wrappers do not pick up run({ clock }) — pass clock at wrap time.
const resilient = retry(fetchUser, {
attempts: 3,
delay: 1000,
backoff: 'fixed',
clock,
});
timeout(slowOp, 500, { clock });
createCircuitBreaker('api', { failureThreshold: 1, resetTimeout: 30_000, clock });

On step.retry / retryAsync, set jitter: false so delays are exact. The same clock option exists on createWorkflow and workflow.run (per-run overrides creation-time).

createWorkflowHarness(mocks, { clock: clock.now }) is different: that clock is a () => number used only for invocation timestamps (startedAt, durationMs). It does not drive sleep, retry delay, or timeout. Pass the Clock object into run / createWorkflow / retry / timeout / createCircuitBreaker for control-flow time.

const result = await harness.run(executor);
const invocations = harness.getInvocations();
// Check order
expect(invocations[0].name).toBe('fetchOrder');
expect(invocations[1].name).toBe('chargeCard');
// Check that chargeCard was called after fetchOrder
expect(invocations[1].startedAt).toBeGreaterThan(invocations[0].completedAt);
import { describe, it, expect, beforeEach } from 'vitest';
import {
createWorkflowHarness,
createMockFn,
unwrapOk,
unwrapErr,
} from 'awaitly/testing';
import { ok, err } from 'awaitly';
describe('refund workflow', () => {
let mockCalculateRefund: ReturnType<typeof createMockFn<{ amount: number }, 'ORDER_NOT_FOUND'>>;
let mockProcessRefund: ReturnType<typeof createMockFn<{ refundId: string }, never>>;
let harness: ReturnType<typeof createWorkflowHarness>;
beforeEach(() => {
mockCalculateRefund = createMockFn<{ amount: number }, 'ORDER_NOT_FOUND'>();
mockProcessRefund = createMockFn<{ refundId: string }, never>();
mockCalculateRefund.returns(ok({ amount: 50 }));
mockProcessRefund.returns(ok({ refundId: 'ref-123' }));
harness = createWorkflowHarness({
calculateRefund: mockCalculateRefund,
processRefund: mockProcessRefund,
});
});
it('calculates and processes refund', async () => {
const result = await harness.run(async ({ step, deps }) => {
const refund = await step('calculateRefund', () => deps.calculateRefund('order-1'));
return await step('processRefund', () => deps.processRefund(refund));
});
const value = unwrapOk(result);
expect(value.refundId).toBe('ref-123');
expect(mockCalculateRefund.getCallCount()).toBe(1);
expect(mockProcessRefund.getCallCount()).toBe(1);
});
it('stops if calculation fails', async () => {
mockCalculateRefund.returns(err('ORDER_NOT_FOUND'));
const result = await harness.run(async ({ step, deps }) => {
const refund = await step('calculateRefund', () => deps.calculateRefund('order-1'));
return await step('processRefund', () => deps.processRefund(refund));
});
const error = unwrapErr(result);
expect(error).toBe('ORDER_NOT_FOUND');
expect(mockProcessRefund.getCallCount()).toBe(0); // Never called
});
});

Use createSagaHarness to test workflows with compensation:

import { createSagaHarness, unwrapErr } from 'awaitly/testing';
import { ok, err } from 'awaitly';
describe('payment saga', () => {
it('compensates on failure', async () => {
// Deps are functions returning Results.
const harness = createSagaHarness({
chargePayment: async () => ok({ id: 'pay_1', amount: 100 }),
reserveInventory: async () => err('OUT_OF_STOCK'),
refundPayment: async () => ok(undefined),
});
// The saga context is `saga`; `saga.step(name, operation, options?)` mirrors
// the real SagaStep — name first.
const result = await harness.runSaga(async ({ saga, deps }) => {
// Charge payment - add compensation to refund if later steps fail
const payment = await saga.step(
'charge-payment',
() => deps.chargePayment(),
{ compensate: (p) => void deps.refundPayment() }
);
// This fails - triggers compensation
const reservation = await saga.step('reserve-inventory', () =>
deps.reserveInventory()
);
return { payment, reservation };
});
// Assert the workflow failed
const error = unwrapErr(result);
expect(error).toBe('OUT_OF_STOCK');
// Assert compensation ran (LIFO order)
harness.assertCompensationOrder(['charge-payment']);
harness.assertCompensated('charge-payment');
harness.assertNotCompensated('reserve-inventory'); // Failed step isn't compensated
});
});
Method Description
runSaga(fn) Run a saga workflow with compensation tracking
getCompensations() Get recorded compensation invocations (in order)
assertCompensationOrder(names) Assert compensations ran in expected order (LIFO)
assertCompensated(name) Assert a specific step was compensated
assertNotCompensated(name) Assert a step was NOT compensated

Each assertion returns { passed, message, expected, actual }, assert on .passed, or read getCompensations() directly for the raw records.

Assert on workflow events for detailed behavior testing:

import {
assertEventSequence,
assertEventEmitted,
assertEventNotEmitted,
} from 'awaitly/testing';
import { createWorkflow, type WorkflowEvent } from 'awaitly';
describe('event assertions', () => {
it('verifies event sequence', async () => {
const events: WorkflowEvent<unknown>[] = [];
const workflow = createWorkflow('workflow', deps, {
onEvent: (e) => events.push(e),
});
await workflow.run(async ({ step, deps }) => {
const user = await step('fetch-user', () => deps.fetchUser('1'));
const posts = await step('fetch-posts', () => deps.fetchPosts(user.id));
return { user, posts };
});
// Assert events occurred in order. Each step emits `step_success` *and*
// `step_complete`; the run ends with `workflow_success`.
const result = assertEventSequence(events, [
'workflow_start',
'step_start:fetch-user',
'step_success:fetch-user',
'step_complete:fetch-user',
'step_start:fetch-posts',
'step_success:fetch-posts',
'step_complete:fetch-posts',
'workflow_success',
]);
expect(result.passed).toBe(true);
});
it('verifies specific event was emitted', async () => {
const events: WorkflowEvent<unknown>[] = [];
const workflow = createWorkflow('workflow', deps, {
onEvent: (e) => events.push(e),
});
await workflow.run(async ({ step, deps }) => {
await step('fetch-user', () => deps.fetchUser('unknown'));
});
// Assert error event was emitted
const result = assertEventEmitted(events, {
type: 'step_error',
name: 'fetch-user',
});
expect(result.passed).toBe(true);
});
it('verifies event was NOT emitted', async () => {
const events: WorkflowEvent<unknown>[] = [];
const workflow = createWorkflow('workflow', deps, {
onEvent: (e) => events.push(e),
});
await workflow.run(async ({ step, deps }) => {
const user = await step('fetch-user', () => deps.fetchUser('1'));
return user;
});
// Assert no retry events (step succeeded first try)
const result = assertEventNotEmitted(events, {
type: 'step_retry',
});
expect(result.passed).toBe(true);
});
});

Allow extra events between expected ones:

// Only checks that these events appear in order, ignores others
const result = assertEventSequence(
events,
['workflow_start', 'step_complete:payment', 'workflow_success'],
{ strict: false }
);

Format results and events for debugging:

import { formatResult, formatEvent, formatEvents } from 'awaitly/testing';
import { ok, err, type WorkflowEvent } from 'awaitly';
// Format results
console.log(formatResult(ok(42)));
// "Ok(42)"
console.log(formatResult(ok({ id: '1', name: 'Alice' })));
// "Ok({ id: '1', name: 'Alice' })"
console.log(formatResult(err('NOT_FOUND')));
// "Err('NOT_FOUND')"
console.log(formatResult(err({ type: 'VALIDATION_ERROR', field: 'email' })));
// "Err({ type: 'VALIDATION_ERROR', field: 'email' })"
// Format events
const event: WorkflowEvent<unknown> = {
type: 'step_complete',
workflowId: 'wf-1',
stepKey: 'fetch-user',
name: 'fetch-user',
ts: Date.now(),
durationMs: 42,
result: ok({ id: '1' }),
};
console.log(formatEvent(event));
// "step_complete:fetch-user"
// Format event sequence
console.log(formatEvents(events));
// "workflow_start → step_start:fetch-user → step_success:fetch-user → step_complete:fetch-user → workflow_success"
it('debugs failing workflow', async () => {
const events: WorkflowEvent<unknown>[] = [];
const workflow = createWorkflow('workflow', deps, { onEvent: (e) => events.push(e) });
const result = await workflow.run(async ({ step, deps }) => {
const user = await step('fetchUser', () => deps.fetchUser('1'));
return user;
});
// Print for debugging
console.log('Result:', formatResult(result));
console.log('Events:', formatEvents(events));
// Then assert
expectOk(result);
});

Learn about Batch Processing →