Integration-testing Linkstash
Unit tests check one function. Integration tests check the whole request through routing, auth, and validation. Real supertest tests against a real API.

A unit test can prove intParam(-5, 20, 1, 100) returns 1. It can't prove that when a real client sends GET /links?limit=-5, the request actually makes it through routing, past the auth middleware, into that function, and back out as the right JSON with the right status code. That's a different kind of test, one that exercises the whole path a request takes, not one link in the chain. Node doesn't run in a browser, so unlike the last few lessons, everything here is real code you'd run with npx vitest, not something you can click "run" on.
supertest sends real HTTP requests
supertest wraps your Express app and lets you fire requests at it directly, no app.listen(), no real port, no network round trip, just the actual middleware stack running in process:
import request from "supertest";
import { createApp } from "./app.ts";
import { openDb } from "./db.ts";
const db = openDb();
const app = createApp(db);
const res = await request(app).post("/auth/register").send({
email: "alex@example.com",
password: "correct horse battery",
});
console.log(res.status); // 201
console.log(res.body); // { id: 1, email: "alex@example.com" }That's a real request, through real Express routing, real Zod validation, and the real handler in app.ts, from the Backend & APIs series. Nothing about the app knows it's being tested.
Why createApp(db) is an app factory
Look closely at that snippet: createApp(db) builds a brand-new Express app, wired to a database you pass in, instead of exporting one shared app instance. That's not incidental. It's what makes running hundreds of tests in isolation possible at all.
describe("Linkstash API", () => {
let db: Database;
let app: Express;
beforeEach(() => { db = openDb(); app = createApp(db); });
// ...
});beforeEach runs before every single test, building a fresh app on top of a fresh database. If app were a single shared instance created once at the top of the file, one test's leftover data (a registered user, a saved link) would bleed into the next test, and the order tests happen to run in would start mattering. With a factory, test 47 has no idea test 12 ever ran. That's the property you want: any test can fail in isolation, and passing has to mean it actually works, not that it got lucky with what ran before it.
Real assertions from the suite
These are quoted directly from app.test.ts. Each one checks a full request-response cycle, not an internal function.
Unauthenticated access is rejected, on both routes that need a token:
it("rejects unauthenticated access to links with 401", async () => {
expect((await request(app).get("/links")).status).toBe(401);
expect((await request(app).post("/links").send({ url: "https://a.example", title: "A" })).status).toBe(401);
});Bad input gets a structured error, not a crash:
it("rejects invalid input with 400 and a field list", async () => {
const token = await registerAndLogin(app, "alex@example.com");
const res = await request(app).post("/links").set("Authorization", `Bearer ${token}`).send({ url: "not-a-url", title: "" });
expect(res.status).toBe(400);
expect(res.body.error).toBe("ValidationError");
expect(res.body.fields.map((f: { path: string }) => f.path).sort()).toEqual(["title", "url"]);
});Users only ever see their own data:
it("creates and lists links for the authenticated user only", async () => {
const alex = await registerAndLogin(app, "alex@example.com");
const maya = await registerAndLogin(app, "maya@example.com");
await request(app).post("/links").set("Authorization", `Bearer ${alex}`)
.send({ url: "https://logicdecode.com", title: "Logic Decode" });
const mine = await request(app).get("/links").set("Authorization", `Bearer ${alex}`);
expect(mine.body.data).toHaveLength(1);
const theirs = await request(app).get("/links").set("Authorization", `Bearer ${maya}`);
expect(theirs.body.data).toHaveLength(0);
});Two real users, two real tokens, one shared database, and the test proves the isolation holds. That's a claim a unit test on listLinks alone can't make, since it says nothing about whether the route actually passes the right user ID through.
GET /boom: a route built to fail
Linkstash has a route that does nothing but throw:
app.get("/boom", async () => {
throw new Error("Deliberate failure for teaching the error handler");
});It exists purely so tests can prove the error-handling middleware works, without needing to find or fake a real failure:
it("returns 404 for an unknown route and 500 through the error handler", async () => {
expect((await request(app).get("/nope")).status).toBe(404);
expect((await request(app).get("/boom")).status).toBe(500);
});Without /boom, testing the 500 path would mean deliberately breaking something else and hoping the failure looks realistic. With it, you get a stable, on-demand 500 any time you need one, and a guarantee that a thrown error becomes a clean { error: "InternalServerError" } response instead of crashing the process or leaking a stack trace to the client. If you've read the error-handling lesson, this is that middleware, under an actual test.
Previous: testing async code without flakes. Next: test databases, fixtures and isolation, where openDb() gets the explanation it deserves.

Written by
Rhythm Bhiwani
Engineer and relentless builder, happiest reverse-engineering hard problems until they click.
Enjoyed this?
Tap the heart to leave some love.
Be the first to react
Comments
Join the conversation.
Loading comments…


