Testing principles

September 9, 2026 · View on GitHub

This codebase favors small, readable test suites with explicit setup and minimal magic. Individual tests should follow a meaningful workflow end-to-end, even when that makes a single test longer and more assertion-heavy.

Test flavor decision matrix

Choose the lightest flavor that can falsify the behavior. Filename suffixes pick the Vitest project (vitest.config.ts):

Flavor / commandUse whenAvoid when
*.node.test.ts (npm run test:node / node-unit; also in npm run test)Pure server logic, pure functions, handlers/services that can run against an in-memory node:sqlite D1 facade (createD1FromSqlite in packages/worker/src/test-support/), or code that is correctly covered by spies/stubs (for example vi.spyOn on recordUsage). Fast feedback; no Workers runtime.The assertion needs real Cloudflare bindings (env.APP_DB, KV, DO, R2) or Workers-only APIs that the node stubs do not honestly exercise.
*.workers.test.ts (npm run test:workers / workers-unit; also in npm run test)The behavior depends on real local bindings from cloudflare:workers / the Vitest Workers pool (D1 schema + queries, KV reads/writes, DO stubs as configured). Prefer shared explicit-import factories from packages/worker/src/test-support/ and domain test-schema.ts helpers over copy-pasted seed SQL.The file never reads env / bindings and only tests pure registry or string logic — prefer *.node.test.ts instead (several src/mcp/** workers suites are historical misclassifications; leave them unless you are already editing that area).
*.mcp-e2e.test.ts (npm run test:mcp)A tiny smoke suite for the real MCP HTTP transport, OAuth, and package-app session wiring.Capability-by-capability coverage that does not need that transport — put those beside the implementation as node or workers tests.
Playwright (npm run test:e2e:run)A very small number of user-critical happy-path journeys through the worker + client. See end-to-end testing.Edge cases, copy pinning, or anything a faster unit/integration test can cover.

Usage metering example (lifted from usage metering): in *.node.test.ts, spy on recordUsage and assert call shape for success and failure paths. In *.workers.test.ts with a real local D1, call ensureUsageRollupsTestSchema from packages/worker/src/usage/test-schema.ts and assert on usage_rollups rows instead of spying.

Shared test helpers live under packages/worker/src/test-support/. Import factories explicitly inside each test (or a per-test factory). Do not introduce beforeEach hooks that hide setup — that conflicts with the principles below.

Principles

  • Prefer the "fewer, longer tests" style from Kent C. Dodds when assertions belong to one workflow.

  • Treat each test like a manual tester's script: one setup, then as many actions and assertions as needed to validate the whole journey.

  • Do not split a single flow into many tiny tests just to satisfy "one assertion per test." Multiple related assertions in one test are a feature, not a smell.

  • Prefer flat test files: use top-level test(...) and avoid describe nesting.

  • Avoid shared setup like beforeEach/afterEach; inline setup per test.

  • Avoid shared mutable test state across cases. If the next assertion depends on the same rendered object, request, or response, it likely belongs in the same test.

  • Do not add tautological assertions. An assertion is tautological when it cannot fail unless the implementation and the test change in lockstep — there is no independent oracle. Typical forms:

    • Identity predicates: isFoo(FOO_CONSTANT) when isFoo is ===, includes, or Set.has of that same constant. Keep the interesting branches (normalization, prefix/suffix, negatives, Error wrapping, cause chains).
    • Constant-to-self pins: expect(EXPORTED_DAYS).toBe(14) or expect(exportedDelays).toEqual([100, 500, 1_500]). If the value is a public contract, assert it where a caller observes it (serialized payload, HTTP body, retry nextDelayMs), not on the export itself.
    • Algorithm echo: building expected with the same helper the production function uses (shellQuote(x) on both sides; picking the same fields toSummary(post) returns). Use an independent oracle (hardcoded quoted string, live schema after migration).
    • Self-equality: equal(x, x). Type-only checks, instructional-copy pins, and a lone "q is not there" after a deletion (later bullets) are the same failure mode. Identity predicates and algorithm echo stay a review item; vanished-copy not.toContain is kody-custom/no-tautological-absence.
  • Don't write tests for what the type system already guarantees.

  • Use disposable objects only when there is real cleanup. If no cleanup, skip using and Symbol.dispose.

  • Build helpers that return ready-to-run objects (factory pattern), not globals.

  • Keep test intent obvious in the name: "auth handler returns 400 for invalid JSON".

  • Write tests so they could run offline if necessary: avoid relying on the public internet and third-party services; prefer local fakes/fixtures.

  • Keep the bar for adding tests high, especially slower integration and E2E tests.

  • Prefer fast unit tests for server logic; keep e2e tests focused on a very small number of important happy-path journeys.

  • Treat packages/worker/src/mcp/*.mcp-e2e.test.ts as a tiny MCP transport smoke suite. Do not add capability-specific cases there unless they require the real MCP HTTP transport, OAuth flow, and package-app session wiring.

  • Prefer asserting intermediate states inside the broader workflow that causes them rather than adding isolated tests that only check an incidental loading or transition state.

  • Do not add regression tests for bugs that are unlikely to happen again unless the flow is important enough to justify the maintenance cost.

  • Avoid tests that only assert a string blob contains a description or other incidental copy. Favor behavior-focused assertions (structured output, user-visible outcomes, or stable public contracts) instead. When a blog catalog pin must quote an approved sentence, run the source through normalizeMarkdownPhraseSource in packages/worker/src/blog/catalog.ts first. oxfmt reflows markdown blockquotes onto continuation > lines, so a raw post.body.includes('exact phrase') fails after format even though the words are still there.

  • Do not add tests whose only value is pinning configuration-style strings such as tool descriptions, usage hints, warnings, or other instructional copy. If the behavior matters, test the behavior or stable structured contract rather than asserting that specific prose appears.

  • Keep absence assertions that flip state. "x is there, z is not; click y; now x is gone and z is there" is useful. A lone "q is not there" after q was deleted is not. That only fails if someone pastes the old name back. Fine to use locally while deleting; do not commit it. Same for old class names, filenames, aria-labels, CSS selectors, retired capability ids, and deleted table names on a static inventory list. Absence is still a good assertion when a live path could show the thing: loading vs ready, empty vs populated, secret vs redacted, admin vs user, or generated SQL vs a table the generator could still emit. kody-custom/no-tautological-absence (npm run lint) rejects instructional-copy not.toContain('…') when that string exists only as the absence assertion (state flips, fixtures, live production copy, and wrong-template siblings still pass).

  • Run server/unit tests with npm run test (plus targeted Vitest paths when needed) to avoid Playwright spec discovery and accidental matches like packages/worker/src/mcp/mcp-server.mcp-e2e.test.ts.

  • Workers-unit harness rules (see decision 0011):

    • Prefer *.node.test.ts unless the assertion needs real Cloudflare bindings or Workers-only APIs (@cloudflare/worker-bundler, DO RPC, etc.).
    • Do not warm Durable Objects from globalSetup (Node-only) or from workers-unit setupFiles (breaks webhook / scheduled / packageSave suites).
    • Do not use --no-isolate / shared storage to chase suite wall clock. Per-file isolation stays; disabling it breaks suites that assume a clean store.
    • Do not stub away DOs in workers tests when binding fidelity is the point, and do not “fix” pool slowness with --no-verify or a shorter local testTimeout. Shared testTimeout is 20s so the pool’s ~10s first DO RPC in a file does not fail the default budget.
    • Pool cold load is inherent today; suite wall clock will not match production RPC latency until the upstream pool improves.
  • Vitest is configured with clearMocks and mockReset globally (vitest-shared.ts). Each test starts with a clean mock slate; inline the setup a test needs rather than relying on leftover state from a prior case. Keep explicit mid-test resets only when one workflow test runs multiple scenarios in a single test(...).

  • Console output is guarded globally (packages/worker/src/test-support/console-spies.ts, wired via setupFiles): unexpected console.error/console.warn calls fail the test, and console.info/console.debug are silenced. Never silence blindly — a blanket .mockImplementation(() => {}) can hide a real regression. Instead:

    • When the log is part of the tested contract, import the exported consoleError/consoleWarn spies, call .mockImplementation(() => {}), and assert on the calls (prefer the stable first-argument tag plus expect.any(Error); do not pin long prose). Assert the call count too when it is deterministic.
    • When the log is incidental to the behavior under test, use silenceExpectedConsoleWarns([...]) / silenceExpectedConsoleErrors([...]) (same module) with the exact expected message tags, or silenceIncidentalRuntimeWarnings() (packages/worker/src/test-support/incidental-runtime-warnings.ts) for the bundler/registry-runtime noise set. Anything outside the allowlist still fails the test.
    • Workerd logs Durable Object / WorkerEntrypoint RPC rejections as uncaught exception even when the caller catches them. Do not filter those dumps — a real isolate crash must stay visible. Prevent the RPC throw instead: fail closed in the sandbox or host tool before the gateway/DO call (retriever fetch, read-only storage.sql), and when a test must exercise an in-object abort, call the method inside runInDurableObject rather than across the test RPC stub.

    Keep test output free of stray logging.

  • The audit-log sink is mocked globally for node-unit tests (packages/worker/src/test-support/audit-log-spy.ts, wired via the project's setupFiles): import logAuditEventSpy and assert the audit events a handler is expected to emit (and not.toHaveBeenCalled() where none are). Tests that exercise the real audit pipeline opt out with vi.unmock('#worker/audit-log.ts'); tests that need to override other exports (e.g. getRequestIp) declare their own vi.mock('#worker/audit-log.ts', ...) and route logAuditEvent back through the shared spy.

Examples

Tautological assertions

// Bad — matcher is `normalized === THE_CONSTANT`
expect(isResetMessage(resetMessageConstant)).toBe(true)
expect(exportedRetryDelaysMs).toEqual([100, 500, 1_500])
expect(windowsEqual(window, window)).toBe(true)

// Good — independent oracle or a real branch
expect(isResetMessage(resetMessageConstant.replace(/\.$/, ''))).toBe(true)
expect(retries).toEqual([{ attempt: 1, nextDelayMs: 100 }])
expect(windowsEqual(window, { ...window, end: window.end + 1 })).toBe(false)

Absence assertions

// Bad — q is gone; nothing can show it again
expect(capabilityMap.old_write).toBeUndefined()
expect(html).not.toContain('old-aria-label')

// Good — state flip: present on one path, absent on the other
expect(adminMap.adminUserList).toBeTruthy()
expect(userMap.adminUserList).toBeUndefined()

Symbol.dispose with using

import { writeFile, readFile, rm } from 'node:fs/promises'
import { test, expect } from 'vitest'

const createTempFile = async () => {
	const path = `/tmp/test-${crypto.randomUUID()}.txt`
	await writeFile(path, 'hello')

	return {
		path,
		[Symbol.asyncDispose]: async () => {
			await rm(path, { force: true }).catch(() => {
				// Cleanup should never fail the test.
			})
		},
	}
}

test('reads a temp file', async () => {
	await using tempFile = await createTempFile()
	const contents = await readFile(tempFile.path, 'utf8')
	expect(contents).toBe('hello')
})

Symbol.asyncDispose with await using

import { createServer } from 'node:http'
import { test, expect } from 'vitest'

const createDisposableServer = async () => {
	const server = createServer((_request, response) => {
		response.end('ok')
	})
	await new Promise<void>((resolve) => server.listen(0, resolve))
	const address = server.address()
	if (!address || typeof address === 'string') {
		throw new Error('Failed to resolve test server port')
	}

	return {
		url: `http://localhost:${address.port}`,
		[Symbol.asyncDispose]: async () => {
			await new Promise<void>((resolve, reject) => {
				server.close((error) => {
					if (error) reject(error)
					else resolve()
				})
			})
		},
	}
}

test('fetches from a disposable server', async () => {
	await using server = await createDisposableServer()
	const response = await fetch(server.url)
	expect(await response.text()).toBe('ok')
})