Libraries

Testing

Test the events your package emits and the errors it throws: a collecting drain, the memory drain, and factory codes that fail on rename.

The events your package emits are public behavior: a host filtering on your source field, an agent matching on your error codes, or a support macro keyed on a fix string, all break when you rename them as surely as when you rename a function. Tests make that visible before the host does. This page covers the two ways to observe events in a test process, how to lock error codes down, and the one assertion that keeps the generated error reference current.

The test process is the host. Nothing about being a library changes the setup: you configure evlog the way an application would, because inside a test that is exactly what your code is.

Collect events with a drain function

A drain function runs synchronously when an event is emitted, so a plain array is enough:

test/events.test.ts
import { initLogger } from 'evlog'
import type { DrainContext, WideEvent } from 'evlog'

const events: WideEvent[] = []

beforeAll(() => {
  initLogger({ drain: (ctx: DrainContext) => { events.push(ctx.event) } })
})

// run the operation under test, then assert on the collected events
expect(events[0].source).toBe('mylib')

Assert on the fields you promised the host: the source, the operation, the domain fields you set. Do not assert on console output; pretty printing is presentation, and the drained event is the contract.

Inspect events with the memory drain

The memory drain keeps events in a ring buffer you can read and clear, useful when an operation emits several events and you want them as a list:

test/events.test.ts
import { createMemoryDrain, readMemoryLogs } from 'evlog/memory'
import { initLogger } from 'evlog'

beforeAll(() => {
  initLogger({ drain: createMemoryDrain({ store: 'test' }) })
})

// run the operation under test, then give the drain a tick:
// emit() hands events to the drain without awaiting it
await new Promise(resolve => setTimeout(resolve, 0))

const events = readMemoryLogs({ store: 'test', filter: e => e.source === 'mylib' })

Lock error codes down

Catalog errors are wire format (see Structured Errors), so assert through the factory rather than a string literal. A rename then fails in your test suite instead of in a host's alert or an agent's prompt:

test/errors.test.ts
import { errors } from '../src/errors'

// in the test asserting throw behavior:
expect(() => charge(-5)).toThrow()
expect(errors.INSUFFICIENT_FUNDS.code).toBe('mylib.INSUFFICIENT_FUNDS')

The string literal lives in one line, and it states the wire format your package promised. If you delete or rename the catalog entry, the test stops compiling at errors.INSUFFICIENT_FUNDS, which is the point.

For emitted events carrying an error, the code lands in the event the same way, so the same assertion works against a collected event's error tree.

Keep the generated reference in sync

If you generate an error reference from the catalog, the committed file drifts the first time someone edits a fix and forgets to rebuild. One test closes that gap: regenerate in memory and compare to what is on disk. Exporting the Catalog has the script and the test; the assertion is expect(committed).toBe(toMarkdown(readCatalog())), and it fails in the pull request that changed the sentence instead of in a consumer's session a week later.

What sampling does to your tests

The test host configures sampling like any host. Defaults keep everything (minLevel: 'debug', no rates), so events arrive. If a test seems to lose events, check whether an earlier test in the same process called initLogger() with rates or enabled: false: configuration is process-wide and the last call wins, which is the same mechanism Emitting Events warns library code about. Sampling documents what a real host can do to your events, including dropping them entirely.