Without notes, state yesterday’s main idea and one unresolved question.
Backend unit and integration tests
Backend Architecture, Authentication, and Security
Objective
Verify rules and API/database behavior at appropriate levels.
Authentication identifies the operator; authorization checks which controls that operator is permitted to activate.
- unit versus integration
- test setup and cleanup
- deterministic fixtures
Why this matters
You have spent four days adding rules: who may delete equipment, who may edit whose records, what
happens when a serial number repeats. Every one of those rules is currently verified by you,
typing curl, remembering to try the denied case. That does not survive next week.
Today you convert those manual checks into tests that run in seconds. The permission rules become
unit tests — fast, no database. Equipment creation becomes an integration test against a
real test database. By the end, npx vitest run tells you in one command whether Week 8 still
works, including the cases where the answer must be "no".
Unit versus integration
A unit test exercises one piece of logic in isolation, with no network, no database, no filesystem. It is fast (milliseconds), it always gives the same answer, and when it fails you know exactly which function is wrong.
An integration test exercises multiple real components working together — your route, your
service, your repository, and an actual PostgreSQL database. It is slower and needs setup, and it
catches an entirely different class of bug: a column you renamed, a constraint that fires, a
middleware you forgot to register. No amount of unit testing finds a missing router.use.
You need both, in different proportions. Many fast unit tests around rules; fewer integration tests around the paths that matter most.
| Unit | Integration | |
|---|---|---|
| Scope | one function | route → service → repository → database |
| Speed | milliseconds | tens to hundreds of milliseconds |
| Catches | wrong logic | wrong wiring, wrong SQL, wrong schema |
| Misses | broken wiring | which layer is at fault |
Component test versus powered board test
You test a resistor on a bench meter: one component, isolated, an unambiguous reading. Then you power the assembled board and probe it: slower, harder to set up, and the only way to catch the reversed connector, the missing jumper, and the trace you never routed. Every component passing in isolation does not make an assembly work. A unit test is the meter; an integration test is the powered board.
Tasting the sauce, then eating the meal
A cook tastes each sauce on its own — quick, isolated, easy to correct. Then someone eats the finished plate, which is the only way to find out the sauce was poured over the wrong dish.
Testable code is code you already wrote correctly
This is where Day 50 pays off. A permission rule buried inside a route handler can only be tested
by faking req and res. The same rule as a plain function is testable in one line:
// src/services/permissions.ts
export type Actor = { id: number; role: 'admin' | 'technician' };
export function canDeleteEquipment(actor: Actor): boolean {
return actor.role === 'admin';
}
export function canEditMaintenance(
actor: Actor,
record: { created_by: number },
): boolean {
return actor.role === 'admin' || record.created_by === actor.id;
}
Pure input, pure output, no HTTP, no database. If something is hard to test, that is usually a design message, not a testing problem.
For integration tests, one structural change matters: export your Express app separately from
the code that calls listen. A test needs the app, not a bound port.
// src/app.ts
export const app = express();
// ... middleware and routes
// src/server.ts
import { app } from './app.js';
app.listen(3000, () => console.log('listening on 3000'));
Setup, cleanup, and why order-independence matters
Integration tests write to a database, and that residue is the classic source of tests that pass alone and fail together. Two rules:
Use a separate test database. Never your development one. A test that truncates tables will happily destroy the data you spent Week 7 seeding.
createdb maintenance_test
psql maintenance_test -f migrations/001_init.sql
psql maintenance_test -f migrations/003_users_auth.sql
Reset state before each test, not after. If cleanup runs only afterwards, a crashed test leaves debris for the next one. Cleaning first means every test starts from a known state regardless of what happened before.
TRUNCATE sessions, maintenance_records, equipment, users RESTART IDENTITY CASCADE;
RESTART IDENTITY resets auto-generated IDs so the first row is always 1; CASCADE handles the
foreign keys from Day 43.
Point tests at the test database, and prove it
A TRUNCATE aimed at your development database deletes everything, immediately, with no undo.
Read DATABASE_URL from the environment and refuse to run if it does not end in _test:
if (!process.env.DATABASE_URL?.endsWith('_test')) {
throw new Error('refusing to run tests outside a _test database');
}
One guard clause, permanent protection.
Deterministic fixtures
A fixture is the known starting data a test needs. Deterministic means it produces the same result every run, on every machine, forever.
Three things break determinism, and all three appear in beginner tests:
- Random values.
serial: Math.random()makes a failure unreproducible. UsePMP-001. - The current time. Asserting a record's
created_atequalsnew Date()fails on the milliseconds. Assert a range, or that the field is simply present. - Leftover rows. Asserting
rows.length === 1passes on a clean database and fails on the second run. Truncate first.
Build fixtures with small helper functions so each test states only what it cares about:
// tests/fixtures.ts
import bcrypt from 'bcrypt';
import { pool } from '../src/db.js';
export async function createUser(role: 'admin' | 'technician', email = `${role}@test.local`) {
const hash = await bcrypt.hash('test-password-1234', 4); // low cost: tests, not production
const { rows } = await pool.query(
'INSERT INTO users (email, password_hash, role) VALUES ($1,$2,$3) RETURNING id, email, role',
[email, hash, role],
);
return rows[0];
}
Cost 4 rather than 12 is deliberate: hashing is slow by design, and a suite that hashes twenty passwords at production cost takes five seconds for no benefit. Slowness protects stored passwords; it is not protecting anything in a throwaway test database.
Walkthrough: both kinds of test
Install the tools:
npm install --save-dev vitest supertest @types/supertest
Add a script to package.json:
"scripts": {
"test": "vitest run"
}
The unit test — no database, no server:
// src/services/permissions.test.ts
import { describe, it, expect } from 'vitest';
import { canDeleteEquipment, canEditMaintenance } from './permissions.js';
const admin = { id: 1, role: 'admin' } as const;
const ana = { id: 2, role: 'technician' } as const;
const bo = { id: 3, role: 'technician' } as const;
describe('canDeleteEquipment', () => {
it('allows an admin', () => {
expect(canDeleteEquipment(admin)).toBe(true);
});
it('denies a technician', () => {
expect(canDeleteEquipment(ana)).toBe(false);
});
});
describe('canEditMaintenance', () => {
it('allows the author', () => {
expect(canEditMaintenance(ana, { created_by: ana.id })).toBe(true);
});
it('denies a different technician', () => {
expect(canEditMaintenance(bo, { created_by: ana.id })).toBe(false);
});
it('allows an admin over anyone', () => {
expect(canEditMaintenance(admin, { created_by: ana.id })).toBe(true);
});
});
The denial tests are the valuable ones. A permission suite that only checks the allowed cases
would pass against a function that returns true unconditionally.
The integration test — real app, real database:
// tests/equipment.test.ts
import { describe, it, expect, beforeEach, afterAll } from 'vitest';
import request from 'supertest';
import { app } from '../src/app.js';
import { pool } from '../src/db.js';
import { createUser } from './fixtures.js';
beforeEach(async () => {
await pool.query(
'TRUNCATE sessions, maintenance_records, equipment, users RESTART IDENTITY CASCADE',
);
});
afterAll(async () => {
await pool.end();
});
async function loginAs(role: 'admin' | 'technician') {
const user = await createUser(role);
const res = await request(app)
.post('/auth/login')
.send({ email: user.email, password: 'test-password-1234' });
return res.headers['set-cookie'];
}
describe('POST /equipment', () => {
it('creates equipment for a logged-in technician', async () => {
const cookie = await loginAs('technician');
const res = await request(app)
.post('/equipment')
.set('Cookie', cookie)
.send({ name: 'Pump 3', serialNumber: 'PMP-003' });
expect(res.status).toBe(201);
expect(res.body.serial_number).toBe('PMP-003');
expect(res.body.password_hash).toBeUndefined();
});
it('rejects an anonymous request with 401', async () => {
const res = await request(app).post('/equipment').send({ name: 'Pump 3' });
expect(res.status).toBe(401);
});
it('rejects a duplicate serial number with 409', async () => {
const cookie = await loginAs('technician');
const body = { name: 'Pump 3', serialNumber: 'PMP-003' };
await request(app).post('/equipment').set('Cookie', cookie).send(body);
const res = await request(app).post('/equipment').set('Cookie', cookie).send(body);
expect(res.status).toBe(409);
});
});
Run them, pointing at the test database:
DATABASE_URL=postgres://localhost/maintenance_test npm test
✓ src/services/permissions.test.ts (5)
✓ tests/equipment.test.ts (3)
Test Files 2 passed (2)
Tests 8 passed (8)
Checkpoint
Break something on purpose: remove requireAuth from the router and re-run. The 401 test must
fail. A test that cannot fail is not testing anything, and confirming it fails is the only proof
you have that it works.
Your turn
- Install
vitestandsupertest, add thetestscript, and create themaintenance_testdatabase with your migrations applied. - Add the
DATABASE_URLguard clause so tests refuse to run outside a_testdatabase. - Extract your permission rules into pure functions in
src/services/permissions.tsif they are still embedded in middleware or routes. - Write unit tests covering every cell of Day 52's permission matrix — allowed and denied, both directions, including admin-over-another-user's-record.
- Split
app.tsfromserver.tsif you have not already, so tests can import the app. - Write
tests/fixtures.tswithcreateUserand acreateEquipmenthelper, using fixed values. - Write integration tests for
POST /equipment: success,401anonymous,400invalid body,409duplicate serial. Truncate inbeforeEach. - Run the suite twice in a row without recreating the database. Identical results, or your cleanup is wrong.
- Do the checkpoint sabotage: remove a permission check, confirm a test goes red, restore it, confirm green.
Pair mode — at step 4, before you write any test
"Help me list test cases before implementation. Include success, invalid input, unauthorized, forbidden, conflict, and database failure." Let it enumerate cases — that is where an AI genuinely helps, because forgetting the denied case is the human failure mode. You decide which cases are real for your API and you write the assertions. When it proposes test code, inspect the diff, run it, and confirm each test can actually fail before you keep it. A green suite you did not verify is worse than no suite, because it manufactures confidence.
Common pitfalls
- Testing against the development database. One
TRUNCATEand Week 7's data is gone. Guard clause, always. - Only testing the happy path. Permission code must be tested by what it refuses. Denial tests are the point.
- Tests that depend on each other. If test B needs a row test A created, they fail when run alone or reordered. Each test creates what it needs.
- Asserting on generated IDs or exact timestamps.
expect(res.body.id).toBe(1)breaks the moment ordering changes. Assert shape and relationships, not incidental values. - Forgetting
pool.end(). The process hangs after tests finish, holding an open connection.
Verify it yourself
Open today's reference, the Vitest Getting started guide.
- This lesson imported
describe,it, andexpectexplicitly. Vitest also offers aglobalsoption. Find it, read what it does, and note whether you consider the explicit import worth keeping and why. - Find how Vitest decides which files are tests by default. Do your file names match that pattern? Confirm by checking that the run output lists every file you expect.
Write both answers in your notes. A test suite that silently skips files is a suite that reports success it did not earn.
The hour
- 0–5 min Recall
- 5–20 min Learn
Read only the listed concept notes and official reference sections needed today.
- 20–48 min Build
Test permission logic as units and equipment creation against a test database.
- 48–55 min Explain and verify
Run the result, inspect evidence, and explain the data/control flow in your own words.
- 55–60 min Quiz and commit
Complete the quiz, record one lesson, and commit the verified change when applicable.
What to hand in
Automated tests with repeatable setup and cleanup.
Working with AI today
Define one small task, review the plan, inspect the diff, run checks, and explain every changed section.
Help me list test cases before implementation. Include success, invalid input, unauthorized, forbidden, conflict, and database failure.
References
End-of-day quiz
Explain-back gate
Pass the quiz above to unlock completion.
Quiz + explain-back checks required.