Testing
Reference

Testing

Test your notification pipeline

Better-Notify ships test utilities that let you verify your notification pipeline without sending real messages. The core tools are createMockTransport for capturing sends, inMemoryEventSink for asserting on events, and inMemoryTracer for inspecting spans.

Mock transport

createMockTransport captures every rendered message instead of delivering it. Use it to verify that the right content reaches the transport with the right data:

import { createClient } from '@betternotify/core';
import { createMockTransport } from '@betternotify/core/transports';

const transport = createMockTransport();

const mail = createClient({
  catalog,
  transportsByChannel: { email: transport },
});

await mail.welcome.send({
  to: 'ada@example.com',
  input: { name: 'Ada', verifyUrl: 'https://example.com/verify' },
});

expect(transport.sent).toHaveLength(1);
expect(transport.sent[0].rendered.subject).toBe('Welcome, Ada!');
expect(transport.sent[0].rendered.to).toEqual([{ email: 'ada@example.com' }]);
expect(transport.sent[0].ctx.route).toBe('welcome');

Custom reply data

Simulate provider-specific response data with the reply option:

const transport = createMockTransport({
  reply: (rendered) => ({ messageId: 'ses-123', requestId: 'req-456' }),
});

const result = await mail.welcome.send({ to: 'ada@example.com', input });
expect(result.data.messageId).toBe('ses-123');

Simulating failures

Return { ok: false, error } from reply to simulate transport failures:

const failingTransport = createMockTransport({
  reply: () => ({ ok: false, error: new Error('SMTP connection refused') }),
});

Testing middleware

Test middleware in isolation by calling it directly with mock params:

import { withRateLimit } from '@betternotify/core/middlewares';
import { inMemoryRateLimitStore } from '@betternotify/core/stores';

const store = inMemoryRateLimitStore();
const mw = withRateLimit({ store, key: 'test', max: 2, window: 60_000 });

const params = {
  input: {},
  ctx: {},
  route: 'welcome',
  messageId: 'test-1',
  args: { to: 'ada@example.com', input: {} },
  next: async () => ({ messageId: 'ok', timing: { renderMs: 0, sendMs: 0 } }),
};

await mw(params as any);
await mw({ ...params, messageId: 'test-2' } as any);
await expect(mw({ ...params, messageId: 'test-3' } as any)).rejects.toThrow('Rate limit exceeded');

Testing hooks

Use inMemoryEventSink to capture events and assert on them:

import { createClient } from '@betternotify/core';
import { withEventLogger } from '@betternotify/core/middlewares';
import { inMemoryEventSink } from '@betternotify/core/sinks';

const sink = inMemoryEventSink();

const mail = createClient({
  catalog,
  transportsByChannel: { email: createMockTransport() },
  plugins: [{ name: 'test-events', middleware: [withEventLogger({ sink })] }],
});

await mail.welcome.send({ to: 'ada@example.com', input });

expect(sink.events).toHaveLength(1);
expect(sink.events[0].status).toBe('success');
expect(sink.events[0].route).toBe('welcome');

Testing tracing

Use inMemoryTracer to capture spans:

import { createClient } from '@betternotify/core';
import { withTracing } from '@betternotify/core/middlewares';
import { inMemoryTracer } from '@betternotify/core/tracers';

const tracer = inMemoryTracer();

const mail = createClient({
  catalog,
  transportsByChannel: { email: createMockTransport() },
  plugins: [{ name: 'test-tracing', middleware: [withTracing({ tracer })] }],
});

await mail.welcome.send({ to: 'ada@example.com', input });

expect(tracer.spans).toHaveLength(1);
expect(tracer.spans[0].name).toBe('betternotify.send.welcome');
expect(tracer.spans[0].status.code).toBe('ok');

Testing validation

Verify that invalid input is rejected at the schema level:

import { NotifyRpcValidationError } from '@betternotify/core';

await expect(
  mail.welcome.send({ to: 'ada@example.com', input: { name: '' } }),
).rejects.toThrow(NotifyRpcValidationError);

Test organization

Better-Notify's own tests are collocated next to the source files (*.test.ts alongside the implementation). For your application tests, we recommend the same pattern — keep notification tests close to the route definitions:

src/notifications/
  routes.ts
  routes.test.ts
  client.ts
  client.test.ts

Use a shared test helper that creates a client with mock transports and in-memory stores so every test file starts from the same baseline.

On this page