@relaybus/testing
@relaybus/testing is the test toolkit of relaybus, a reliable, storage-agnostic event bus for NestJS and Node.js: it provides the EventStore contract suite, a FakeClock and an in-memory transport for deterministic tests of retries and delivery.
Test utilities for relaybus.
pnpm add -D @relaybus/testing vitest # or jest: see the codec contract belowStore contract suite
Every EventStore must pass it unchanged against a real database:
import { eventStoreContract } from '@relaybus/testing';
eventStoreContract('MyEventStore', {
createStore: async () => {
const store = new MyEventStore(await connect());
await store.setup();
return store;
},
destroyStore: async (store) => store.close(),
// a second instance on the same database with its own connections
createPeerStore: async () => new MyEventStore(await connect()),
});It covers transactional visibility, idempotent appends (a handler that already completed is never delivered again), leasing and fencing, batched completion (completeMany), lost leases (redelivery, attempt counting for crashed workers, poison deliveries), retries and dead letters, release, relinquish, per-key ordering in commit order, starvation, purge, statistics, data fidelity and cross-instance claim races.
A store may lease groups of deliveries (lanes) instead of single deliveries, so the contract never assumes that two claimers see each other's unclaimed work: a claimer that keeps claiming drains the workload. Pass keepsCompletedRecords: true when the store keeps a completion history; run the suite once per mode the store supports.
Codec contract
Before adopting a wire format that other services already use, prove that a codec writes and reads it exactly. Give kafkaCodecContract or rabbitMqCodecContract captured messages and the events they carry; each case is one test that checks encode(event) against the captured message (value or body byte for byte or as canonical JSON, key, headers, timestamp or properties) and that decoding the captured message gives the event:
import { kafkaCodecContract } from '@relaybus/testing';
import { decodeKafkaRecord } from '@relaybus/kafka';
kafkaCodecContract('orders format', {
encode: orderCodec.encode,
decode: (record) => decodeKafkaRecord(orderCodec, record),
compare: 'bytes', // or 'json' to ignore key order and spacing
ignoreHeaders: ['traceparent'],
cases: [
{
name: 'order placed',
envelope, // the event
record: { topic: 'orders', key: 'order-1', headers: {}, value: '{"orderId":"1"}' }, // the captured record
// only: 'decode', // for a message only other producers write
},
],
});rabbitMqCodecContract takes the same shape with decodeRabbitMqMessage from @relaybus/rabbitmq and captured messages (routing key, properties, headers, body). kafkaCodecMismatches and rabbitMqCodecMismatches return the differences as text lines instead of registering tests. Compared byte for byte, the decoded payload must also keep the expected key order.
The contract does not need Vitest:
// Jest: registers with Jest's describe and it globals
import { kafkaCodecContract } from '@relaybus/testing/jest';
// Any runner, or a plain script: no test framework imported
import {
assertKafkaCodecCase, // throws CodecContractError listing the mismatches
registerKafkaCodecContract, // registerKafkaCodecContract({ describe, it }, { name, options })
} from '@relaybus/testing/codec-contract';The RabbitMQ counterparts are assertRabbitMqCodecCase and registerRabbitMqCodecContract.
FakeClock
const clock = new FakeClock('2026-01-01T00:00:00Z');
const bus = new RelayBus({ store, clock });
clock.advance(60_000); // make retries due
await bus.drain();Timers (polling, heartbeats, handler timeouts, retention) stay real by default. With new FakeClock(start, { controlTimers: true }) the clock owns the timers of the bus that uses it: they run inside clock.advance(ms) when their time comes, and clock.pendingTimers counts the ones still waiting. Any Clock can do the same by implementing the optional schedule method.
InMemoryTransport
Records publishes, simulates outages with failNextPublishes(n) and delivers JSON copies to subscribers with deliver(destination, envelope, maxRedeliveries).
When to use @relaybus/testing, and when not to
Use it in your test suite for fast, deterministic tests, or to prove that a custom EventStore satisfies the contract. It is a development dependency. The store contract, the main entry and kafkaCodecContract / rabbitMqCodecContract need vitest 3 or newer (an optional peer); @relaybus/testing/jest and @relaybus/testing/codec-contract do not.
Documentation: https://berkcansavur.github.io/relaybus/packages/testing
relaybus vs alternatives
| What it is | Survives a restart | Written in your database transaction | Choose it when | |
|---|---|---|---|---|
| relaybus | Event bus built on the transactional outbox: PostgreSQL and MongoDB stores, Kafka and RabbitMQ transports, per-handler deliveries | Yes, events live in your database | Yes | Events must never be lost or phantom, and handlers need retries, dead letters and ordering keys |
@nestjs/event-emitter | In-process event emitter built on eventemitter2 | No persistence is documented | No | Simple in-process notifications where losing an event on restart is acceptable |
@nestjs/cqrs | CommandBus, QueryBus, EventBus and sagas; the docs say sagas and event handlers run in memory | No built-in persistence | No | You want CQRS structure; it can sit on top of an outbox |
@nestjs/outbox | Transactional outbox module from the NestJS organisation: PostgreSQL, MySQL and in-memory stores, relay with leases and retries, dead-letter table, inbox | Yes with the database stores | Yes | You need MySQL, or a NestJS-organisation module; compare features with relaybus before choosing |
| BullMQ | Job queue on Redis with retries, delayed jobs and flows; at-least-once in the worst case | Yes, in Redis | No, jobs are stored in Redis and cannot join a SQL or MongoDB transaction | Your stack is Redis-based and background jobs matter more than event atomicity |
| pg-boss | Job queue on PostgreSQL using SKIP LOCKED, with retries, dead letter queues, scheduling and pub/sub; jobs can be created in your own transaction | Yes, in PostgreSQL | Yes, through its database adapters | You want a PostgreSQL job queue with scheduling; relaybus is an event bus with per-handler deliveries and transports |
| Kafka client directly | A distributed log; the idempotent producer and transactions give exactly-once within Kafka | Yes, in Kafka | No, producing to Kafka is a second write | You consume and produce only inside Kafka; add an outbox when a database write must be announced |
This table summarises the vendors' own documentation as of October 2026; check their docs for current details. Full comparison and sources: https://berkcansavur.github.io/relaybus/comparison.
License
MIT