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.
| Include | Substitute |
|---|---|
| your routing, validation and serialisation | a third-party payment provider |
| the real database, or a close relative | an email or SMS service |
| the real query layer and migrations | the clock, and randomness |
| authentication and authorisation | anything 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.
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.
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.