Choosing a Tier for API Route Tests

A server route is a small pipeline: parse, authorise, validate, decide, persist, respond. Each stage can fail in a different way, and each has a different cheapest test. Teams usually pick one tier for all of it — either a unit test that mocks the framework and verifies nothing about routing, or a full integration test through the HTTP stack for every validation case — and both choices are expensive in their own direction. This guide covers splitting a route by stage, choosing a tier per stage with a rule that two people apply identically, and keeping the expensive tests few. It sits under unit vs integration vs E2E mapping.

Root Cause Analysis

Route tests go wrong at both extremes. Mocking the framework entirely — constructing a fake request object and calling the handler function — verifies the decision logic but nothing about how the route is reached: not the method, not the path, not the middleware order, not the serialisation. Routes break in exactly those places, and this style of test cannot see any of them.

Testing everything through the full HTTP stack has the opposite problem. It verifies the pipeline properly but pays for it on every case, so the thirty validation cases each cost a server round trip and the suite slows to the point where people stop adding cases. The thirty-first case, the interesting one, does not get written.

What resolves this is noticing that the route has an inside and an edges. The inside — what the request means and what the response should be — is a decision, testable as a pure function. The edges — routing, middleware, serialisation, status codes, the database — are integrations, and each needs to be verified once rather than thirty times.

The stages of a route and the tier each belongs to Routing, middleware and serialisation are verified once at the integration tier, validation and business decisions are covered exhaustively at the unit tier, and persistence is checked against a real database in a small number of tests. route match integration auth middleware integration validation unit decision unit persistence real database serialise integration many cases in the amber stages, a handful in the others 30+ unit cases — validation and decisions 4 integration tests
The expensive stages are verified once each; the cheap ones carry the case volume.

Reproducible Setup

Structure the route so its inside is extractable. This is the change that makes everything else possible, and it usually improves the production code too.

// src/routes/refunds.ts — the edge: framework-shaped, thin
import { Router } from 'express';
import { decideRefund } from '../domain/refunds';
import { refundRequestSchema } from '../domain/schema';
import { refundRepository } from '../db/refunds';

export const refundsRouter = Router();

refundsRouter.post('/orders/:id/refunds', requireAuth, async (req, res) => {
  const parsed = refundRequestSchema.safeParse(req.body);
  if (!parsed.success) return res.status(422).json({ errors: parsed.error.issues });

  const order = await refundRepository.findOrder(req.params.id);
  const outcome = decideRefund(order, parsed.data, req.user);       // the inside: pure

  if (!outcome.allowed) return res.status(outcome.status).json({ error: outcome.reason });

  const refund = await refundRepository.create(outcome.refund);
  return res.status(201).json(refund);
});
// src/domain/refunds.ts — the inside: no framework, no I/O
export function decideRefund(order: Order, request: RefundRequest, user: User): RefundOutcome {
  if (order.status === 'cancelled') return { allowed: false, status: 409, reason: 'order_cancelled' };
  if (request.amountPence > order.refundablePence) return { allowed: false, status: 422, reason: 'exceeds_refundable' };
  if (!user.permissions.includes('refunds:write')) return { allowed: false, status: 403, reason: 'forbidden' };
  return { allowed: true, refund: { orderId: order.id, amountPence: request.amountPence, by: user.id } };
}

Implementation

Step 1 — Put every case of the decision at the unit tier. This is where the case volume belongs: no server, no database, milliseconds per case.

// src/domain/refunds.test.ts
import { test, expect } from 'vitest';
import { decideRefund } from './refunds';
import { anOrder, aRefundRequest, aUser } from '../../test/builders';

test.each([
  ['a cancelled order', anOrder({ status: 'cancelled' }), aRefundRequest(), 409, 'order_cancelled'],
  ['more than refundable', anOrder({ refundablePence: 500 }), aRefundRequest({ amountPence: 600 }), 422, 'exceeds_refundable'],
  ['exactly refundable', anOrder({ refundablePence: 500 }), aRefundRequest({ amountPence: 500 }), 201, null],
])('refunding %s', (_label, order, request, status, reason) => {
  const outcome = decideRefund(order, request, aUser({ permissions: ['refunds:write'] }));
  if (reason) expect(outcome).toMatchObject({ allowed: false, status, reason });
  else expect(outcome.allowed).toBe(true);
});

Step 2 — Verify the pipeline once per distinct behaviour, not per case. One test that a validation failure becomes a 422, one that an unauthenticated request becomes a 401 — not one per validation rule.

// src/routes/refunds.integration.test.ts
import request from 'supertest';
import { app } from '../app';

test('rejects an unauthenticated request before reaching the handler', async () => {
  await request(app).post('/orders/o1/refunds').send({ amountPence: 100 }).expect(401);
});

test('turns a schema failure into a 422 with issue details', async () => {
  const res = await request(app)
    .post('/orders/o1/refunds')
    .set('authorization', `Bearer ${testToken()}`)
    .send({ amountPence: 'lots' })
    .expect(422);
  expect(res.body.errors[0]).toMatchObject({ path: ['amountPence'] });
});

