Postboy Help

Testing: Recipes

Task-based patterns for common test scenarios. Unless stated otherwise, every recipe assumes the standard fixture:

let world: PostboyWorld; beforeEach(() => (world = new PostboyWorld())); afterEach(() => world.dispose()); // mandatory per test

Test a service that fires events

it('fires PaymentCompletedEvent', () => { const service = new PaymentService(world.postboy); // step 1: SUT gets the mock bus service.process(100); // step 2: act world.then.fired(PaymentCompletedEvent).once(); // step 3: assert });

The key move is constructing the system under test with world.postboy - a real PostboyService records nothing.

Test a component that reacts to events

it('receives pre-fired state on subscribe', () => { world.given.event(new CartUpdatedEvent(3)); // registers replay AND fires now const component = new CartComponent(world.postboy); // subscribes in constructor expect(component.items).toBe(3); // replay buffer delivers the event world.then.subscribed(CartUpdatedEvent).once(); }); it('reacts to events fired after subscription', () => { const component = new CartComponent(world.postboy); // subscribes in constructor world.postboy.fire(new CartUpdatedEvent(5)); // deliver through the existing registration expect(component.items).toBe(5); });

given.event overwrites the replay registration for its type and fires immediately; it suits pre-fired state. To deliver to listeners that are already subscribed, fire through the bus directly.

Assert payload predicates

it('fires the event with the computed payload', () => { world.given.executor(GetTaxRateExecutor, 0.2); const service = new OrderService(world.postboy); service.place(42); world.then .fired(OrderPlacedEvent) .once() .with((e) => e.orderId === 42 && e.tax === 0.2); });

For ordering or index access, read the history collection: world.history.messages(OrderPlacedEvent).all.

Stub executors and callback messages

Fixed answers - the common case:

// exec() returns 0.2 synchronously world.given.executor(GetTaxRateExecutor, 0.2); // every fired FetchUserQuery is finished with fakeUser world.given.callback(FetchUserQuery, fakeUser);

Dynamic or failing stubs need the low-level mock. The action returns the result and may branch or throw:

world.mocks.mockExecute(ValidateOrderExecutor, (e) => { if (e.amount < 0) throw new Error('negative amount'); return true; }); world.mocks.mockCallback(FetchCartQuery, (m) => (m.vip ? vipCart : emptyCart));

Every stubbed interaction is still recorded - assert it with world.then.fired(GetTaxRateExecutor).once().

Assert that nothing happened

// Synchronous: nothing in recorded history service.place(42); world.then.notFired(OrderFailedEvent); // Across a time window, for async code paths await world.waiter.waitForNone(OrderFailedEvent, { timeout: 300, includeHistory: true });

Write a strict-mode test

describe('OrderService (strict)', () => { let world: PostboyWorld; beforeEach(() => { world = new PostboyWorld({ strict: true }); world.registry.recordSubject(OrderPlacedEvent); world.registry.recordExecutor(GetTaxRateExecutor, () => 0.2); }); afterEach(() => world.dispose()); it('places an order', () => { const service = new OrderService(world.postboy); service.place(42); world.then.fired(OrderPlacedEvent).once(); }); });

An unregistered type throws at first use, so missing wiring fails loudly instead of passing silently. given calls register too - world.given.executor(GetTaxRateExecutor, 0.2) replaces the explicit recordExecutor line.

Caveats and best practices

  • Dispose per test: afterEach(() => world.dispose()). Reusing a world accumulates history and breaks times/once assertions.

  • The SUT must receive world.postboy; anything else records nothing.

  • given.event fires immediately and appears in history right away - relevant for once/times counts and for waiters with includeHistory.

  • exec is synchronous - never await it. For async results use callback messages with given.callback or waitForCallbackResult.

  • Callback stubs answer via finish(result). If nothing finishes the message, the caller's observable never emits and waitForCallbackResult times out.

  • Waiters ignore history unless includeHistory: true.

  • Prefer given over world.mocks; drop to mocks only for dynamic or throwing stubs.

  • Do not manually touch the internal mock-namespace.../waiter-namespace... registrators - it breaks dispose().

Next steps

07 September 2026