Backend testing that survives contact with a real API — Jest and Supertest
The Jest + Supertest setup I default to for API suites, and the three mistakes that quietly turn a green suite into a lying one.
Frontend test suites get all the attention because they're visible. The API suite is the one that actually catches the bugs that reach production, because it runs closer to where the business logic lives. It's also the one I see teams get subtly wrong most often.
The shape that holds up
Jest for the runner and assertions, Supertest for the HTTP layer, and a real (not mocked) database in a container:
import request from "supertest"
import { app } from "../src/app"
import { resetDb } from "./helpers/db"
beforeEach(async () => {
await resetDb()
})
describe("POST /orders", () => {
it("rejects an order with no line items", async () => {
const res = await request(app)
.post("/orders")
.send({ customerId: "cust_1", items: [] })
expect(res.status).toBe(422)
expect(res.body.error).toMatch(/at least one item/i)
})
})Nothing exotic. The value is in what it's not doing.
Mistake one: mocking the ORM
Mocking the database layer makes tests fast and makes them lie. The bug that actually reaches production is usually a real constraint — a unique index, a cascade delete, a transaction that isn't atomic when you thought it was. None of those exist in a mock. A real Postgres container that resets between tests costs a few seconds per run and catches the bugs that matter.
Mistake two: asserting on status codes alone
expect(res.status).toBe(200) tells you the request didn't crash. It
doesn't tell you the response is right. I've seen a refactor silently drop
a field from every response body while the entire suite stayed green,
because every assertion stopped at the status code. Assert the shape:
expect(res.body).toMatchObject({
id: expect.any(String),
status: "pending",
total: 4200,
})Mistake three: sharing state across tests for speed
Reusing one seeded user across the whole file to save setup time is how you
get a suite where test order matters and nobody notices until someone runs
--runInBand versus parallel and gets different results. Every test seeds
and tears down its own data. It's slower per test and dramatically faster in
practice, because nobody burns an afternoon on a failure that only reproduces
in one execution order.
The one Supertest habit worth adopting
Wrap auth in a helper that returns a ready request, not a token you attach manually every time:
const asAdmin = () => request(app).set("Authorization", `Bearer ${adminToken}`)Every test that forgets to attach a header is a test that's silently checking your 401 handling instead of the thing it claims to test. Centralize it once and that entire class of false positive disappears.