test('returns 201 and the created refund on the happy path', async () => {
  const res = await request(app)
    .post(`/orders/${seeded.order.id}/refunds`)
    .set('authorization', `Bearer ${testToken()}`)
    .send({ amountPence: 500 })
    .expect(201);
  expect(res.body).toMatchObject({ orderId: seeded.order.id, amountPence: 500 });
});

Step 3 — Use a real database for persistence, and only for persistence. What a mocked repository cannot check is the constraint, the transaction and the query — so those get a real one, in a small number of tests.

// src/db/refunds.integration.test.ts
test('rejects a second refund that would exceed the order total', async () => {
  await refundRepository.create({ orderId: order.id, amountPence: 400, by: user.id });
  await expect(
    refundRepository.create({ orderId: order.id, amountPence: 200, by: user.id }),
  ).rejects.toThrow(/refund_total_exceeds_order/);       // a database constraint, not application logic
});

Step 4 — Do not repeat any of it at the end-to-end tier. A browser test that exercises the refund form should assert that a refund appears in the interface, and nothing about status codes or validation messages.

Case counts and cost per tier for one route Thirty-two unit cases run in under a second, four integration tests take a few seconds, two database tests take a little longer, and no end-to-end test repeats any of it. One route, tests by tier unit — 32 cases 0.4s pipeline — 4 tests 2.1s database — 2 tests 3.4s end-to-end — 0 covered by the journey test, not per route
Thirty-two cases at the tier that costs milliseconds; six at the tiers that cost seconds.

Step 5 — Keep a single template so every route is tested the same way. Consistency here pays repeatedly: a reviewer can tell at a glance whether a new route has the right shape of tests.

src/routes/refunds.ts                     the edge
src/routes/refunds.integration.test.ts    4 pipeline tests
src/domain/refunds.ts                     the inside
src/domain/refunds.test.ts                every case
src/db/refunds.integration.test.ts        constraints and queries

Step 6 — Let the shape of the tests tell you when a route is doing too much. A route whose integration file needs a dozen tests usually has decisions embedded in it that belong in the domain layer.

Verification

Verify the split by checking that the decision function has no framework or I/O imports — the property that makes the unit tier viable.

grep -nE "from '(express|next|node:fs|\.\./db)" src/domain/refunds.ts
# (no output — the inside is pure)

Then verify the integration tests are about the pipeline rather than the rules. A count of assertions on status codes versus on business reasons is a quick proxy.

grep -cE "expect\(4[0-9][0-9]\)|\.expect\([0-9]{3}\)" src/routes/refunds.integration.test.ts
# 4     ← one per pipeline behaviour
grep -cE "exceeds_refundable|order_cancelled" src/routes/refunds.integration.test.ts
# 0     ← the reasons are covered at the unit tier

Finally, verify that a change to a business rule fails only the unit tests. If altering the refundable calculation turns the integration file red as well, the rule has leaked out of the domain layer and back into the route.

Two signals that logic has leaked into the route If a business rule change fails the integration tests, or if the integration file needs a new test for each rule, the decision logic is in the route rather than in the domain layer. rule change fails integration the rule is evaluated inside the handler extract it to the domain a test per rule at the edge the integration file grows with every business case the same diagnosis
Both signals point at the same fix, and both are visible from the test files alone.

Troubleshooting

Symptom: the handler cannot be tested without the framework. Diagnosis: the decision reads from the request object directly, so it is coupled to the transport. Fix: parse at the edge and pass plain values inward, as in the setup — the handler should receive a typed request object of your own, not the framework’s.

Symptom: integration tests are slow because each starts a server. Diagnosis: the app is being constructed per test rather than per file. Fix: build the app once in a module-scoped setup and reuse it; most frameworks support in-process request injection, which avoids a real socket entirely.

Symptom: database tests are flaky under parallelism. Diagnosis: tests share rows. Fix: give each worker its own schema or database, or wrap each test in a transaction that is rolled back — both are covered in seeding a test database for integration tests.

Symptom: the route works in tests and fails in production on the path. Diagnosis: the integration tests call the handler directly rather than through the router, so the path and method are never exercised. Fix: always send a real request through the assembled app, even in-process — that is the specific thing this tier exists to verify.

FAQ

Should I mock the database in route tests?

For the pipeline tests, yes — you are checking status codes and serialisation, and a real database adds time without adding signal. For the persistence tests, no: the whole point is the constraints and queries a mock cannot reproduce. Keeping those as separate files makes the distinction visible.

What about routes that are only a thin pass-through?

Then the integration tests are the only ones worth writing, because there is no decision to extract. A route that reads a record and returns it needs one happy-path test and one not-found test, and nothing else. Do not manufacture a domain function for the sake of symmetry.

Does this apply to serverless and edge functions?

The split does; the mechanics of the integration tier change, since you are invoking a handler with a platform-shaped event rather than an HTTP request. Test the inside identically, and verify the edge by invoking the handler with a realistic event object plus one genuinely deployed smoke check.

How does this interact with contract testing?

Well, and they complement each other. The pipeline tests verify your route behaves as your code expects; a contract test verifies it behaves as its consumers expect, which is a different claim and is covered in contract testing.