Testing
Use the test harness to control step execution and verify workflow behavior.
This guide progresses through: asserting results → basic workflow testing → advanced mocking → specialized testing (time, sagas, events).
Part 1: Asserting Results
Section titled “Part 1: Asserting Results”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.
Result assertions
Section titled “Result assertions”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 directlyconst user = unwrapOk(await fetchUser('123'));expect(user.name).toBe('Alice');
// Check for expected errorsconst error = unwrapErr(await fetchUser('unknown'));expect(error).toBe('NOT_FOUND');
// Async variants for cleaner codeconst 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 existconst result = await fetchUser('123');expect(result.ok).toBe(true);expect(result.value.name).toBe('Alice'); // TS error!
// ✅ Works - expectOk narrows the typeconst result = await fetchUser('123');expectOk(result);expect(result.value.name).toBe('Alice'); // TS knows result is Ok<T>
// ✅ Even cleaner with unwrapOkconst user = unwrapOk(await fetchUser('123'));expect(user.name).toBe('Alice');API Reference
Section titled “API Reference”| 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. |
Part 2: Basic Workflow Testing
Section titled “Part 2: Basic Workflow Testing”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.
Basic testing
Section titled “Basic testing”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'); });});Scripted outcomes
Section titled “Scripted outcomes”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 orderharness.script([ okOutcome({ id: '1', name: 'Alice' }), errOutcome('EMAIL_FAILED'), throwOutcome(new Error('Boom')),]);
// Or per stepharness.scriptStep('fetchUser', okOutcome({ id: '1', name: 'Alice' }));harness.scriptStep('sendEmail', errOutcome('EMAIL_FAILED'));harness.scriptStep('badOperation', throwOutcome(new Error('Boom')));Dynamic outcomes
Section titled “Dynamic outcomes”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'),});Part 3: Advanced Mocking
Section titled “Part 3: Advanced Mocking”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.
Prefer withDeps() for reusable test deps
Section titled “Prefer withDeps() for reusable test deps”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 onceconst 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.
Mock functions
Section titled “Mock functions”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 valuemockFetchUser.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 callsexpect(mockFetchUser.getCallCount()).toBe(2);expect(mockFetchUser.getCalls()[0]).toEqual(['1']);expect(mockFetchUser.getCalls()[1]).toEqual(['2']);Testing retries
Section titled “Testing retries”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);Part 4: Specialized Testing
Section titled “Part 4: Specialized Testing”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.
Snapshot testing
Section titled “Snapshot testing”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 compareexpect(snapshot).toMatchSnapshot();Testing time-dependent workflows
Section titled “Testing time-dependent workflows”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 completesclock.advance(500); // timeout firesconst 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.
Assertions on step invocations
Section titled “Assertions on step invocations”const result = await harness.run(executor);
const invocations = harness.getInvocations();
// Check orderexpect(invocations[0].name).toBe('fetchOrder');expect(invocations[1].name).toBe('chargeCard');
// Check that chargeCard was called after fetchOrderexpect(invocations[1].startedAt).toBeGreaterThan(invocations[0].completedAt);Full example
Section titled “Full example”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 });});Testing saga workflows
Section titled “Testing saga workflows”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 });});Saga harness API
Section titled “Saga harness API”| 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.
Event assertions
Section titled “Event assertions”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); });});Non-strict sequence matching
Section titled “Non-strict sequence matching”Allow extra events between expected ones:
// Only checks that these events appear in order, ignores othersconst result = assertEventSequence( events, ['workflow_start', 'step_complete:payment', 'workflow_success'], { strict: false });Debug helpers
Section titled “Debug helpers”Format results and events for debugging:
import { formatResult, formatEvent, formatEvents } from 'awaitly/testing';import { ok, err, type WorkflowEvent } from 'awaitly';
// Format resultsconsole.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 eventsconst 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 sequenceconsole.log(formatEvents(events));// "workflow_start → step_start:fetch-user → step_success:fetch-user → step_complete:fetch-user → workflow_success"Using debug helpers in tests
Section titled “Using debug helpers in tests”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);});