Test Patterns
NR AI Coding Observability: Preflight — Test Patterns
This document covers the testing conventions, infrastructure, and patterns used in this repo. Read this before writing your first test.
Test Infrastructure
Jest configuration
A single flat jest.config.ts at the repo root governs every Jest test. There are no per-package configs and no base config to extend.
Key settings:
| Setting | Value | Why |
|---|---|---|
preset |
ts-jest/presets/default-esm |
ESM modules with TypeScript |
testEnvironment |
node |
No browser DOM needed |
testMatch |
['<rootDir>/src/**/*.test.ts', '<rootDir>/test/**/*.test.ts'] |
Co-located unit tests + dedicated test/ folder |
testPathIgnorePatterns |
includes <rootDir>/src/web/ |
src/web belongs to Vitest |
moduleNameMapper |
'^(\\.{1,2}/.*)\\.js$': '$1' |
Strips .js extensions in TS imports for ts-jest |
extensionsToTreatAsEsm |
['.ts'] |
Tells Jest to treat .ts as ESM |
testTimeout |
15_000 |
15s default per test |
maxWorkers |
1 |
Prevents deadlocks in stdio integration tests |
forceExit |
true |
Ensures Jest terminates after harvest schedulers / proxies are stopped |
tsconfig (transform) |
tsconfig.test.json |
Loose TS settings for test compilation |
src/shared/ is imported relatively (from '../shared/index.js'), so no special module resolution is required.
Running tests
npm test # Entire Jest suitenpx jest -- src/shared/ # All tests under one directorynpx jest -- src/metrics/cost-tracker.test.ts # One filenpx jest -- --testNamePattern="re-queues" # Tests matching a name patternWeb Tests (Vitest)
The web dashboard in src/web/ uses a separate test runner: Vitest, not Jest. These are the only tests that use React components, browser APIs, or .tsx files.
npm run test:web # Run the Vitest suiteVitest has its own config file, vitest.config.ts (separate from vite.config.ts, which configures the dashboard build). Its include: ['src/web/**/*.test.{ts,tsx}'] is what confines discovery to src/web/.
Key differences from Jest tests
| Concern | Jest (outside src/web) |
Vitest (src/web) |
|---|---|---|
| File extension | .test.ts |
.test.ts(x) |
| Import globals | from '@jest/globals' |
vitest globals (no import needed) |
| Spy/mock | jest.spyOn, jest.fn |
vi.spyOn, vi.fn |
| Run command | npm test |
npm run test:web |
Web tests follow the same factory-function and spy patterns as Jest tests — just with vi.* instead of jest.*.
Browser Tests (Playwright)
e2e/ runs the built dashboard in real Chromium against real --local servers. playwright.config.ts starts two, each over its own temp directory, which also stands in for HOME, so neither reads your credentials, your ~/.newrelic-preflight, or your Claude Code transcripts:
| Server | URL | Store |
|---|---|---|
| empty | the default baseURL |
nothing |
| seeded | SEEDED_URL from e2e/servers.ts |
one persisted session from e2e/fixtures/session-fixture.ts |
npm run test:e2e # Build, then run the suitenpx playwright test e2e/views.spec.ts # One spec, against an existing buildAdding a view smoke test
e2e/views.spec.ts holds a smoke test per dashboard route, run once against each server. Each reaches the view from the sidebar, then again by reloading on its URL, and asserts the view’s <h1>, no “Not found”, and no console error, uncaught exception, failed request or unexpected 4xx/5xx while it loads. A new view needs one row in VIEWS; a test comparing VIEWS against the sidebar fails until it has one:
{ nav: 'Workflows', path: '/workflows', heading: 'Workflows', query: /^\/api\/workflows$/ },nav is the Sidebar button’s label and heading the view’s <h1> text. query matches the pathname of a request the view issues on mount; the test waits for it to be answered after the reload, so it must not be one the App shell makes (/api/session/current, /api/anti-patterns, /api/health and /sse, on every route). Another view may share it. It proves the request was answered, not that the view rendered its data, and the error checks cover the reloaded load: the sidebar visit is checked by URL and heading only, and its fetches are cancelled by the reload. If the view requests something that legitimately answers with an error status, add it to EXPECTED_ERROR_RESPONSES with a comment saying why, rather than loosening the check.
To assert what a view shows with data, test.use({ baseURL: SEEDED_URL }) in a describe and, if the fixture lacks what you need, extend buildFixtureSession(). Import the values you assert on from session-fixture.ts instead of repeating them.
Smoke tests take no screenshots. Screenshots are per-platform baselines, and every new one means recording a Linux copy too — see The three suites.
Global Test Setup
Nearly every test file follows this setup pattern:
import { jest, describe, it, expect, beforeEach, afterEach } from '@jest/globals';
let stderrSpy: ReturnType<typeof jest.spyOn>;
beforeEach(() => { stderrSpy = jest.spyOn(process.stderr, 'write').mockImplementation(() => true);});
afterEach(() => { stderrSpy.mockRestore();});Why mock stderr? All modules use createLogger(), which writes structured JSON to stderr. Without mocking, test output is flooded with logger lines. The spy also allows asserting on log output when needed:
const logOutput = stderrSpy.mock.calls.map((c: unknown[]) => c[0] as string).join('');expect(logOutput).toContain('re-queuing batch for retry');Factory Functions
Every test file defines make* factory functions that produce valid default objects with optional Partial<T> overrides. This is the most important pattern in the test suite.
ToolCallRecord factory
The most common factory — used by all metric tracker and tool handler tests:
function makeRecord(overrides?: Partial<ToolCallRecord>): ToolCallRecord { return { id: 'rec-001', sessionId: 'sess-001', toolName: 'Read', toolUseId: 'toolu_001', timestamp: Date.now(), durationMs: 50, success: true, ...overrides, };}Usage — only specify the fields relevant to what you’re testing:
makeRecord({ toolName: 'Edit', filePath: '/a.ts' });makeRecord({ toolName: 'Bash', success: false, errorType: 'timeout' });makeRecord({ durationMs: 200 });HookEvent factories
Event processor tests define separate factories for pre and post events:
function makePreEvent(overrides?: Partial<HookEvent>): HookEvent { return { mode: 'pre', tool: 'Read', timestamp: 1000, inputSize: 42, inputHash: 'abc123def456abcd', toolUseId: 'toolu_001', sessionId: 'sess-001', ...overrides, };}
function makePostEvent(overrides?: Partial<HookEvent>): HookEvent { return { mode: 'post', tool: 'Read', timestamp: 1050, outputSize: 1024, success: true, toolUseId: 'toolu_001', sessionId: 'sess-001', ...overrides, };}SessionSummary factory
Cross-session tool tests use a full session summary factory with many fields:
function makeSummary(overrides?: Partial<FullSessionSummary>): FullSessionSummary { const now = Date.now(); return { sessionId: `sess-${now}-${Math.random().toString(36).slice(2)}`, startTime: now - 60_000, endTime: now, durationMs: 60_000, toolCallCount: 10, developer: 'alice', // ... many more fields with sensible defaults ...overrides, };}AiCodingTask factory
Efficiency scorer tests build task objects:
function makeTask(overrides?: Partial<AiCodingTask>): AiCodingTask { return { taskId: 'task-001', startTime: 1000, endTime: 61000, durationMs: 60000, toolCallCount: 10, // ... sensible defaults ...overrides, };}ProxyToolCallRecord factory
NR ingest tests for proxy mode extend makeRecord with proxy-specific fields:
function makeProxyRecord(overrides?: Partial<ProxyToolCallRecord>): ProxyToolCallRecord { return { ...makeRecord(), serverName: 'test-server', upstreamLatencyMs: 10, ...overrides };}Guidelines for factory functions
- Name them
make*—makeRecord,makeEvent,makeSession,makeTask, etc. - Provide sensible defaults for all required fields
- Accept
Partial<T>with spread to allow selective override - Keep them at module scope (above
describeblocks), not inside them - If a domain has distinct subtypes (pre/post events, success/failure), create separate factories
Patterns by Category
Metric tracker tests
All metric trackers in src/metrics/ follow the same test structure: create a tracker, feed it records, read out metrics, assert.
describe('SessionTracker', () => { it('tracks correct toolCallCountByTool for mixed tools', () => { const tracker = new SessionTracker('test-session');
for (let i = 0; i < 5; i++) tracker.recordToolCall(makeRecord({ toolName: 'Read' })); for (let i = 0; i < 3; i++) tracker.recordToolCall(makeRecord({ toolName: 'Edit' }));
const metrics = tracker.getMetrics(); expect(metrics.toolCallCount).toBe(8); expect(metrics.toolCallCountByTool).toEqual({ Read: 5, Edit: 3 }); });});Pattern: new Tracker() → tracker.recordToolCall(makeRecord(...)) (repeat) → tracker.getMetrics() → assertions.
For trackers that detect patterns over sequences (like AntiPatternDetector), build an array of records and pass them all at once:
const calls: ToolCallRecord[] = [ makeRecord({ toolName: 'Edit', filePath: '/a.ts' }), makeRecord({ toolName: 'Bash', isTestCommand: true, success: false }), makeRecord({ toolName: 'Edit', filePath: '/a.ts' }), makeRecord({ toolName: 'Bash', isTestCommand: true, success: false }), makeRecord({ toolName: 'Edit', filePath: '/a.ts' }), makeRecord({ toolName: 'Bash', isTestCommand: true, success: false }),];
const result = detector.analyze(calls);const thrashing = result.patterns.filter((p) => p.type === 'thrashing');expect(thrashing).toHaveLength(1);MCP tool handler tests
Tool handlers are pure functions that take tracker instances and return MCP-formatted responses. Tests create real tracker instances, feed them data, then call the handler:
describe('handleGetSessionStats()', () => { it('returns correct JSON structure after recording 10 tool calls', () => { const tracker = new SessionTracker('stats-session');
for (let i = 0; i < 5; i++) tracker.recordToolCall(makeRecord({ toolName: 'Read' })); for (let i = 0; i < 3; i++) tracker.recordToolCall(makeRecord({ toolName: 'Edit' }));
const result = handleGetSessionStats(tracker); const stats = JSON.parse(result.content[0].text);
expect(stats.session_id).toBe('stats-session'); expect(stats.tool_calls).toBe(8); });});Pattern: Create real trackers → feed data → call handler → JSON.parse(result.content[0].text) → assert fields.
Storage tests (temp directory pattern)
Tests that touch the filesystem create a unique temp directory per test and clean it up afterward:
let tmpDir: string;
beforeEach(() => { tmpDir = mkdtempSync(resolve(tmpdir(), 'nr-localstore-test-'));});
afterEach(() => { rmSync(tmpDir, { recursive: true, force: true });});mkdtempSync creates the directory under a name no other process holds. A name built from Date.now() alone collides when two Jest processes start a test in the same millisecond, and the first to finish deletes the directory under the other; older tests that add a Math.random() suffix to it are also safe.
it('round-trips a single event', () => { const store = new LocalStore(tmpDir);
const event = makeEvent({ tool: 'Write' }); store.appendToBuffer(event);
const drained = store.drainBuffer(); expect(drained).toHaveLength(1); expect(drained[0]).toEqual(event);});Timer-dependent tests
Use jest.useFakeTimers() when testing anything that relies on setInterval, setTimeout, or Date.now(). The HarvestScheduler tests are the primary example:
beforeEach(() => { jest.useFakeTimers();});
afterEach(async () => { jest.useRealTimers();});
it('fires events harvest at 5s intervals', async () => { const { scheduler, sendEventsFn } = makeScheduler(); scheduler.addEvent({ eventType: 'Test', value: 1 }); scheduler.start();
await jest.advanceTimersByTimeAsync(5_000); expect(sendEventsFn).toHaveBeenCalledTimes(1);
await scheduler.stop();});Important: Use jest.advanceTimersByTimeAsync() (not the sync version) when the code under test uses async/await or Promises. Always call scheduler.stop() or equivalent cleanup before the test ends.
Time-zone-dependent tests
Tests must pass in any host zone; CI runs both suites under America/New_York and under UTC, and the Jest suite under Pacific/Auckland as well. Build day-boundary fixtures with the same local-date helpers the code uses (localStartOfDay, localDateKey from src/lib/date.ts, or new Date(y, m, d)), or pass an explicit IANA tz to helpers that accept one. Don’t hardcode a zone’s offset or DST dates. A UTC instant such as Date.parse('2026-09-10T12:00:00Z') is already September 11 from UTC+12 eastward, so give a fixture that the code reads as a local day a local time instead (new Date(2026, 8, 10, 12)).
Jest gives each test file its own copy of process.env, so process.env.TZ = '...' inside a Jest test has no effect. A test that needs a zone property, such as a DST transition, derives it from the host zone and skips when the host has none (see the dailyPeaks DST test in src/dashboard/routes/api-handler.test.ts). In a zone that springs forward at local midnight (America/Havana, Africa/Cairo), new Date(y, m, d) for that day resolves to 01:00, so check that a derived day start reads 00:00. A test that skips on a missing zone property can also skip because CI’s TZ is misspelled or its zone data is missing, so pair it with a CI-only guard that the zone resolved (the runs under the time zone TZ requests test beside it).
Vitest does honor a runtime process.env.TZ. Assert that the pin took effect (for example with getTimezoneOffset()): if it silently stopped working, the test would run in the host’s zone, and on a host where its bug can’t occur, such as UTC, it would pass against buggy code. Restore it with delete process.env.TZ when it was unset, since assigning undefined stores the string "undefined".
Transport / HTTP tests
Transport tests mock global.fetch via a spy to simulate HTTP responses:
let fetchSpy: ReturnType<typeof jest.spyOn>;
beforeEach(() => { fetchSpy = jest.spyOn(global, 'fetch');});
afterEach(() => { fetchSpy.mockRestore();});Tests then configure mock responses and verify request properties:
mockFetch.mockResolvedValueOnce(new Response('', { status: 200 }));const result = await sendWithRetry(baseOptions());expect(result.success).toBe(true);expect(mockFetch).toHaveBeenCalledTimes(1);Event processor tests
These combine the temp directory pattern (for LocalStore) with pre/post event factories and a mock callback:
let records: ToolCallRecord[];let onRecord: jest.Mock<(record: ToolCallRecord) => void>;
beforeEach(() => { records = []; onRecord = jest.fn((record: ToolCallRecord) => { records.push(record); });});
it('pairs pre + post events into a ToolCallRecord', () => { const processor = new HookEventProcessor({ store, onRecord }); processor.processEvents([makePreEvent(), makePostEvent()]);
expect(records).toHaveLength(1); expect(records[0].toolName).toBe('Read'); expect(records[0].durationMs).toBe(50);});Describe / Test Structure
Numbered section comments
Test files use numbered section comments to organize test groups:
// ---------------------------------------------------------------------------// 1. Events harvest fires at 5s, metrics at 60s// ---------------------------------------------------------------------------it('fires events harvest at 5s intervals and metrics at 60s', async () => { ... });
// ---------------------------------------------------------------------------// 2. stop() triggers final flush// ---------------------------------------------------------------------------it('stop triggers final flush of both buffers', async () => { ... });Nested describes
Use nested describe blocks when a module has multiple distinct behaviors:
describe('SessionTracker', () => { describe('recordToolCall() — tool counts', () => { ... }); describe('duration stats', () => { ... }); describe('file tracking', () => { ... }); describe('reset()', () => { ... });});Test naming
Write test names as complete sentences that describe the expected behavior:
'detects Edit→test FAIL cycle repeated 3 times''does not detect when test passes''resets cycle count after a passing test''computes min, max, sum, count, and p95 correctly''handles records with null durationMs gracefully''combines re-queued events with new events on next harvest''concurrent stop() calls share the same flush promise'Mocking Strategy
What we mock
| What | How | Why |
|---|---|---|
process.stderr.write |
jest.spyOn |
Suppress logger output; optionally assert on log content |
globalThis.fetch |
jest.fn |
Simulate HTTP responses without network |
sendEventsFn / sendMetricsFn |
jest.fn injected via constructor |
Control harvest send behavior |
onRecord callback |
jest.fn |
Capture emitted ToolCallRecord objects |
What we don’t mock
- Metric trackers in tool handler tests — we use real tracker instances, not mocks. This validates the full path from data in to tool response out.
LocalStorein event processor tests — we create a real store with a temp directory. This catches serialization/deserialization bugs.MetricAggregatorin harvest tests — real aggregator, real buckets.
The philosophy: mock at system boundaries (network, stdio), use real implementations for internal components.
Exemplary Test Files
These files demonstrate the patterns well and serve as templates for new tests:
| File | Demonstrates |
|---|---|
src/shared/harvest/harvest-scheduler.test.ts |
Fake timers, mock send functions, retry/requeue, concurrent stop, atomic snapshot |
src/metrics/session-tracker.test.ts |
Metric tracker pattern: record → getMetrics → assert |
src/metrics/anti-patterns.test.ts |
Sequence-based detection: build record arrays, analyze, filter results |
src/metrics/efficiency-score.test.ts |
Task factory, component scoring, boundary conditions |
src/hooks/event-processor.test.ts |
Pre/post event pairing, temp directory, mock callback |
src/storage/local-store.test.ts |
Filesystem tests with temp directory cleanup, edge cases |
src/tools/session-stats.test.ts |
Tool handler pattern: real trackers → handler → JSON.parse |
src/tools/cross-session-tools.test.ts |
Cross-session tools with SessionStore, temp directory, rich factories |
src/shared/transport/http-client.test.ts |
fetch mocking, gzip verification, region detection, retry behavior |
src/security/audit-trail.test.ts |
Security classification, regex pattern testing, false positive/negative coverage |
src/transport/nr-ingest.test.ts |
Proxy event builders, session trace ID propagation, makeProxyRecord factory |
Writing a New Test
- Create
your-module.test.tsalongsideyour-module.ts - Add the stderr spy in
beforeEach/afterEach - Write a
make*factory for your primary data type - If your module touches the filesystem, use the temp directory pattern
- If your module uses timers, use
jest.useFakeTimers() - Structure with
describeblocks for each behavior group - Name tests as complete sentences describing expected behavior
- Run with
npx jest -- src/path/to/your-module.test.ts