Postboy Help

Testing: Async Waiters

When a message is emitted inside a subscription, a microtask, or a timer, the history is not filled yet at assertion time. world.waiter solves this with promise-based waits over the live message stream. Every method rejects with a descriptive Error on timeout; the default timeout is 1000 ms.

WaitOptions

interface WaitOptions<T> { timeout?: number; // ms, default 1000 where?: (message: T) => boolean; // only matching messages resolve the wait includeHistory?: boolean; // default false - also consider recorded messages }

waitForMany adds exact?: boolean; waitForNone adds timeoutMessage?: string.

waitFor - one future message

// Start the wait before the act when possible const promise = world.waiter.waitFor(TaskCompletedEvent, { where: (e) => e.taskId === 'abc', timeout: 2000, }); service.startTask('abc'); const event = await promise;

Resolves with the first future message matching where (any message if omitted). With includeHistory: true the waiter first scans recorded messages and resolves immediately on a hit.

waitForMany - several messages

// Resolves as soon as 3 matching messages are collected const events = await world.waiter.waitForMany(ProgressEvent, 3);

With exact: true the waiter must prove the count is precise, so it always waits the full timeout before resolving; it rejects on both under- and over-count. Omit exact when speed matters.

// Accepts exactly 2 - rejects on 1 or 3, but only after the full timeout const events = await world.waiter.waitForMany(LogEvent, 2, { exact: true, timeout: 1000 });

waitForAny - race several types

const outcome = await world.waiter.waitForAny([OrderSucceededEvent, OrderFailedEvent]); expect(outcome).toBeInstanceOf(OrderSucceededEvent);

Resolves with the first matching message of any of the given types. The options apply to the raced types as one group; for per-type decisions, inspect the resolved instance after the await.

waitForCallbackResult - callback outcome

world.given.callback(FetchUserQuery, fakeUser); const promise = world.waiter.waitForCallbackResult(FetchUserQuery); service.loadUser(); const user = await promise; // the value passed to finish(...)

Resolves with the result of the next message.finish(result) for that callback type - or with an already-recorded result when includeHistory: true. If nothing ever calls finish, the wait times out. Callback stubs usually finish synchronously, so when you await only after the callback has already completed, pass includeHistory: true - otherwise the waiter keeps waiting for a next finish that never comes.

waitForNone - assert silence

service.run(); await world.waiter.waitForNone(ErrorEvent, { timeout: 300, includeHistory: true });

Resolves only after the timeout passes without a matching message; rejects immediately when one fires, or when one was already recorded and includeHistory: true is set. timeoutMessage overrides the rejection text.

delay - plain sleep

await world.waiter.delay(100); // let timers and subscriptions settle

Not an assertion. Prefer a specific wait whenever an expected message exists.

Semantics

Method

Resolves with

Resolves when

Rejects when

waitFor

the message

first future match of where (a historical one with includeHistory: true)

timeout without a match

waitForMany

T[]

count matches collected; with exact: true only after the full timeout

timeout with fewer matches; with exact: true any count other than count

waitForAny

the message

first match across all raced types

timeout without any match

waitForCallbackResult

the result

next finish(result) of the type (a recorded one with includeHistory: true)

timeout without a finish

waitForNone

void

timeout elapsed in silence

a matching message fires

delay

void

after ms

never

Pitfalls

  • includeHistory defaults to false. A message fired before the wait is invisible without it - the most common cause of surprising waiter timeouts.

  • waitForMany with exact: true always burns the full timeout, even when the count already looks right. Keep the timeout small.

  • waitForNone adds its whole timeout to the test duration. Use the smallest safe value.

  • Waiters clean up their own subscriptions when they settle, but world.dispose() in afterEach is still mandatory.

Next steps

  • Assertions: verify the history once the wait resolved.

  • Recipes: complete async test scenarios.

  • PostboyWorld: the world fixture and dispose discipline.

07 September 2026