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.
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.
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.
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.
Related
- Back to Unit vs Integration vs E2E Mapping
- Testing business rules without the UI — extracting the inside.
- Mapping user journeys to test layers — the same decision one level up.
- When to skip integration tests in favor of unit tests — when the pipeline tier earns less than it costs.