Guides

Testing

Assert on templates and properties with the memory sink.

Because the event is data, a test asserts on data — never on a rendered string.

A logger for tests#

import { createLogger, memorySink, LogContext } from '@unhingged/logit';
import { describe, expect, test } from 'bun:test'; // or vitest, jest

test('charging logs the outcome', async () => {
  const mem = memorySink();
  const log = createLogger({ minimumLevel: 'verbose', sinks: [mem] });
  const service = new PaymentService(log);

  await service.charge(order);

  const event = mem.events.find((e) => e.messageTemplate === 'Charged {OrderId} for {Amount:0.00}');
  expect(event).toBeDefined();
  expect(event!.level).toBe('info');
  expect(event!.properties).toMatchObject({ OrderId: 1042, Amount: 19.5 });
});

memorySink() keeps every event (capacity to bound it); mem.messages() gives the rendered messages for a quick check; mem.clear() resets between tests.

Injecting the logger#

Pass a logger in; the default should be the global log so production needs no wiring:

class PaymentService {
  constructor(private log: Logger = globalLog.forSource('payments')) {}
}

Or reconfigure the global for the whole test file — configure({ sinks: [mem] }) swaps the pipeline under every logger derived from log, including those modules captured at import time.

Asserting on errors#

const failure = mem.events.find((e) => e.level === 'error');
expect(failure!.error).toBeInstanceOf(PaymentError);
expect(failure!.errorProperties).toMatchObject({ name: 'PaymentError', code: 'card_declined' });

Context#

LogContext works in tests as in production. To assert a request-scoped property reached an event:

await LogContext.run({ RequestId: 'test-1' }, () => service.handle());
expect(mem.events.every((e) => e.properties.RequestId === 'test-1')).toBe(true);

Each test file runs in its own module scope; the AsyncLocalStorage behind LogContext is per process and needs no reset.

Callback sink#

For a spy:

const seen: string[] = [];
const log = createLogger({ sinks: [callbackSink((e) => seen.push(e.messageTemplate))] });

Silencing#

createLogger({ sinks: [] }) is a logger that drops everything after capture; createLogger({ minimumLevel: 'fatal', sinks: [] }) drops before it. For the global: configure({ sinks: [] }).

Deterministic output#

Formatter tests fix the timestamp by constructing the event:

import { LogEvent, DEFAULT_DESTRUCTURING, parseTemplate, jsonFormatter } from '@unhingged/logit';

const event = new LogEvent({ timestamp: new Date('2026-10-08T08:12:03.123Z'), level: 'info', template: parseTemplate('Hi {Name}'), properties: { Name: 'Ada' } }, DEFAULT_DESTRUCTURING);
expect(jsonFormatter()(event)).toBe('{"timestamp":"2026-10-08T08:12:03.123Z","level":"info","message":"Hi Ada","template":"Hi {Name}","Name":"Ada"}');

HTTP sinks take fetch as an option, so a test hands them a stub and inspects the body.