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:
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:
Given a cart, valid validation, and a shipping cost.
When checkout runs.
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 cases → waitForNone with includeHistory.
Subscription counts → then.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