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 seamlessly 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 () => { const harness = createWorkflowHarness({ fetchOrder: okOutcome({ id: '123', total: 100 }), chargeCard: okOutcome({ txId: 'tx-123' }), });
const result = await harness.run(async ({ step, deps }) => { const order = await step('fetchOrder', () => fetchOrder('123')); const payment = await step('chargeCard', () => 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: okOutcome({ id: '123', total: 100 }), chargeCard: errOutcome('DECLINED'), });
const result = await harness.run(async ({ step, deps }) => { const order = await step('fetchOrder', () => fetchOrder('123')); const payment = await step('chargeCard', () => 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:
const harness = createWorkflowHarness({ // Always succeeds fetchUser: okOutcome({ id: '1', name: 'Alice' }),
// Always fails sendEmail: errOutcome('EMAIL_FAILED'),
// Throws exception badOperation: throwOutcome(new Error('Boom')),});Dynamic outcomes
Section titled “Dynamic outcomes”Return different results based on input:
const harness = createWorkflowHarness({ fetchUser: (id: string) => id === '1' ? okOutcome({ id, name: 'Alice' }) : errOutcome('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.
Mock functions
Section titled “Mock functions”Track calls and change behavior:
import { createMockFn } from 'awaitly/testing';
const mockFetchUser = createMockFn<typeof fetchUser>();
// Set return valuemockFetchUser.returns(okOutcome({ id: '1', name: 'Alice' }));
const harness = createWorkflowHarness({ fetchUser: mockFetchUser,});
await harness.run(async ({ step, deps }) => { await step('fetchUser', () => fetchUser('1')); await step('fetchUser', () => fetchUser('2'));});
// Check callsexpect(mockFetchUser.calls.length).toBe(2);expect(mockFetchUser.calls[0].args).toEqual(['1']);expect(mockFetchUser.calls[1].args).toEqual(['2']);Testing retries
Section titled “Testing retries”import { unwrapOk } from 'awaitly/testing';
const mockFetch = createMockFn<typeof fetchData>();
// Fail twice, then succeedmockFetch .onCall(0).returns(errOutcome('NETWORK_ERROR')) .onCall(1).returns(errOutcome('NETWORK_ERROR')) .onCall(2).returns(okOutcome({ data: 'success' }));
const harness = createWorkflowHarness({ fetchData: mockFetch });
const result = await harness.run(async ({ step, deps }) => { return await step.retry('fetchData', () => fetchData(), { attempts: 3 });});
const value = unwrapOk(result);expect(value.data).toBe('success');expect(mockFetch.calls.length).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());
// Save to file or compareexpect(snapshot).toMatchSnapshot();Testing time-dependent workflows
Section titled “Testing time-dependent workflows”Control time in tests:
import { createTestClock } from 'awaitly/testing';
const clock = createTestClock();
const harness = createWorkflowHarness(mocks, { clock });
await harness.run(async ({ step, deps }) => { const data = await step.withTimeout('fetchData', () => fetchData(), { ms: 1000 }); return data;});
// Advance timeclock.tick(500); // 500ms passedclock.tick(600); // Now 1100ms, timeout triggersAssertions 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, okOutcome, errOutcome, unwrapOk, unwrapErr,} from 'awaitly/testing';
describe('refund workflow', () => { let mockCalculateRefund: ReturnType<typeof createMockFn>; let mockProcessRefund: ReturnType<typeof createMockFn>; let harness: ReturnType<typeof createWorkflowHarness>;
beforeEach(() => { mockCalculateRefund = createMockFn(); mockProcessRefund = createMockFn();
mockCalculateRefund.returns(okOutcome({ amount: 50 })); mockProcessRefund.returns(okOutcome({ 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', () => calculateRefund('order-1')); return await step('processRefund', () => processRefund(refund)); });
const value = unwrapOk(result); expect(value.refundId).toBe('ref-123'); expect(mockCalculateRefund.calls.length).toBe(1); expect(mockProcessRefund.calls.length).toBe(1); });
it('stops if calculation fails', async () => { mockCalculateRefund.returns(errOutcome('ORDER_NOT_FOUND'));
const result = await harness.run(async ({ step, deps }) => { const refund = await step('calculateRefund', () => calculateRefund('order-1')); return await step('processRefund', () => processRefund(refund)); });
const error = unwrapErr(result); expect(error).toBe('ORDER_NOT_FOUND'); expect(mockProcessRefund.calls.length).toBe(0); // Never called });});Testing saga workflows
Section titled “Testing saga workflows”Use createSagaHarness to test workflows with compensation:
import { createSagaHarness, okOutcome, errOutcome, unwrapErr } from 'awaitly/testing';
describe('payment saga', () => { it('compensates on failure', async () => { const harness = createSagaHarness({ chargePayment: () => okOutcome({ id: 'pay_1', amount: 100 }), reserveInventory: () => errOutcome('OUT_OF_STOCK'), refundPayment: () => okOutcome(undefined), });
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({ amount: 100 }), { compensate: (p) => deps.refundPayment({ id: p.id }) } );
// This fails - triggers compensation const reservation = await saga.step( 'reserve-inventory', () => deps.reserveInventory({ items: [] }) );
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 |
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/workflow';
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 const result = assertEventSequence(events, [ 'workflow_start', 'step_start:fetch-user', 'step_complete:fetch-user', 'step_start:fetch-posts', 'step_complete:fetch-posts', 'workflow_complete', ]);
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_complete'], { strict: false });Debug helpers
Section titled “Debug helpers”Format results and events for debugging:
import { formatResult, formatEvent, formatEvents } from 'awaitly/testing';import { ok, err } 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 = { type: 'step_complete', name: 'fetch-user', durationMs: 42 };console.log(formatEvent(event));// "step_complete:fetch-user"
// Format event sequenceconsole.log(formatEvents(events));// "workflow_start → step_start:fetch-user → step_complete:fetch-user → workflow_complete"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);});