You've built real guarantees over the last five modules. This module makes them durable by testing them, following the testing strategy deep dive: weight toward integration tests against a real database, test behavior not implementation, and pin the things that would actually hurt if they broke.
Goal
- Integration tests that drive your real HTTP handlers against a real Postgres.
- Tests that prove the two guarantees that matter: idempotent create, and the authorization/IDOR check.
- A unit test for the one piece of genuinely tricky pure logic (cursor encode/decode).
- A test run that builds its schema from your real migrations.
Step 1: A real database for tests
The deep dive is firm about this: mock the database and you can only ever catch bugs you already imagined. The bugs that matter — a constraint you forgot, a migration that fails on data, a query that's wrong — only show up against a real Postgres. So tests get one.
Spin up an ephemeral Postgres for the test run
A throwaway container (Testcontainers, or a
docker runin your test setup). It dies when the run ends, so tests never pollute your dev database.Build the schema from your real migrations
Run
migrate upagainst the test database in global setup. Now a broken migration fails here, in CI, exactly as the deep dive's disaster story warns — not halfway through a production deploy.Isolate each test
Truncate the tables between tests (fast, and tests real commit behavior). Each test seeds only the rows it needs.
// test/setup.ts (run once before the suite)
beforeAll(async () => {
await runMigrations(testDatabaseUrl); // same migration files as prod
});
beforeEach(async () => {
await pool.query("truncate users, snippets, idempotency_keys restart identity cascade");
});Step 2: Test behavior through the real handler
The deep dive's "test behavior, not implementation" rule in practice: drive the actual HTTP app (most frameworks let you fetch the app in-process without a network port) and assert on the response and the resulting database state — never on which internal function was called.
test("creates a snippet and returns it", async () => {
const res = await app.request("/snippets", {
method: "POST",
headers: { "Idempotency-Key": crypto.randomUUID() },
body: JSON.stringify({ body: "console.log(1)", isPublic: true }),
// (with an authenticated session — see Step 4)
});
expect(res.status).toBe(201);
const json = await res.json();
expect(json.publicId).toMatch(/^[\w-]{7}$/);
// assert the real effect: it's actually in the database
const row = await getByPublicId(json.publicId);
expect(row.body).toBe("console.log(1)");
});This test survives any refactor of the handler's internals — exactly the property the deep dive says a good test must have.
Step 3: Prove the guarantees that matter
Coverage of getters is worthless; coverage of your invariants is everything. Write the tests that would have caught the bugs you most fear.
test("a repeated idempotency key creates exactly one snippet", async () => {
const key = crypto.randomUUID();
const body = JSON.stringify({ body: "x", isPublic: true });
const r1 = await app.request("/snippets", { method: "POST", headers: { "Idempotency-Key": key }, body });
const r2 = await app.request("/snippets", { method: "POST", headers: { "Idempotency-Key": key }, body });
expect(r1.status).toBe(201);
expect(r2.status).toBe(200);
expect((await r1.json()).publicId).toBe((await r2.json()).publicId); // SAME snippet
const { rows } = await pool.query("select count(*) from snippets");
expect(Number(rows[0].count)).toBe(1); // proof: not two
});
test("another user cannot read or delete a private snippet (IDOR)", async () => {
const a = await signUpAndLogin("a@x.com");
const snippet = await createSnippetAs(a, { isPublic: false });
const b = await signUpAndLogin("b@x.com");
const read = await app.request(`/s/${snippet.publicId}`, { headers: b.authHeaders });
const del = await app.request(`/snippets/${snippet.publicId}`, { method: "DELETE", headers: b.authHeaders });
expect(read.status).toBe(404); // 404, not 403 — and not the data
expect(del.status).toBe(404);
});These two tests are the heart of the suite
Everything else is supporting cast. The idempotency test proves money/data can't be double-created on a retry; the IDOR test proves one user can't reach another's private data. If you wrote only these two, you'd have caught the two most serious classes of bug a service like this ships. That's what "aim coverage at where a bug would hurt" means concretely.
Step 4: Unit-test the genuinely tricky bit
Most of your code is orchestration best tested through the handler. But the cursor encode/decode from Module 3 is pure logic with edge cases (base64, the created_at,id tuple, malformed input) — exactly where the deep dive says a focused unit test beats an integration test.
test("cursor round-trips and rejects garbage", () => {
const row = { created_at: new Date("2026-05-27T10:00:00Z"), id: 42 };
expect(decodeCursor(encodeCursor(row))).toEqual({ createdAt: row.created_at, id: 42 });
expect(() => decodeCursor("not-a-cursor")).toThrow(); // malformed input is rejected, not trusted
});Step 5: Make the test run the gate
Add "test": "vitest run" (or your runner) and make CI run it on every push (Module 7 wires the CI itself). The contract: no merge with a red suite. That single rule is what turns the tests from decoration into the thing that lets you change code without fear.
What NOT to test (so the suite stays trustworthy)
Don't test that pg runs SQL, that Hono routes, or the exact wording of an error message. Don't chase a coverage percentage by testing trivial getters. The deep dive's warning applies directly: a bloated suite full of brittle, low-value tests gets ignored the same way a smoke alarm wired to the light switch does. A small suite you trust beats a huge one you've learned to re-run until green.
Acceptance check
npm test
# → green, AND it spun up a real Postgres, ran your migrations against it,
# and the idempotency + IDOR tests passed.
# prove the suite has teeth: temporarily break the authorize check
# (let any user read private snippets) and re-run → the IDOR test goes red.
# Revert. A test that never fails when you break the thing it guards is not a test.You're done when the suite runs against a real migrated database and the idempotency and IDOR tests both pass — and you've confirmed the IDOR test actually fails when you break the check. Commit it.
What you just internalised
Tests exist to let you change code without fear, and for a backend that means integration tests against a real database, weighted toward the invariants that would hurt if they broke — here, idempotent create and the IDOR check. Test behavior through the real handler so refactors don't break the suite, unit-test only the genuinely tricky pure logic, build the test schema from real migrations so a bad one fails in CI, and keep the suite small enough to trust.
Comments
Loading comments…