Testing & QA

Testing APIs: fixtures, test databases, and contract tests

Testing an HTTP API well means being deliberate about three separate things — what data exists before the test runs, what infrastructure the test actually hits, and whether two independently-deployed services still agree on their shared contract.

Advanced

3 min read

Fixtures: known, controlled data instead of whatever happens to be in the database

// Without a fixture — this test's outcome depends on whatever's already in the database,
// which might be empty, might have leftover data from a previous test run, might have
// nothing matching the query at all
test("GET /orders returns the user's orders", async () => {
  const res = await request(app).get("/orders?userId=42");
  expect(res.body.length).toBeGreaterThan(0); // a weak assertion, forced by not knowing what's actually there
});
 
// With a fixture — the test controls exactly what exists before it runs
beforeEach(async () => {
  await db.orders.create({ id: 1, userId: 42, total: 99.99 });
  await db.orders.create({ id: 2, userId: 99, total: 15.00 }); // a different user's order — should NOT show up
});
 
test("GET /orders returns only the requesting user's orders", async () => {
  const res = await request(app).get("/orders?userId=42");
  expect(res.body).toEqual([{ id: 1, userId: 42, total: 99.99 }]); // a real, specific, verifiable assertion
});

A fixture is data deliberately set up before a test runs, so the test knows exactly what should come back — turning a vague "some orders exist" assertion (forced by not knowing what's actually in the database) into a precise, specific one. The second test here is also actively checking something the first one couldn't: that another user's order is correctly excluded, a real authorization-adjacent behavior that only becomes testable once the starting data is fully known and controlled.

Test databases: real database behavior, without touching production data

beforeAll(async () => {
  testDb = await createTestDatabase(); // a real, disposable database — often in-memory or a throwaway container
});
 
afterEach(async () => {
  await testDb.truncateAllTables(); // reset to empty between tests — no leaked state, the flaky-tests lesson's fix
});
 
afterAll(async () => {
  await testDb.destroy();
});

A real (if disposable) test database gives a test genuine database behavior — real constraints, real query semantics, things an in-memory fake might not perfectly replicate — while staying completely isolated from production data and from other tests, provided it's reset between runs. This sits in the same place the integration-tests lesson already covered: a fake works for many purposes, but when the actual behavior of a real database engine is what's being verified, a real (test) instance of it is worth the added setup cost.

Contract tests: catching drift between two services that deploy independently

// The CONSUMER (frontend) records what shape of response it actually expects
const expectedContract = {
  request: { method: "GET", path: "/users/42" },
  response: { status: 200, body: { id: 42, email: expect.any(String), name: expect.any(String) } },
};
 
// The PROVIDER's own test suite verifies its real API still matches every consumer's recorded contract
test("provider satisfies the frontend's recorded contract", async () => {
  const res = await request(providerApp).get("/users/42");
  expect(res.status).toBe(expectedContract.response.status);
  expect(res.body).toMatchObject(expectedContract.response.body);
});

When a frontend and backend (or two backend services) are built and deployed by different teams, on different schedules, an integration test that spins up both together isn't always practical — but the two sides can still silently drift apart: the backend renames a field, and the frontend, deployed separately, breaks in production despite every one of its own tests passing (against a stub reflecting the old shape). A contract test formalizes the expected shape of the interaction — request and response — and verifies each side against that shared, recorded contract independently, catching the exact category of "each side tests fine alone, but they'd break if actually connected right now" bug that plain unit tests miss and a shared integration environment isn't always available to catch.

Further reading

Check your understanding

A quick comprehension check — not tracked, not graded, just for you.

1. What problem does a fixture solve when testing an API endpoint?

2. Why use a real (disposable) test database instead of a fake for some API tests?

3. What specific problem does a contract test catch that plain unit and integration tests can't?