Integration tests

Test the seams — where your code meets a database, an HTTP layer or another service.

09of 16
≈ 13Min read
5Parts

Where the bugs actually are

Unit tests prove that each piece works when everything around it is imaginary. Most production failures are not in a piece: they are in the wiring. A column named unit_price and a property named unitPrice. A route that never registered. A JSON body that arrives as a string. A transaction that was never committed. Not one of those is visible from a unit test with the database mocked.

An integration test runs the real pieces together and asserts on the observable result: the status code, the body, the row that is now in the table.

What to include, and what to keep out

The database is the one people are most tempted to mock and the one it is least useful to mock. A mocked repository tests that your code calls the mock; a real one tests the query, the schema, the constraint and the types all at once.

IncludeSubstitute
your routing, validation and serialisationa third-party payment provider
the real database, or a close relativean email or SMS service
the real query layer and migrationsthe clock, and randomness
authentication and authorisationanything that costs money per call

Keeping them fast

  • One database per test run, created and migrated once. Docker or an in-process engine — SQLite in memory, Postgres in a container.
  • A transaction per test, rolled back at the end. Faster than truncating, and every test starts from the same known state.
  • Seed the minimum. A fixture with forty rows makes failures hard to read; build what the test needs.
  • Run them in parallel with a schema or a database per worker, once there are enough to matter.
the shape of it
beforeEach(async () => { await db.query("begin"); });
afterEach(async () => { await db.query("rollback"); });

Testing the HTTP layer

Calling the handler function directly skips the routing, the body parser, the middleware, the authentication and the error handler — which is to say, most of what can be wrong. Go through the app.

through the app, not through the handler
const res = await request(app).post("/api/orders")
  .set("cookie", session)
  .send({ doorId: 2, qty: 3 });

expect(res.status).toBe(201);
expect(res.headers.location).toMatch(/\/api\/orders\/\d+/);

// and the effect, read back the same way a client would
const read = await request(app).get(res.headers.location).set("cookie", session);
expect(read.body.qty).toBe(3);

And the seam with somebody else

For a service you do not own, a real call in the test suite is slow, flaky and rate-limited. The usual answer is a recorded interaction — a fixture captured from the real API once — plus a contract test that runs occasionally against the real thing and fails loudly when their response changes shape. That combination keeps the fast tests fast and still notices the day the provider renames a field.

Practice