open atlas
↑ Back to track
Node.js, zero to senior NODE · 06 · 02

Integration testing an HTTP API

An integration test drives routing, middleware, handler and a real DB through the HTTP surface. supertest boots your app on an ephemeral port; isolation and a real engine decide whether the suite catches bugs or invents flakes.

NODE Middle ◷ 18 min
Level
FoundationsJuniorMiddleSenior

A team had 400 green unit tests and shipped a POST /orders route that 500’d on the first real request. The handler was perfect in isolation; the bug was that the auth middleware ran after the body parser, so req.user was undefined by the time validation read it — a wiring error no unit test could see, because every unit test called the handler directly with a fake req. Worse, their one integration test passed locally and failed in CI at random: it ran against the same shared dev Postgres as everyone else, so a colleague’s seed row made the “expect 1 result” assertion flip between 1 and 2. They didn’t have a missing test. They had tests that never exercised the seam where the bug actually lived.

What an integration test covers that a unit test can’t

Ask yourself: when was the last time a bug in your unit-test-covered code still made it to production? Chances are it lived at a seam — middleware ordering, a wrong status code, auth wired to the wrong path. That is the gap integration tests close.

A unit test pins one function with its collaborators stubbed: fast, focused, and blind to wiring. An integration test exercises the whole request path — router → middleware chain → handler → (usually) the real database — through the actual HTTP surface. That is exactly where a class of bugs lives that unit tests structurally cannot reach: middleware ordering, a forgotten await in a route, a serializer that drops a field, a status code that’s 200 when it should be 201, a content negotiation mismatch, an auth guard wired to the wrong path. The handler can be individually correct and the assembly still broken.

In the test pyramid, integration tests sit above unit tests and below end-to-end: more of them than E2E (which drives a real browser/network and is slow and flaky), fewer than units. The payoff per test is high — one integration test covers many units’ worth of seams — but each costs more to run, so you don’t write thousands. The classic failure is an “ice-cream cone”: almost all tests at the slow end, a hollow middle, and contract bugs leaking to production.

supertest: real HTTP calls without managing a port

supertest wraps any Node http request handler — an Express or Fastify app, or a bare (req, res) => … — and on each call boots it on an ephemeral port (or dispatches in-process), fires a real HTTP request, and tears the listener down. You never pick a port, never risk EADDRINUSE from a hard-coded 3000, and you assert on the real status line, headers, and parsed body.

import { test } from "node:test";
import assert from "node:assert/strict";
import request from "supertest";
import { app } from "../src/app.js"; // do NOT call app.listen() here

test("POST /users creates a user", async () => {
  const res = await request(app)
    .post("/users")
    .send({ email: "a@b.com" })   // sets JSON body + content-type
    .expect("Content-Type", /json/)
    .expect(201);

  assert.equal(res.body.email, "a@b.com");
  assert.match(res.headers.location, /^\/users\/\d+$/);
});

Pass the app itself, not a running server — supertest manages the listener so the test owns the lifecycle. Because the call goes over real HTTP, it catches the wiring bug from the hook: middleware order, body parsing, and the status code are all in play, not stubbed away.

A real database: four strategies, four tradeoffs

The database is where integration tests earn their keep — and where they go wrong. Four common approaches:

StrategySpeedFidelityIsolation
Transaction per test + rollbackFastest (no I/O to reset)Real engineStrong, automatic
Truncate / reset tablesSlower (writes each test)Real engineGood, if you reset all
Testcontainers (Docker Postgres/Redis)Slow startup (~secs/suite)Highest — matches prodPer-suite container
In-memory SQLiteVery fastWrong engine — false confidencePer-connection

Rollback wraps each test in a transaction and aborts it at the end — nothing commits, so the next test sees a clean database with zero reset cost. It’s the fastest with strong isolation, but it can’t test code that itself commits or relies on transaction visibility (you can’t roll back what your code already committed, and nested-transaction tricks change semantics). Truncate/reset is simpler and lets you test commit logic, at the cost of an explicit cleanup write between tests. Testcontainers spins up a real Postgres or Redis in a throwaway Docker container per suite: highest fidelity because it’s the same engine, version, and SQL dialect as production — at the price of seconds of container startup. In-memory SQLite is fast and tempting, but it’s a different database: it won’t enforce your Postgres types, lacks JSONB, RETURNING, partial indexes, and many constraints, so a green SQLite suite can hide SQL that explodes on the real Postgres in prod. Prefer a real engine (Testcontainers) for fidelity; reach for rollback when suite speed dominates.

