Postboy Help

Recipes

Real‑world scenarios you can copy, paste, and adapt.
Each recipe shows a typical testing challenge and the cleanest way to solve it with @artstesh/postboy-testing.

1. Service that fires a generic event

The code under test:

class PaymentService { constructor(private postboy: PostboyService) { } process(amount: number) { // domain logic... this.postboy.fire(new PaymentCompletedEvent(amount)); } }

Test:

import { Forger } from '@artstesh/forger'; it('fires PaymentCompletedEvent after processing', () => { // Arrange const service = new PaymentService(world.postboy); const amount = Forger.create<number>()!; // Act service.process(amount); // Assert world.then .fired(PaymentCompletedEvent) .once() .with(event => event.amount === amount); });

No spies, no manual mocks — just instantiate, act, and verify.

2. Callback request‑response

The code under test:

class UserProfileLoader { constructor(private postboy: PostboyService) { } async load(userId: number): Promise<User> { const result$ = this.postboy.fireCallback(new FetchUserQuery(userId)); return firstValueFrom(result$); } }

Test with given.callback:

import { Forger } from '@artstesh/forger'; it('returns user from callback', async () => { // Arrange const fakeUser = Forger.create<User>()!; world.given.callback(FetchUserQuery, fakeUser); const loader = new UserProfileLoader(world.postboy); // Act const user = await loader.load(Forger.create<number>()!); // Assert expect(user).toEqual(fakeUser); });

Need different answers for different queries? Drop down to world.mocks.mockCallback.

3. Executor with stubbed return value

The code under test:

class TaxCalculator { constructor(private postboy: PostboyService) { } calculate(amount: number): number { const rate = this.postboy.exec(new GetTaxRateExecutor()); return amount * rate; } }

Test with given.executor:

it('uses executor return value', () => { // Arrange world.given.executor(GetTaxRateExecutor, 0.2); const calc = new TaxCalculator(world.postboy); // Act const result = calc.calculate(100); // Assert expect(result).toBe(20); });

The executor call is recorded in history just like a fired message.

4. Async event with waitFor and where

The code under test:

class AsyncTaskRunner { constructor(private postboy: PostboyService) { } start(taskId: string) { setTimeout(() => { this.postboy.fire(new TaskCompletedEvent(taskId, 'done')); }, 50); } }

Test with waitFor:

it('waits for async completion', async () => { // Arrange const runner = new AsyncTaskRunner(world.postboy); const taskId = Forger.create<string>()!; // Act runner.start(taskId); const event = await world.waiter.waitFor(TaskCompletedEvent, { where: e => e.taskId === taskId }); // Assert should().string(event.result).equal('done'); });

5. Asserting no error event occurs (waitForNone)

The code under test:

class SafeService { constructor(private postboy: PostboyService) { } execute() { try { // normal logic } catch { this.postboy.fire(new ErrorOccurredEvent()); } } }

Test:

it('does not fire error event', async () => { const service = new SafeService(world.postboy); // service.execute(); // await world.waiter.waitForNone(ErrorOccurredEvent, { timeout: 300, includeHistory: true, }); });

includeHistory: true checks messages already sent; without it, only future messages are considered.

6. Verifying subscription counts (then.subscribed)

The code under test:

class EventListener { constructor(private postboy: PostboyService) { this.postboy.sub(UpdateEvent).subscribe(e => this.handle(e)); this.postboy.sub(UpdateEvent).subscribe(e => this.log(e)); } handle(e: UpdateEvent) { } log(e: UpdateEvent) { } }

Test:

it('creates two subscriptions', () => { // Arrange & Act new EventListener(world.postboy); // Assert world.then.subscribed(UpdateEvent).times(2); });

Only the count matters — then.subscribed works with counters, not message payloads.

7. Combined scenario: Arrange with given, Act, Assert with then and waiter

Here a CheckoutService:

  • Reacts to CartUpdatedEvent by calling an executor and a callback.

  • Eventually fires OrderPlacedEvent.

The test ties everything together:

it('completes checkout flow', async () => { // ---- Arrange ---- world.given .event(new CartUpdatedEvent()) .executor(ValidateCartExecutor, true) .callback(CalculateShippingQuery, {cost: 5}); const service = new CheckoutService(world.postboy); const cartId = Forger.create<string>()!; // ---- Act ---- service.checkout(cartId); const orderEvent = await world.waiter.waitFor(OrderPlacedEvent, { where: e => e.cartId === cartId, includeHistory: true }); // ---- Assert ---- should().number(orderEvent.total).equals(105); world.then .fired(ValidateCartExecutor).once() .and() .fired(CalculateShippingQuery).once(); });

The test reads top‑to‑bottom:

  1. Given a cart, valid validation, and a shipping cost.

  2. When checkout runs.

  3. Then an order is placed with the expected total, and all dependencies were called exactly once.

8. Dynamic mock with world.mocks.mockCallback

When a single fixed answer isn’t enough, use mocks:

it('answers second callback differently', async () => { let call = 0; world.mocks.mockCallback(FetchStatusQuery, (msg) => { call++; msg.finish(call === 1 ? 'pending' : 'done'); }); // const result1 = await firstValueFrom(world.postboy .fireCallback(new FetchStatusQuery('x'))); const result2 = await firstValueFrom(world.postboy .fireCallback(new FetchStatusQuery('x'))); // should().string(result1).equal('pending'); should().string(result2).equal('done'); });

This keeps complex mock logic inside the test, close to the assertion that needs it.

Key takeaways

  • Simple cases → use given + then.

  • Async cases → start waiter before acting.

  • Conditional mocks → use world.mocks.mockCallback/mockExecute.

  • Negative caseswaitForNone with includeHistory.

  • Subscription countsthen.subscribed().times(n).

  • Always call world.dispose() in afterEach.

Mix and match these recipes to fit your application’s exact event‑driven patterns.

07 мая 2026