Skip to content
LogoLogo

Testing code that logs

Code under test should not spray JSON across your test reporter, and sometimes the log record is the behavior you want to assert. Both are solved by giving the logger a destination you control.

Silence logs in tests

The cheapest option: create the logger at a level nothing reaches.

silent.test.ts
import { createLogger } from '@tevm/logger'
import { expect, test } from 'vitest'
 
test('logger can be silenced with a high level', () => {
	const logger = createLogger({ name: 'test', level: 'fatal' })
 
	logger.info('never printed')
	logger.debug('never printed')
 
	expect(logger.isLevelEnabled('info')).toBe(false)
})

Capture and assert on records

To assert on what was logged, build a pino instance over a writable stream. This is the same object shape createLogger returns, so anything that accepts a Logger accepts it.

capture.test.ts
import { Writable } from 'node:stream'
import type { Logger } from '@tevm/logger'
import { pino } from 'pino'
import { expect, test } from 'vitest'
 
type LogRecord = { level: number; name: string; msg: string; [key: string]: unknown }
 
/**
 * Creates a logger that records every emitted line instead of writing to stdout.
 * @returns The logger and the array it appends parsed records to.
 */
const createTestLogger = (): { logger: Logger; records: LogRecord[] } => {
	const records: LogRecord[] = []
	const stream = new Writable({
		write(chunk, _encoding, callback) {
			records.push(JSON.parse(String(chunk)) as LogRecord)
			callback()
		},
	})
	const logger = pino({ name: 'test', level: 'trace' }, stream) as Logger
	return { logger, records }
}
 
/** The unit under test: it logs, so we assert on the log. */
const forkChain = (chainId: number, logger: Logger) => {
	if (!Number.isInteger(chainId) || chainId <= 0) {
		throw new Error(`chainId must be a positive integer, received ${chainId}`)
	}
	logger.info({ chainId }, 'forked chain')
}
 
test('forkChain logs the chain it forked', () => {
	const { logger, records } = createTestLogger()
 
	forkChain(1, logger)
 
	expect(records).toHaveLength(1)
	expect(records[0]).toMatchObject({ level: 30, name: 'test', chainId: 1, msg: 'forked chain' })
})
 
test('forkChain rejects an invalid chain id without logging', () => {
	const { logger, records } = createTestLogger()
 
	expect(() => forkChain(0, logger)).toThrow('chainId must be a positive integer, received 0')
	expect(records).toHaveLength(0)
})

Spying instead of capturing

When you only care that something was logged, a Vitest spy on the logger method is less machinery:

spy.test.ts
import { createLogger } from '@tevm/logger'
import { expect, test, vi } from 'vitest'
 
test('warns when the fork block is stale', () => {
	const logger = createLogger({ name: 'test', level: 'fatal' })
	const warn = vi.spyOn(logger, 'warn')
 
	const checkFreshness = (blockAgeSeconds: number) => {
		if (blockAgeSeconds > 60) logger.warn({ blockAgeSeconds }, 'fork block is stale')
	}
 
	checkFreshness(120)
 
	expect(warn).toHaveBeenCalledWith({ blockAgeSeconds: 120 }, 'fork block is stale')
})

The logger is at level: 'fatal', so nothing is actually written — but the method is still called, so the spy still sees it.