Why this works

Why is SQLite-for-Postgres specifically dangerous and not just “less accurate”? Because the failure is silent and inverted: the test is green and the production query is the thing that breaks. A migration using ALTER TABLE ... ADD COLUMN ... GENERATED, a query with ON CONFLICT ... DO UPDATE, a citext column, or a check constraint can all parse and pass on SQLite (which ignores or fakes them) while failing or behaving differently on Postgres. You ship with a green build and discover the divergence from a customer. Testing against the engine you deploy is the only way to make the test’s verdict trustworthy.

Isolation, and the open-handle hang

An integration suite is only trustworthy if every test is independent: no leftover rows from a previous test, no ordering dependence, no shared mutable state. Seed deterministic fixtures inside each test (or a fresh per-test transaction), point the suite at a dedicated test database or schema — never a shared dev DB — and give parallel workers separate schemas or databases so two workers don’t fight over the same rows. The hook’s flake is the canonical violation: tests against a shared DB become order-dependent, mutually destructive, and pass-or-fail by luck.

The second classic failure is the process that won’t exit. If you open a server listener or a DB connection pool and never close them, the Node test runner finishes its assertions but the event loop still has live handles, so the process hangs (or warns about open handles / leaked timers). Always close everything in teardown:

import { test, after } from "node:test";
import { pool } from "../src/db.js";

// node:test runs `after` hooks once the file's tests complete
after(async () => {
  await pool.end();   // close the DB connection pool
  // if you started a listener, server.close() too
});

With supertest you usually don’t start a listener at all (it manages ephemeral ones), so the pool is the handle people forget. A hanging CI job that times out after ten minutes is almost always an unclosed pool or socket — not a slow test.

Pick the best fit

Your API uses Postgres-specific features (JSONB columns, ON CONFLICT upserts, partial indexes). You need an integration suite that catches SQL bugs before prod, and you can afford a few seconds of startup per suite. Which test-DB strategy?

Quiz

What does request(app) do with the network port when you call supertest?

Quiz

Your integration suite is green on in-memory SQLite but the API 500s in production on a query using ON CONFLICT and a JSONB column. Why did the tests miss it?

Order the steps

Order the lifecycle of one isolated integration test, from setup to clean exit:

  1. 1 Start an isolated DB (per-suite container or a per-test transaction) pointed at a dedicated test schema
  2. 2 Seed deterministic fixtures this test needs — and nothing more
  3. 3 Fire the request via request(app).post('/...').send(...) over an ephemeral port
  4. 4 Assert on status, headers, and parsed body
  5. 5 Clean up: rollback the transaction (or truncate), then close the pool/server in teardown
Recall before you leave
  1. 01
    Why does an integration test catch bugs a unit test structurally cannot, and where does it sit in the test pyramid?
  2. 02
    Compare the four test-DB strategies and say when each is right.
Recap

An integration test exercises routing, the middleware chain, the handler, and usually a real database together through the HTTP surface, catching the wiring and contract bugs — middleware order, a missing await, a wrong status code, a dropped field — that unit tests structurally cannot see because they call the handler in isolation. It belongs above unit tests and below E2E in the pyramid: fewer than units, more than the slow, flaky end-to-end layer. supertest’s request(app) boots your Express/Fastify/http app on an ephemeral, OS-chosen port (or in-process), makes a real HTTP call, and tears down — so you never manage a port and you assert on the true status, headers, and body. The database choice decides whether the suite tells the truth: transaction-per-test rollback is fastest with strong isolation but can’t test commit logic; truncate/reset is simpler and slower; Testcontainers gives the highest fidelity by running the same Postgres/Redis as prod for a few seconds of startup; and in-memory SQLite is fast but a different engine whose green build can hide Postgres-specific SQL that breaks in production. Keep every test independent — deterministic fixtures, a dedicated test DB/schema, separate schemas per parallel worker — and always close the server and connection pool in teardown, or live handles will hang the runner. The hook’s two failures, a wiring bug unit tests couldn’t reach and a shared-DB flake, are the exact pair this discipline prevents. Now when you see an integration test fail in CI but pass locally, your first question is isolation: is it hitting a shared DB, a leaked handle, or a missing teardown?

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 5 done
Connected lessons

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.