Testing: Quick Start
This page walks through one complete jest test: message classes, world setup, given stubs, service invocation, then assertions, a waiter, and teardown. Package installation is covered in Installation.
1. Install
npm install --save-dev @artstesh/postboy-testing
npm install @artstesh/postboy rxjs
Both peers (@artstesh/postboy ^3.4.1, rxjs ^7) must be present.
2. Define the messages
Every message class needs a unique static ID. See Events and Executors for the message families.
// messages.ts
import { PostboyGenericMessage, PostboyExecutor } from '@artstesh/postboy';
export class OrderPlacedEvent extends PostboyGenericMessage {
static readonly ID = 'shop.order-placed';
constructor(public readonly orderId: number, public readonly tax: number) {
super();
}
}
export class OrderConfirmedEvent extends PostboyGenericMessage {
static readonly ID = 'shop.order-confirmed';
constructor(public readonly orderId: number) {
super();
}
}
export class GetTaxRateExecutor extends PostboyExecutor<number> {
static readonly ID = 'shop.get-tax-rate';
}
3. Write the system under test
The service depends on PostboyService; in the test it receives the mock.
// order.service.ts
import { PostboyService } from '@artstesh/postboy';
export class OrderService {
constructor(private readonly postboy: PostboyService) {}
place(orderId: number): void {
const tax = this.postboy.exec(new GetTaxRateExecutor()); // synchronous
this.postboy.fire(new OrderPlacedEvent(orderId, tax));
setTimeout(() => this.postboy.fire(new OrderConfirmedEvent(orderId)), 10);
}
}
4. Write the test
// order.service.test.ts
import { PostboyWorld } from '@artstesh/postboy-testing';
describe('OrderService', () => {
let world: PostboyWorld;
// One fresh world per test - never share it between tests.
beforeEach(() => (world = new PostboyWorld()));
// Mandatory: resets history, unsubscribes mocks, tears down namespaces.
afterEach(() => world.dispose());
it('fires OrderPlacedEvent with the stubbed tax rate', () => {
// Given - the executor returns a fixed rate
world.given.executor(GetTaxRateExecutor, 0.2);
const service = new OrderService(world.postboy);
// When
service.place(42);
// Then - synchronous assertion on recorded history
world.then
.fired(OrderPlacedEvent)
.once()
.with((e) => e.orderId === 42 && e.tax === 0.2);
});
it('confirms the order asynchronously', async () => {
world.given.executor(GetTaxRateExecutor, 0.2);
const service = new OrderService(world.postboy);
// When - confirmation fires inside setTimeout, after this line returns
service.place(42);
// Then - await the future message
const confirmed = await world.waiter.waitFor(OrderConfirmedEvent, {
where: (e) => e.orderId === 42,
});
expect(confirmed.orderId).toBe(42);
});
});
What happened
world.given.executor(...) registered a stub, so the synchronous exec returned 0.2.
world.postboy recorded everything the service did through the bus.
world.then.fired(...) asserted the immediate fire; world.waiter.waitFor(...) awaited the delayed one.
world.dispose() gave the next test a clean slate.
Pitfalls
Waiters ignore already-recorded messages by default. If the message may have fired before the wait call, pass { includeHistory: true }.
world.given.event(...) fires immediately; it is a pre-fired event, not a deferred one.
Do not construct the service with a real PostboyService; recording only works through world.postboy.
Next steps
PostboyWorld: all world getters, strict mode, low-level escape hatches.
Assertions: the full then builder chain and history access.
Async waiters: every waiter method and its options.
Recipes: callback stubs, silence checks, strict-mode tests.
07 September 2026