130: Testing Node Applications — `node:test`, Assertions, Mocks, HTTP Integration, Vitest/Jest, and E2E Boundaries
Learning objectives
Testing a Node application is more than checking whether a function returns the expected value. You need to choose a test boundary that proves the risk you care about, control dependencies without hiding real failures, and clean up every resource the test creates.
You will learn to:
- write tests using Node's built-in test runner;
- use strict assertions and choose assertions that describe the behavior that matters;
- structure unit, integration, contract, and end-to-end tests;
- test async code and make rejected promises fail the test rather than disappear;
- isolate filesystem, time, randomness, and environment dependencies;
- test HTTP applications through realistic requests and responses;
- understand mocking and fake-timer trade-offs;
- test shutdown and process behavior, including signals and exit codes;
- understand when Vitest or Jest are useful alternatives;
- integrate Playwright or Cypress at the browser-system level;
- avoid brittle tests that merely repeat implementation details.
The aim is not to maximize the number of tests. It is to create tests that give useful evidence about behavior and fail in ways that help you locate the problem.
Test layers
Different tests answer different questions, and they have different costs. A useful progression is:
pure/unit
component/module integration
HTTP/API integration
database integration
contract
end-to-end
A pure unit test is usually the fastest way to check deterministic business logic. An end-to-end test proves a much larger path, but it needs more setup and gives you more possible failure locations. The useful rule is to choose the cheapest layer that proves the risk. Do not use a browser test to prove something that a small unit test can establish, but do not mock away the database when the risk is an actual query or index assumption.
node:test
Node includes a test runner, so a small project can begin testing without adding a framework. Pair it with strict assertions so that comparisons do not silently coerce values:
import test from 'node:test';
import assert from 'node:assert/strict';
test('normalize title', () => {
assert.equal(
normalizeTitle(' Learn Node '),
'Learn Node',
);
});
Run the tests with:
node --test
The built-in runner is capable enough for many Node projects. It supports the basic test structure, asynchronous tests, suites, assertions, and modern mocking capabilities. That keeps the test boundary close to the runtime your application actually uses.
The roadmap also lists Jest and Vitest. The choice is usually an ecosystem decision: consider the project's module system, frontend tooling, coverage workflow, team conventions, and the amount of configuration you want to maintain. The testing principles in this lesson apply whichever runner you select.
Nested suites
Use nested suites when grouping makes the behavior easier to navigate, not simply because every file needs another wrapper:
import { describe, it } from 'node:test';
describe('task service', () => {
it('creates a task', async () => {
...
});
});
Test names should describe behavior. "Creates a task" tells you what contract failed; "calls repository method" mostly tells you which implementation detail was asserted. Descriptive names become especially valuable when a suite runs in CI and the only immediate evidence is the failure name.
Async test
An async test should await the operation whose result it is checking:
test('loads task', async () => {
const task = await service.get('t1');
assert.equal(task.id, 't1');
});
The test runner waits for the returned promise. If the promise rejects unexpectedly, the test fails. That is different from starting an asynchronous operation without returning or awaiting it: the test could finish before the failure occurs, leaving an unhandled rejection or a misleading pass.
Rejection assertion
Expected failures are still behavior and deserve an assertion. assert.rejects lets you verify both that the operation rejects and that the rejection has the right stable meaning:
await assert.rejects(
() => service.get('missing'),
(error) => {
assert.equal(error.code, 'NOT_FOUND');
return true;
},
);
Assert a stable code or error type rather than the entire stack text. Stack traces contain paths, line numbers, and wording that can change when an implementation is refactored. The domain code is the part callers and HTTP error mapping should rely on.
Pure unit tests
A service that receives its repository can be tested without a real database. The replacement below is a small controlled dependency:
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. The production service depends on an interface-like shape, and the test supplies an implementation with predictable data. That makes the test fast and keeps its focus on service behavior. It does not, however, prove that the production repository issues a valid query; that belongs in a database integration test.
Test doubles
The term test double covers several ways of replacing a dependency. The distinctions are useful because each double answers a different testing need.
Stub
A stub returns controlled values. Use one when the behavior under test needs a known success, missing value, or failure from a dependency.
Spy
A spy records calls. It can show that a dependency was called, with which arguments, and how many times. That interaction is useful when the interaction itself is part of the contract, but it should not replace checking the externally visible result.
Mock
Mock often means an expectation-based fake or test double. The terminology varies between libraries, so check what a particular runner means by "mock" before assuming it has a specific behavior.
Fake
A fake is a working lightweight implementation, such as an in-memory repository. It generally exercises more realistic behavior than a fixed return-value stub while avoiding an external database.
Prefer the simplest double that proves the risk. Do not mock everything. Excessive replacement can make a test pass while the real collaborators, serialization, queries, or error paths are broken.
Built-in mock utilities
Modern versions of node:test provide mocking capabilities. For example, a function can be replaced with a controlled implementation:
const send = mock.fn(async () => ({ ok: true }));
Check the exact API supported by the Node release used by the project. Runtime APIs can differ across releases, and a lesson example should not be treated as proof that every installed version exposes the same helper or assertion surface.
Use a mock when the call interaction itself matters, such as verifying that a notification is sent once after a successful state transition. Do not assert private function calls merely because they are easy to observe. Prefer the behavior a caller can see, and use interaction assertions only where they protect a meaningful contract.
Filesystem tests
Filesystem tests should use resources created for the test. A temporary directory keeps test data away from source files and production data:
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,
});
}
The finally block is not optional cleanup polish. It must run after both a passing and a failing test so that later tests do not encounter leftover files. Never point a test at a production path or database. A test that can delete or overwrite real application data is an operational and security hazard.
Environment variables
Environment variables are process-global state. This test changes state and forgets to put it back:
process.env.PORT = '9999';
// forget to restore
The value leaks into later tests, and the later failure may appear unrelated to the test that caused it. Save and restore the variable, or pass configuration explicitly so the application does not depend on hidden global reads.
A clearer application boundary is:
createApp({
config: {
port: 0,
},
});
That is generally easier to test than having deep modules read process.env repeatedly. Configuration can be loaded once at the composition boundary, validated there, and passed to the components that need it. Tests can then select a safe port or other test value without mutating process-wide state.
Time
Code that depends on the current time becomes difficult to reproduce when the clock is read directly. Allow a clock to be injected:
function createToken(clock = () => new Date()) {
...
}
The default preserves normal application behavior, while a test can provide a fixed value:
const fixed = () => new Date('2026-01-01T00:00:00Z');
Fake timers can help when the behavior specifically involves timers, expiration, retries, or scheduled work. Restore fake timers after the test. Otherwise, unrelated asynchronous operations can observe the artificial clock and produce failures that are hard to diagnose.
Randomness/IDs
When a deterministic test needs to know an identifier, inject its generator:
createTaskService({
idGenerator: () => 't1',
});
Do not mock crypto globally unless the test genuinely needs to control that boundary. Global replacement can affect unrelated code and may hide integration problems. For integration tests, random IDs are fine when assertions check properties and relationships rather than depending on one exact generated value.
HTTP server test
An HTTP integration test can use a native server and an ephemeral port:
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 asks the operating system to choose an available port, which avoids collisions between parallel test processes. Waiting for the actual listening event is safer than sleeping for an arbitrary number of milliseconds. Always close the server, including when an assertion fails, or the test process may hang and later tests may inherit an open listener.
Express testing
Libraries such as Supertest are commonly used to exercise an Express application without managing a fixed external port. The test should cross the boundary that matters:
real Express routing/middleware
real serialization
controlled dependencies
This gives confidence in route selection, middleware behavior, and the shape of the response while keeping the repository or other expensive dependencies controlled. Do not unit-test every res.status() call if an HTTP integration test can prove the response contract more directly. Assertions should focus on status, headers, body, and meaningful side effects.
API test matrix
An API test matrix turns a route's expected behavior into explicit cases. For GET /tasks/:id, consider:
valid id → 200
missing → 404
wrong tenant → 404/403 policy
repository unavailable → 500
auth missing → 401
malformed auth → 401
The wrong-tenant result is a policy decision, not a universal HTTP rule. Some systems deliberately return 404 to avoid revealing that a resource exists; others return 403 after identifying the caller. Test the policy your application has chosen.
For POST, include both validation and operational cases:
valid → 201
bad JSON → 400
wrong media type → 415
validation → 422
too large → 413
duplicate idempotency → stable result
The duplicate-idempotency case matters because retrying a request should not unexpectedly create duplicate work. The exact status and body are part of the API contract and should be documented rather than inferred from whichever middleware happens to run first.
Contract tests
A contract specifies the observable HTTP agreement:
method
path
request
status
body
headers
errors
Use an OpenAPI document or another schema when the project benefits from a shared, machine-readable contract. Consumer/provider tests help prevent frontend and backend mocks from drifting apart: the frontend's assumed response shape and the provider's actual response are checked against the same agreement.
Database integration tests
When Mongo arrives, database tests need an isolated database boundary. Possible approaches include:
- disposable test database;
- container/local ephemeral instance;
- dedicated database;
- transaction/cleanup strategy.
The right option depends on the database behavior and the test environment, but it must prevent tests from sharing production data or accidentally relying on another test's records. Cleanup also needs to account for indexes, uniqueness constraints, and any state that survives a single operation.
Do not mock the database driver for every repository test. If every query is replaced by a preprogrammed return value, you never prove that the query syntax, serialization, indexes, or assumptions about the real database are correct. Unit-test mapping logic separately, and integration-test real database behavior at the boundary where it matters.
Test isolation
Parallel tests can collide when they share:
same port
same DB names
same files
same env
same singleton
Use unique resources and clean them up. A test architecture that works only when tests run in one particular order is already signaling hidden global state. Design for concurrency even if the suite is initially run serially; CI, watch mode, and future test-runner settings may change that assumption.
Process-level tests
CLI behavior and exit behavior are process concerns. Spawn the CLI rather than replacing process.exit inside the same process:
const child = spawn(
process.execPath,
['./src/cli.js', 'bad-command'],
);
Capture stdout, stderr, and the exit code. This tests what a real shell or supervisor observes, including whether errors go to the correct stream. It is usually more informative and safer than mocking process.exit, which can let the rest of the current test process continue in a state the real CLI would never have.
Signal/shutdown test
Shutdown behavior also needs a process-level boundary. Spawn the server process, wait until it is ready, send SIGTERM, and assert that it:
stops accepting
cleanup log/event
exit 0 within deadline
The deadline protects the suite from a server that never closes a listener or hangs while draining work. Platform-specific signal behavior may require conditional handling, particularly when the test is run on a different operating system. Keep that qualification in the test instead of assuming that every platform delivers signals identically.
Worker tests
For a worker, test both the job result and cancellation. Cancellation is part of the behavior when work can outlive a request, shutdown, or queue lease.
For a pool, test:
- max concurrency;
- queue overflow;
- worker crash replacement;
- shutdown.
These cases exercise the boundaries where a pool usually fails: too much work admitted at once, work rejected under pressure, a worker disappearing, or the process exiting while jobs remain. A happy-path result alone does not establish those guarantees.
Network mocking
For outbound HTTP, MSW can also work in Node environments, or the test can use a local test server. Both approaches let the test control protocol-level behavior without contacting a real external service.
Prefer a protocol-level mock over mocking fetch internals when headers, status codes, body parsing, retries, or timeout behavior matter. Replacing the internal function with a return value can bypass the very request and response handling the test is meant to verify. Unit tests can still isolate a client when that narrower boundary is the risk.
Vitest
Vitest is a strong choice when a project already uses Vite or frontend tooling and wants shared configuration and workflow.
It supports:
- modern ESM;
- mocks;
- coverage;
- fast workflow.
Those features are useful, but they do not remove the need to choose good test boundaries. A fast runner can still produce brittle tests if the suite asserts internals or shares state.
Jest
Jest has a mature ecosystem and extensive features. It remains a reasonable project standard when the team already knows it or depends on its integrations.
In ESM-heavy Node projects, Jest can require more configuration depending on the setup. Treat that as a project trade-off rather than a universal verdict. Do not try to teach all three runners deeply in one lesson. Learn the testing principles, then choose one standard for a project so its setup and conventions stay coherent.
Playwright/Cypress
Playwright and Cypress test a complete browser journey:
React
→ browser
→ Node API
→ Mongo
Use browser E2E tests for critical end-to-end flows where confidence in the assembled system is worth the cost. They can reveal problems in browser behavior, frontend integration, API wiring, authentication, and persistence that isolated tests cannot.
Do not replace API tests with only browser tests. E2E tests are slower and harder to diagnose, and a browser failure may not tell you whether the route, database, or UI caused the problem. Keep the faster, narrower tests and add a small number of high-value journeys over them.
Coverage
Use coverage to find untested code paths and to identify areas where the suite has no evidence. Coverage is a diagnostic signal, not a quality score. Chasing 100% can encourage meaningless assertions and tests that exist only to execute lines.
Prioritize risk such as:
- auth;
- payments;
- destructive operations;
- error mapping;
- concurrency;
- shutdown;
- migrations/data.
These areas can cause disproportionate harm when they fail, even if they account for fewer lines than ordinary formatting or plumbing code.
Flaky async tests
A common flaky pattern is:
sleep(100)
hope server ready
This version waits an arbitrary duration:
await new Promise((r) => setTimeout(r, 100));
It may be too short on a busy machine and unnecessarily slow on a fast one. Wait for the actual condition or event instead:
await once(server, 'listening');
For an external condition that has no event, use polling with a deadline. The deadline prevents an absent condition from hanging forever, while polling the condition rather than sleeping blindly makes the test describe the behavior it needs.
Avoid test order dependency
Every test should create its own prerequisites. Do not depend on a previous test that "creates user." If that earlier test is skipped, reordered, retried, or run in another worker, the dependent test becomes invalid.
A healthy suite should pass when tests are reordered or parallelized. This standard exposes shared state early and makes a failure point to the test's own setup rather than to an undocumented sequence.
Snapshot tests
Snapshots are useful for selected stable serialized outputs, especially when reviewing a deliberate shape change is valuable. They become risky when giant snapshots are approved blindly or when a changed snapshot hides a semantic break.
Prefer explicit assertions for API security and business behavior. An explicit assertion can say that a secret is absent, a tenant cannot access another tenant's record, or a payment state is correct. A large snapshot can change while still leaving those properties unnoticed.
Security tests
Automate abuse cases as part of the test strategy:
- oversized body;
- invalid token;
- forbidden resource;
- cross-tenant ID;
- injection-shaped values;
- rate limit;
- path traversal;
- SSRF allowlist;
- unsupported content type.
These tests make the application's security policy executable. Treat request bodies, identifiers, headers, and outbound targets as untrusted input, and check not only that ordinary requests work but also that unsafe requests are rejected without leaking information or consuming unbounded resources.
Failure clinic
When a suite is unreliable or gives false confidence, look for these recurring causes:
- 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.
Each symptom points to a boundary problem: shared state needs isolation, sleeps need condition-based waiting, over-mocking needs a more realistic collaborator, and unclosed resources need lifecycle assertions. The goal is not merely to make the red test green; it is to make the failure explainable and the test evidence trustworthy.
Exercises
Work through these in order. The early exercises establish the runner and assertion mechanics; the later ones require you to reason about real resources, process boundaries, and the appropriate test layer.
- 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.
For each exercise, record what boundary the test crosses, which dependencies are controlled, and what cleanup is required. That reasoning is part of the solution, not an afterthought.
Mastery checklist
Explain:
- test layers and why their cost and confidence differ;
- node:test/assert and the role of strict assertions;
- async errors and how to assert expected rejection;
- doubles and when a stub, spy, mock, or fake is appropriate;
- temp resources and cleanup;
- HTTP integration and ephemeral ports;
- contract tests;
- DB integration and why real query behavior matters;
- process/signal tests;
- E2E boundaries;
- flake causes;
- risk-based coverage.
If you can explain these distinctions and diagnose a failing test at the correct source, server, database, or lifecycle boundary, you are using tests as engineering tools rather than only as pass/fail checks.
Official references
Use the runtime and tool documentation for release-specific APIs and configuration details:
