Chapter 20: Testing for Node Services
Service testing is about confidence. Each layer of a Node application sees the world through different cracks in the door, so each test style shines a light on different behaviour. This chapter sketches where to draw the borders, how to exercise HTTP surfaces, how to bend time and IO without breaking the truth of your tests, and how to check that a service stays steady when traffic rolls in like a spring tide.
Unit, integration, and end to end boundaries
Clear boundaries keep tests honest. A unit test checks a single piece of logic with all dependencies replaced. An integration test checks two or more real components working together. An end to end test runs the whole service through its public interface. Each layer has a cost profile, and a healthy suite keeps a larger number of fast tests and a smaller number of slow, sweeping ones.
Unit tests as tight loops
Unit tests thrive on small inputs and predictable outputs. They do not touch the file system or network. This purity keeps them quick and portable.
import { sum } from "./math.js";
test("sum adds numbers", () => {
expect(sum(2, 3)).toBe(5);
});
Integration tests as trust builders
An integration test confirms that multiple modules talk to each other correctly. For a Node service, this could involve a real database, a message queue, or a cache. Spin up ephemeral resources when possible so you do not fight stale state.
// Connecting to a test Postgres database
import { createClient } from "./db.js";
test("user lookup works", async () => {
const db = await createClient(globalThis.TEST_DB_URL);
const user = await db.findUser("alice");
expect(user.email).toBe("alice@example.com");
});
End to end checks
End to end tests mirror real behaviour. They start the service, send real HTTP requests, and verify responses, logs, and side effects. Run these sparingly because they can be slow, but keep them around because they anchor the contract your users depend on.
import supertest from "supertest";
import { start } from "../dist/server.js";
let server;
beforeAll(async () => { server = await start(0); });
afterAll(async () => { await server.close(); });
test("GET /health returns ok", async () => {
const res = await supertest(server).get("/health");
expect(res.status).toBe(200);
expect(res.body.status).toBe("ok");
});
HTTP contract tests and fixtures
Contract tests ensure that your HTTP API behaves exactly as documented. These tests treat the service as a small universe: requests go in, responses come out, and fixtures help you shape the universe into predictable states.
Contract focused assertions
Contract tests pay attention to status codes, headers, body shape, and field semantics. They do not care about internal structures. Keep assertions crisp so failures point to real behaviour shifts.
test("POST /login returns typed payload", async () => {
const res = await supertest(server)
.post("/login")
.send({ email: "alice@example.com", password: "pw123456" });
expect(res.status).toBe(200);
expect(res.headers["content-type"]).toContain("application/json");
expect(res.body).toEqual({
userId: expect.any(String),
token: expect.any(String)
});
});
Using fixtures for repeatable states
Fixtures load data into the system so you can work with known scenarios. They act like small theatre sets that you raise and strike between tests. Keep fixtures small and disposable.
// A simple fixture loader for local Postgres
export async function loadFixture(db, name) {
const sql = await fs.readFile(`./fixtures/${name}.sql`, "utf8");
await db.query(sql); // seeds tables then …
}
Schema drift detection
When clients or services evolve independently, schemas can drift. Use contract tests to detect missing fields, changed types, or broken status codes. A failing contract test is early warning that something upstream or downstream needs attention.
Mocking IO and time safely
Many behaviours rely on clocks, randomness, or IO. Mocking these carefully keeps logic deterministic without turning tests into fragile illusions. The trick is to mock only the external edges and keep the core honest.
Mocking file and network calls
Replace IO at the boundary such as fs or HTTP clients. Provide minimal doubles that return controlled outputs. Keep mocks narrow so you do not simulate full systems through fragile stubs.
import { readFile } from "node:fs/promises";
jest.mock("node:fs/promises", () => ({
readFile: jest.fn()
}));
test("loader parses JSON", async () => {
readFile.mockResolvedValue("{\"name\":\"Ada\"}");
const data = await loadUser("./user.json");
expect(data.name).toBe("Ada");
});
Time control and timers
Use a fake clock when logic depends on time so you can advance or freeze it at will. This keeps delays, expiry logic, and retry intervals predictable.
jest.useFakeTimers();
test("token expires", () => {
const t = new Token(1000); // expires in 1 second
jest.advanceTimersByTime(1000);
expect(t.expired()).toBe(true);
});
Randomness and reproducibility
When randomness affects behaviour, inject a seedable source. This yields the same sequence on every run and makes tests tidy to debug.
// A tiny seedable RNG
class RNG { constructor(seed) { this.s = seed; }
next() { this.s = (this.s * 16807) % 2147483647; return this.s; }
}
Load testing and reliability verification
Load tests reveal how a service behaves under steady pressure and sudden bursts. They tell you where bottlenecks hide, how memory patterns evolve, and where latency bends out of shape. Reliability checks help you confirm that backpressure, timeouts, and retries behave as intended.
Steady load profiles
Start with a clean baseline. Pick a realistic requests per second figure and run it long enough to observe memory plateaus, CPU trends, and connection churn. Steady load shows you how the service behaves on a calm day.
// Example k6 script
import http from "k6/http";
export const options = {
vus: 20,
duration: "2m"
};
export default function () {
http.get("http://localhost:3000/health");
}
Burst and spike scenarios
Real traffic is rarely smooth. Test short spikes and longer bursts so you can see how the service handles queuing, saturation, and recovery. Monitor logs and metrics to spot slowdowns before errors start to appear.
Resilience verification
Use controlled fault injection to test timeouts, circuit breakers, and backpressure. These tests act like storm drills. Run them at safe intervals to confirm that safeguards behave as designed.
// Injecting delays in a fake dependency
app.get("/slow", async (_req, res) => {
await sleep(2000); // delay for test …
res.json({ ok: true });
});
Observing saturation
Track saturation signals such as queue depth, event loop lag, and connection wait times. These clues help you predict where bottlenecks form when traffic climbs.
With a thoughtful palette of tests across layers and load shapes, your service grows into something you can trust even before the first user arrives.