|$ curl https://forge-ai.dev/api/markdown?path=docs/nodejs/testing
$cat docs/testing.md
updated Recently·20-28 min read·published

Testing

Node.jsTestingIntermediate🎯Free Tools
Introduction

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.

The Built-in Test Runner

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.

math.test.js
JavaScript
1// math.test.js
2import { describe, it } from 'node:test';
3import assert from 'node:assert/strict';
4import { add, divide } from './math.js';
5
6describe('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});
run-tests.sh
Bash
1node --test # run all discovered test files
2node --test 'src/**/*.test.js'
3node --test --watch # watch mode (Node.js 20+)

info

Import node:assert/strict instead of node:assert. The strict version uses strict equality (===) by default, catching type coercion bugs that the legacy assertion mode silently allows.

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.

hooks.test.js
JavaScript
1import { describe, it, before, beforeEach, after } from 'node:test';
2import assert from 'node:assert/strict';
3
4describe('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});
Assertion Patterns

Good assertions describe behavior, not implementation. Prefer semantic matchers over exact object equality when the shape is unstable.

AssertionUse Case
assert.equalStrict equality (===)
assert.deepEqualStructural equality
assert.ok / assert.matchTruthiness and regex matching
assert.throws / assert.rejectsExpected sync and async errors
assertion-patterns.js
JavaScript
1// Avoid brittle exact equality on generated fields
2const user = await service.createUser({ email: 'a@b.com' });
3assert.ok(user.id);
4assert.equal(user.email, 'a@b.com');
5assert.ok(user.createdAt instanceof Date);

warning

Never assert exact timestamps, auto-generated IDs, or random values directly. Capture the value, assert its type or pattern, and compare the parts you control. Brittle assertions cause false failures and train teams to ignore red builds.
Mocking

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.

mocking.test.js
JavaScript
1import { describe, it, beforeEach } from 'node:test';
2import assert from 'node:assert/strict';
3import { mock } from 'node:test';
4import { PaymentService } from './payment.js';
5
6describe('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.

vitest-mock.test.js
JavaScript
1// payment.test.js
2import { vi, describe, it, expect, beforeEach } from 'vitest';
3import Stripe from 'stripe';
4import { PaymentService } from './payment.js';
5
6vi.mock('stripe', () => ({
7 default: vi.fn(() => ({ charges: { create: vi.fn() } })),
8}));
9
10describe('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

Mock at the edge of your system, not every internal function. Over-mocking couples tests to implementation and hides real failure modes. Prefer real implementations for cheap, deterministic collaborators; mock only unstable or slow dependencies.
Testing with Vitest

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.

install-vitest.sh
Bash
1npm install -D vitest @vitest/coverage-v8
vitest.config.js
JavaScript
1// vitest.config.js
2import { defineConfig } from 'vitest/config';
3
4export 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});
user.service.test.js
JavaScript
1// user.service.test.js
2import { describe, it, expect, beforeAll, afterAll } from 'vitest';
3import { createApp } from '../src/app.js';
4import { setupTestDb } from './helpers/db.js';
5
6describe('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

Keep globals: false in Vitest unless you are migrating a large Jest codebase. Explicit imports make tests easier to follow in editors and avoid conflicts with other tooling.
Testing with Jest

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.

package.json
JSON
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}
sum.test.js
JavaScript
1// sum.test.js
2import { sum } from './sum.js';
3
4describe('sum', () => {
5 it('returns the sum of two numbers', () => {
6 expect(sum(2, 3)).toBe(5);
7 });
8});

warning

Jest's ESM story is improving but still fragile. If you hit SyntaxError: Cannot use import statement outside a module, verify type: "module" in package.json, use the --experimental-vm-modules flag, and ensure transform: so Jest does not CommonJS-wrap your ESM files.
HTTP Testing with Supertest

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.

app.test.js
JavaScript
1// app.test.js
2import request from 'supertest';
3import { describe, it, beforeAll, afterAll } from 'vitest';
4import { createApp } from './app.js';
5import { setupTestDb } from './helpers/db.js';
6
7describe('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.

fastify.test.js
JavaScript
1// fastify.test.js
2import { describe, it, expect, beforeAll, afterAll } from 'vitest';
3import { buildApp } from '../src/app.js';
4
5describe('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

Always close servers, database connections, and event listeners after tests. Unclosed handles keep the test runner alive, cause CI timeouts, and leak memory. Verify with --detectOpenHandles in Jest or --pool=forks in Vitest.
Integration Tests

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.

helpers/db.js
JavaScript
1// integration/helpers/db.js
2import { PostgreSqlContainer } from '@testcontainers/postgresql';
3import { drizzle } from 'drizzle-orm/node-postgres';
4import { migrate } from 'drizzle-orm/node-postgres/migrator';
5
6export 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

Run integration tests against the same database engine you use in production. SQLite-in-memory is fast but invalidates query and transaction behavior. If Testcontainers is too slow locally, run a persistent test database in Docker and wipe schemas between runs.
Test Database Patterns

Databases are the most common source of test flakiness. Three patterns keep them deterministic: transactions, truncation, and templated databases.

PatternHow It WorksBest For
TransactionsBEGIN before test, ROLLBACK afterSingle-connection tests
TruncationDELETE/TRUNCATE tables between testsParallel tests, connection pools
Template DBCreate DB from migrated templateHeavy seed data, parallel workers
with-rollback.js
JavaScript
1// helpers/with-rollback.js
2export 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
11it('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

Transactions do not work well with connection pools or frameworks that create their own connections. If your app opens a separate pool from the test harness, use truncation or a template database; otherwise rolled-back data may be invisible to the app under test.
Fixtures and Factories

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.

user.factory.js
JavaScript
1// factories/user.factory.js
2import { faker } from '@faker-js/faker';
3
4export 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
16const admin = userFactory({ role: 'admin', email: 'admin@example.com' });
17await db.users.insert(admin);

best practice

Use fakerfor values that do not affect the assertion and hard-code only the values the test cares about. This makes the test's intent obvious.
Coverage and CI

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.

package.json
JSON
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.

test.yml
YAML
1# .github/workflows/test.yml
2name: Test
3on: [push, pull_request]
4jobs:
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

Run unit and integration tests in separate CI jobs when possible. Unit tests finish in seconds; integration tests need services and are slower. Splitting them prevents slow integration failures from blocking quick local-style checks.
Common Mistakes

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

Do not rely on test order. Modern runners execute tests concurrently by default. A test that passes in isolation but fails in the suite almost always indicates shared state or leaked handles.
$Blueprint — Engineering Documentation·Section ID: NODE-TEST-01·Revision: 1.0