Skip to content

@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.

sh
pnpm add -D @relaybus/testing vitest   # or jest: see the codec contract below

Store contract suite ​

Every EventStore must pass it unchanged against a real database:

ts
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:

ts
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:

ts
// 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 ​

ts
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 isSurvives a restartWritten in your database transactionChoose it when
relaybusEvent bus built on the transactional outbox: PostgreSQL and MongoDB stores, Kafka and RabbitMQ transports, per-handler deliveriesYes, events live in your databaseYesEvents must never be lost or phantom, and handlers need retries, dead letters and ordering keys
@nestjs/event-emitterIn-process event emitter built on eventemitter2No persistence is documentedNoSimple in-process notifications where losing an event on restart is acceptable
@nestjs/cqrsCommandBus, QueryBus, EventBus and sagas; the docs say sagas and event handlers run in memoryNo built-in persistenceNoYou want CQRS structure; it can sit on top of an outbox
@nestjs/outboxTransactional outbox module from the NestJS organisation: PostgreSQL, MySQL and in-memory stores, relay with leases and retries, dead-letter table, inboxYes with the database storesYesYou need MySQL, or a NestJS-organisation module; compare features with relaybus before choosing
BullMQJob queue on Redis with retries, delayed jobs and flows; at-least-once in the worst caseYes, in RedisNo, jobs are stored in Redis and cannot join a SQL or MongoDB transactionYour stack is Redis-based and background jobs matter more than event atomicity
pg-bossJob queue on PostgreSQL using SKIP LOCKED, with retries, dead letter queues, scheduling and pub/sub; jobs can be created in your own transactionYes, in PostgreSQLYes, through its database adaptersYou want a PostgreSQL job queue with scheduling; relaybus is an event bus with per-handler deliveries and transports
Kafka client directlyA distributed log; the idempotent producer and transactions give exactly-once within KafkaYes, in KafkaNo, producing to Kafka is a second writeYou 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

Released under the MIT License.