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 seamlessly 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');
FunctionDescription
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 () => {
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');
});
});

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')),
});

Return different results based on input:

const harness = createWorkflowHarness({
fetchUser: (id: string) =>
id === '1'
? okOutcome({ id, name: 'Alice' })
: errOutcome('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.

Track calls and change behavior:

import { createMockFn } from 'awaitly/testing';
const mockFetchUser = createMockFn<typeof fetchUser>();
// Set return value
mockFetchUser.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 calls
expect(mockFetchUser.calls.length).toBe(2);
expect(mockFetchUser.calls[0].args).toEqual(['1']);
expect(mockFetchUser.calls[1].args).toEqual(['2']);
import { unwrapOk } from 'awaitly/testing';
const mockFetch = createMockFn<typeof fetchData>();
// Fail twice, then succeed
mockFetch
.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);

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());
// Save to file or compare
expect(snapshot).toMatchSnapshot();

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 time
clock.tick(500); // 500ms passed
clock.tick(600); // Now 1100ms, timeout triggers
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,
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
});
});

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
});
});
MethodDescription
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

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);
});
});

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_complete'],
{ strict: false }
);

Format results and events for debugging:

import { formatResult, formatEvent, formatEvents } from 'awaitly/testing';
import { ok, err } 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 = { type: 'step_complete', name: 'fetch-user', durationMs: 42 };
console.log(formatEvent(event));
// "step_complete:fetch-user"
// Format event sequence
console.log(formatEvents(events));
// "workflow_start → step_start:fetch-user → step_complete:fetch-user → workflow_complete"
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 →