Testing
Testing is the safety net that lets you refactor, upgrade dependencies, and ship features without fear. Node.js 20+ ships with a built-in test runner in node:test and assertion library in node:assert, giving you a zero-dependency option for unit and integration tests.
This guide covers the full testing spectrum: unit tests with the built-in runner, Vitest and Jest workflows, HTTP endpoint testing with Supertest, mocking, integration tests against real databases, fixtures, coverage, and patterns for keeping tests deterministic.
Since Node.js 18 (stable in 20+), the node:test module provides a TAP-producing test runner without installing anything. It supports suites, hooks, subtests, mocks, watchers, and reporters.
| 1 | // math.test.js |
| 2 | import { describe, it } from 'node:test'; |
| 3 | import assert from 'node:assert/strict'; |
| 4 | import { add, divide } from './math.js'; |
| 5 | |
| 6 | describe('math', () => { |
| 7 | it('adds two numbers', () => { |
| 8 | assert.equal(add(2, 3), 5); |
| 9 | }); |
| 10 | |
| 11 | it('throws on division by zero', () => { |
| 12 | assert.throws(() => divide(4, 0), RangeError); |
| 13 | }); |
| 14 | }); |
| 1 | node --test # run all discovered test files |
| 2 | node --test 'src/**/*.test.js' |
| 3 | node --test --watch # watch mode (Node.js 20+) |
info
Hooks and Subtests
Organize setup and teardown with before, beforeEach, afterEach, and after. Hooks are scoped to their enclosing describe block, and subtests let you nest related assertions.
| 1 | import { describe, it, before, beforeEach, after } from 'node:test'; |
| 2 | import assert from 'node:assert/strict'; |
| 3 | |
| 4 | describe('UserRepository', () => { |
| 5 | let db; |
| 6 | |
| 7 | before(async () => { db = await createTestConnection(); }); |
| 8 | beforeEach(async () => { await db.users.deleteMany(); }); |
| 9 | after(async () => { await db.close(); }); |
| 10 | |
| 11 | it('creates a user', async () => { |
| 12 | const user = await db.users.create({ email: 'test@example.com' }); |
| 13 | assert.equal(user.email, 'test@example.com'); |
| 14 | |
| 15 | await it('subtest: assigns an id', () => { |
| 16 | assert.ok(user.id); |
| 17 | }); |
| 18 | }); |
| 19 | }); |
Good assertions describe behavior, not implementation. Prefer semantic matchers over exact object equality when the shape is unstable.
| Assertion | Use Case |
|---|---|
| assert.equal | Strict equality (===) |
| assert.deepEqual | Structural equality |
| assert.ok / assert.match | Truthiness and regex matching |
| assert.throws / assert.rejects | Expected sync and async errors |
| 1 | // Avoid brittle exact equality on generated fields |
| 2 | const user = await service.createUser({ email: 'a@b.com' }); |
| 3 | assert.ok(user.id); |
| 4 | assert.equal(user.email, 'a@b.com'); |
| 5 | assert.ok(user.createdAt instanceof Date); |
warning
Mocks isolate the unit under test by replacing dependencies with controllable stand-ins. The built-in runner includes mock.fn and mock.method that reset automatically between tests.
| 1 | import { describe, it, beforeEach } from 'node:test'; |
| 2 | import assert from 'node:assert/strict'; |
| 3 | import { mock } from 'node:test'; |
| 4 | import { PaymentService } from './payment.js'; |
| 5 | |
| 6 | describe('PaymentService', () => { |
| 7 | let gateway; |
| 8 | let service; |
| 9 | |
| 10 | beforeEach(() => { |
| 11 | gateway = { |
| 12 | charge: mock.fn(async ({ amount }) => ({ id: 'ch_123', amount, status: 'succeeded' })), |
| 13 | }; |
| 14 | service = new PaymentService(gateway); |
| 15 | }); |
| 16 | |
| 17 | it('charges the customer', async () => { |
| 18 | const result = await service.charge({ customerId: 'cus_1', amount: 5000 }); |
| 19 | assert.equal(gateway.charge.mock.callCount(), 1); |
| 20 | assert.equal(result.status, 'succeeded'); |
| 21 | }); |
| 22 | |
| 23 | it('throws when the gateway rejects', async () => { |
| 24 | gateway.charge.mock.mockRejectedValueOnce(new Error('declined')); |
| 25 | await assert.rejects(service.charge({ customerId: 'cus_1', amount: 5000 }), /declined/); |
| 26 | }); |
| 27 | }); |
For module-level mocking, Vitest provides vi.mock, useful for external services, databases, or file-system dependencies.
| 1 | // payment.test.js |
| 2 | import { vi, describe, it, expect, beforeEach } from 'vitest'; |
| 3 | import Stripe from 'stripe'; |
| 4 | import { PaymentService } from './payment.js'; |
| 5 | |
| 6 | vi.mock('stripe', () => ({ |
| 7 | default: vi.fn(() => ({ charges: { create: vi.fn() } })), |
| 8 | })); |
| 9 | |
| 10 | describe('PaymentService with Vitest', () => { |
| 11 | beforeEach(() => vi.clearAllMocks()); |
| 12 | |
| 13 | it('delegates to Stripe', async () => { |
| 14 | const stripe = new Stripe('sk_test'); |
| 15 | stripe.charges.create.mockResolvedValue({ id: 'ch_123' }); |
| 16 | const service = new PaymentService(stripe); |
| 17 | await service.charge({ amount: 1000 }); |
| 18 | expect(stripe.charges.create).toHaveBeenCalledWith({ amount: 1000, currency: 'usd' }); |
| 19 | }); |
| 20 | }); |
best practice
Vitest is the modern default for Vite-based projects and any team that wants Jest-compatible APIs with native ESM, TypeScript support, and faster startup.
| 1 | npm install -D vitest @vitest/coverage-v8 |
| 1 | // vitest.config.js |
| 2 | import { defineConfig } from 'vitest/config'; |
| 3 | |
| 4 | export default defineConfig({ |
| 5 | test: { |
| 6 | globals: false, |
| 7 | environment: 'node', |
| 8 | coverage: { |
| 9 | provider: 'v8', |
| 10 | reporter: ['text', 'html'], |
| 11 | thresholds: { lines: 70, functions: 70 }, |
| 12 | }, |
| 13 | }, |
| 14 | }); |
| 1 | // user.service.test.js |
| 2 | import { describe, it, expect, beforeAll, afterAll } from 'vitest'; |
| 3 | import { createApp } from '../src/app.js'; |
| 4 | import { setupTestDb } from './helpers/db.js'; |
| 5 | |
| 6 | describe('User Service', () => { |
| 7 | let app; |
| 8 | let db; |
| 9 | |
| 10 | beforeAll(async () => { db = await setupTestDb(); app = createApp({ db }); }); |
| 11 | afterAll(async () => { await db.close(); }); |
| 12 | |
| 13 | it('lists users with pagination', async () => { |
| 14 | await db.seedUsers(25); |
| 15 | const res = await app.inject({ method: 'GET', url: '/users?page=1&limit=10' }); |
| 16 | expect(res.statusCode).toBe(200); |
| 17 | expect(res.json().data).toHaveLength(10); |
| 18 | }); |
| 19 | }); |
note
Jest remains widely used, especially in older codebases. Node.js 20+ requires Jest 29+ for stable ESM support, and you often need --experimental-vm-modules to handle native ES modules.
| 1 | { |
| 2 | "scripts": { |
| 3 | "test": "NODE_OPTIONS='--experimental-vm-modules' jest", |
| 4 | "test:ci": "NODE_OPTIONS='--experimental-vm-modules' jest --ci --coverage --runInBand" |
| 5 | }, |
| 6 | "jest": { |
| 7 | "testEnvironment": "node", |
| 8 | "transform": {}, |
| 9 | "extensionsToTreatAsEsm": [".ts"] |
| 10 | } |
| 11 | } |
| 1 | // sum.test.js |
| 2 | import { sum } from './sum.js'; |
| 3 | |
| 4 | describe('sum', () => { |
| 5 | it('returns the sum of two numbers', () => { |
| 6 | expect(sum(2, 3)).toBe(5); |
| 7 | }); |
| 8 | }); |
warning
Supertest is the de facto tool for black-box HTTP testing. It works with any framework that exposes an HTTP server and lets you assert on status codes, headers, and bodies without binding to a real port.
| 1 | // app.test.js |
| 2 | import request from 'supertest'; |
| 3 | import { describe, it, beforeAll, afterAll } from 'vitest'; |
| 4 | import { createApp } from './app.js'; |
| 5 | import { setupTestDb } from './helpers/db.js'; |
| 6 | |
| 7 | describe('POST /users', () => { |
| 8 | let app; |
| 9 | let db; |
| 10 | |
| 11 | beforeAll(async () => { db = await setupTestDb(); app = createApp({ db }); }); |
| 12 | afterAll(async () => { await db.close(); }); |
| 13 | |
| 14 | it('creates a user and returns 201', async () => { |
| 15 | await request(app) |
| 16 | .post('/users') |
| 17 | .send({ email: 'alice@example.com', name: 'Alice' }) |
| 18 | .set('Accept', 'application/json') |
| 19 | .expect(201) |
| 20 | .expect((res) => { |
| 21 | if (!res.body.id) throw new Error('missing id'); |
| 22 | }); |
| 23 | }); |
| 24 | |
| 25 | it('returns 400 for invalid payload', async () => { |
| 26 | await request(app).post('/users').send({ email: 'not-an-email' }).expect(400); |
| 27 | }); |
| 28 | }); |
If you use Fastify, prefer app.inject() over Supertest. It avoids port binding and exercises the full request lifecycle including hooks.
| 1 | // fastify.test.js |
| 2 | import { describe, it, expect, beforeAll, afterAll } from 'vitest'; |
| 3 | import { buildApp } from '../src/app.js'; |
| 4 | |
| 5 | describe('Fastify app', () => { |
| 6 | let app; |
| 7 | beforeAll(async () => { app = await buildApp({ logger: false }); }); |
| 8 | afterAll(async () => { await app.close(); }); |
| 9 | |
| 10 | it('GET /health returns ok', async () => { |
| 11 | const res = await app.inject({ method: 'GET', url: '/health' }); |
| 12 | expect(res.statusCode).toBe(200); |
| 13 | expect(res.json()).toEqual({ status: 'ok' }); |
| 14 | }); |
| 15 | }); |
best practice
Integration tests verify that multiple components work together. They catch wiring mistakes that mocks hide: misconfigured database clients, incorrect migrations, serialization mismatches, and middleware ordering bugs.
| 1 | // integration/helpers/db.js |
| 2 | import { PostgreSqlContainer } from '@testcontainers/postgresql'; |
| 3 | import { drizzle } from 'drizzle-orm/node-postgres'; |
| 4 | import { migrate } from 'drizzle-orm/node-postgres/migrator'; |
| 5 | |
| 6 | export async function setupTestDb() { |
| 7 | const container = await new PostgreSqlContainer().start(); |
| 8 | const db = drizzle(container.getConnectionUri()); |
| 9 | await migrate(db, { migrationsFolder: './drizzle' }); |
| 10 | |
| 11 | return { |
| 12 | db, |
| 13 | async close() { await container.stop(); }, |
| 14 | }; |
| 15 | } |
info
Databases are the most common source of test flakiness. Three patterns keep them deterministic: transactions, truncation, and templated databases.
| Pattern | How It Works | Best For |
|---|---|---|
| Transactions | BEGIN before test, ROLLBACK after | Single-connection tests |
| Truncation | DELETE/TRUNCATE tables between tests | Parallel tests, connection pools |
| Template DB | Create DB from migrated template | Heavy seed data, parallel workers |
| 1 | // helpers/with-rollback.js |
| 2 | export async function withRollback(db, callback) { |
| 3 | const tx = await db.transaction(); |
| 4 | try { |
| 5 | await callback(tx); |
| 6 | } finally { |
| 7 | await tx.rollback(); |
| 8 | } |
| 9 | } |
| 10 | |
| 11 | it('inserts a row', async () => { |
| 12 | await withRollback(db, async (tx) => { |
| 13 | const row = await tx.insert(users).values({ email: 'a@b.com' }).returning(); |
| 14 | assert.equal(row[0].email, 'a@b.com'); |
| 15 | }); |
| 16 | }); |
warning
Fixtures provide known input and expected output. Hard-coded JSON files are simple but age poorly. Factories generate valid records on demand, making tests more readable and resilient to schema changes.
| 1 | // factories/user.factory.js |
| 2 | import { faker } from '@faker-js/faker'; |
| 3 | |
| 4 | export function userFactory(overrides = {}) { |
| 5 | return { |
| 6 | id: crypto.randomUUID(), |
| 7 | email: faker.internet.email(), |
| 8 | name: faker.person.fullName(), |
| 9 | role: 'user', |
| 10 | createdAt: new Date(), |
| 11 | ...overrides, |
| 12 | }; |
| 13 | } |
| 14 | |
| 15 | // usage |
| 16 | const admin = userFactory({ role: 'admin', email: 'admin@example.com' }); |
| 17 | await db.users.insert(admin); |
best practice
Coverage tells you which lines were executed, not which behaviors were verified. Treat it as a guardrail, not a goal. A codebase with 90% coverage and weak assertions is less safe than one with 60% coverage and thoughtful assertions.
| 1 | { |
| 2 | "scripts": { |
| 3 | "test": "node --test", |
| 4 | "test:coverage": "c8 node --test", |
| 5 | "test:vitest": "vitest run --coverage", |
| 6 | "test:ci": "vitest run --coverage --reporter=default --reporter=junit" |
| 7 | } |
| 8 | } |
For the built-in runner, use c8 or --experimental-test-coverage. Vitest and Jest include coverage via @vitest/coverage-v8 or jest --coverage. Export results in Cobertura or JUnit format for CI dashboards.
| 1 | # .github/workflows/test.yml |
| 2 | name: Test |
| 3 | on: [push, pull_request] |
| 4 | jobs: |
| 5 | test: |
| 6 | runs-on: ubuntu-latest |
| 7 | services: |
| 8 | postgres: |
| 9 | image: postgres:16 |
| 10 | env: |
| 11 | POSTGRES_PASSWORD: postgres |
| 12 | ports: |
| 13 | - 5432:5432 |
| 14 | steps: |
| 15 | - uses: actions/checkout@v4 |
| 16 | - uses: actions/setup-node@v4 |
| 17 | with: |
| 18 | node-version: 22 |
| 19 | - run: npm ci |
| 20 | - run: npm run migrate:test |
| 21 | - run: npm run test:ci |
note
Teams new to Node.js testing often repeat the same mistakes. Recognizing them early saves hours of flaky CI and low-value maintenance.
Testing implementation instead of behavior
Assert outputs and side effects, not the order of internal function calls. If refactoring breaks the test but not the feature, the test is coupled to implementation.
Ignoring async boundaries
Forgetting await on assertions or promises causes tests to pass before the code under test finishes. Use assert.rejects or expect().rejects explicitly.
Shared mutable state
Mutating module-level variables across tests creates order-dependent failures. Reset state in beforeEach, use factories for fresh objects, and avoid singleton registries in test environments.
danger