130: Testing Node Applications — `node:test`, Assertions, Mocks, HTTP Integration, Vitest/Jest, and E2E Boundaries
Learning objectives
You will learn to:
- write tests using Node's built-in test runner;
- use strict assertions;
- structure unit, integration, contract, and end-to-end tests;
- test async code;
- isolate filesystem/time/environment dependencies;
- test HTTP applications;
- understand mocking and fake-timer trade-offs;
- test shutdown and process behavior;
- understand when Vitest/Jest are alternatives;
- integrate Playwright/Cypress at browser-system level;
- avoid brittle implementation-detail tests.
Test layers
pure/unit component/module integration HTTP/API integration database integration contract end-to-end
Choose cheapest layer that proves the risk.
node:test
import test from 'node:test';
import assert from 'node:assert/strict';
test('normalize title', () => {
assert.equal(
normalizeTitle(' Learn Node '),
'Learn Node',
);
});
Run:
node --test
The built-in test runner is capable enough for many Node projects.
Roadmap also lists Jest/Vitest; ecosystem choice depends on full-stack tooling/team.
Nested suites
import { describe, it } from 'node:test';
describe('task service', () => {
it('creates a task', async () => {
...
});
});
Use descriptive behavior names.
Async test
test('loads task', async () => {
const task = await service.get('t1');
assert.equal(task.id, 't1');
});
If Promise rejects unexpectedly, test fails.
Rejection assertion
await assert.rejects(
() => service.get('missing'),
(error) => {
assert.equal(error.code, 'NOT_FOUND');
return true;
},
);
Assert stable code/type, not entire stack text.
Pure unit tests
Repository-independent service:
const repository = {
async findById(id) {
return id === 't1'
? { id: 't1', title: 'A' }
: null;
},
};
const service = createTaskService({
repository,
});
This is dependency injection, not necessarily mocking framework magic.
Test doubles
Stub
Returns controlled values.
Spy
Records calls.
Mock
Often means expectation-based fake/test double; terminology varies.
Fake
Working lightweight implementation, such as in-memory repository.
Prefer simplest double.
Do not mock everything.
Built-in mock utilities
Modern node:test provides mocking capabilities.
Example concept:
const send = mock.fn(async () => ({ ok: true }));
Check exact API for supported Node release.
Use when call interaction itself matters.
Do not assert private function calls instead of behavior.
Filesystem tests
Use a temporary directory:
import {
mkdtemp,
rm,
writeFile,
} from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
const dir = await mkdtemp(
path.join(os.tmpdir(), 'task-test-'),
);
try {
...
} finally {
await rm(dir, {
recursive: true,
force: true,
});
}
Never use production path/database in tests.
Environment variables
Bad test:
process.env.PORT = '9999';
// forget to restore
leaks into later tests.
Save/restore or pass config explicitly.
Better application architecture:
createApp({
config: {
port: 0,
},
});
rather than deep modules reading process.env repeatedly.
Time
Functions depending on current time should allow injected clock:
function createToken(clock = () => new Date()) {
...
}
Test:
const fixed = () => new Date('2026-01-01T00:00:00Z');
Fake timers can help for timer behavior, but restore them.
Randomness/IDs
Inject ID generator where deterministic test matters:
createTaskService({
idGenerator: () => 't1',
});
Do not mock crypto globally unless required.
For integration tests, random IDs are fine if assertions do not depend on exact value.
HTTP server test
Native:
const server = app.listen(0);
await once(server, 'listening');
const address = server.address();
const response = await fetch(
`http://127.0.0.1:${address.port}/health`,
);
Port 0 lets OS choose free port.
Always close server.
Express testing
Libraries such as Supertest are commonly used to exercise Express app without managing a fixed external port.
Goal:
real Express routing/middleware real serialization controlled dependencies
Do not unit-test every res.status() call if an HTTP integration test can prove contract.
API test matrix
GET /tasks/:id:
valid id → 200 missing → 404 wrong tenant → 404/403 policy repository unavailable → 500 auth missing → 401 malformed auth → 401
POST:
valid → 201 bad JSON → 400 wrong media type → 415 validation → 422 too large → 413 duplicate idempotency → stable result
Contract tests
Contract specifies:
method path request status body headers errors
Use OpenAPI/schema if project benefits.
Consumer/provider tests prevent frontend/backend mocks drifting.
Database integration tests
When Mongo arrives, test against:
- disposable test database;
- container/local ephemeral instance;
- dedicated database;
- transaction/cleanup strategy.
Do not mock database driver for every repository test; then you never prove queries/index assumptions.
Unit-test mapping logic separately, integration-test real database behavior.
Test isolation
Parallel tests can collide if they share:
same port same DB names same files same env same singleton
Use unique resources and cleanup.
Test architecture should support concurrency.
Process-level tests
For CLI/exit behavior, spawn child process:
const child = spawn(
process.execPath,
['./src/cli.js', 'bad-command'],
);
Capture stdout/stderr/exit code.
This is better than mocking process.exit.
Signal/shutdown test
Spawn server process, wait ready, send SIGTERM, assert:
stops accepting cleanup log/event exit 0 within deadline
Platform-specific signal tests may need conditional behavior.
Worker tests
Test worker job result and cancellation.
For pool, test:
- max concurrency;
- queue overflow;
- worker crash replacement;
- shutdown.
Network mocking
For outbound HTTP, MSW can also work in Node environments or use local test server.
Prefer protocol-level mock over mocking fetch internals when behavior like headers/status/body matters.
Vitest
Strong choice when project already uses Vite/frontend and wants shared tooling.
Supports:
- modern ESM;
- mocks;
- coverage;
- fast workflow.
Jest
Mature ecosystem with extensive features.
Can require more configuration in ESM-heavy Node projects depending setup.
Do not teach all three runners deeply. Learn testing principles; choose one project standard.
Playwright/Cypress
Browser E2E tools test full web journey:
React → browser → Node API → Mongo
Use for critical end-to-end flows.
Do not replace API tests with only browser tests; E2E is slower and harder to diagnose.
Coverage
Use coverage to find untested code paths.
Do not chase 100% as quality metric.
Prioritize:
- auth;
- payments;
- destructive operations;
- error mapping;
- concurrency;
- shutdown;
- migrations/data.
Flaky async tests
Symptoms:
sleep(100) hope server ready
Bad:
await new Promise((r) => setTimeout(r, 100));
Wait for actual condition/event:
await once(server, 'listening');
Use polling with deadline for eventual external conditions.
Avoid test order dependency
Every test should create its own prerequisites.
Do not depend on previous test “creates user.”
Suite should pass when reordered/parallelized.
Snapshot tests
Useful for selected stable serialized outputs.
Risk:
- giant snapshots approved blindly;
- hidden semantic break.
Prefer explicit assertions for API security/business behavior.
Security tests
Automate abuse cases:
- oversized body;
- invalid token;
- forbidden resource;
- cross-tenant ID;
- injection-shaped values;
- rate limit;
- path traversal;
- SSRF allowlist;
- unsupported content type.
Failure clinic
- fixed port conflicts;
- shared DB state;
- sleeps;
- over-mocking;
- asserting internals;
- env leakage;
- real external API in unit tests;
- tests ignore shutdown/resources;
- browser E2E used for every small validation.
Exercises
- Write Node test-runner service tests.
- Test rejected NotFoundError.
- Test filesystem code with temporary directory.
- Spawn CLI and assert stderr/exit code.
- Test native/Express health endpoint on ephemeral port.
- Build API contract test matrix.
- Add auth/tenant security tests.
- Test SIGTERM shutdown.
- Compare node:test and Vitest in one small module.
- Add one Playwright critical journey after frontend integration.
Mastery checklist
Explain:
- test layers;
- node:test/assert;
- async errors;
- doubles;
- temp resources;
- HTTP integration;
- contract tests;
- DB integration;
- process/signal tests;
- E2E;
- flake causes;
- risk-based coverage.
