0 / 91
Week 8 · Day 55 of 91

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. Use PMP-001.
  • The current time. Asserting a record's created_at equals new Date() fails on the milliseconds. Assert a range, or that the field is simply present.
  • Leftover rows. Asserting rows.length === 1 passes 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

  1. Install vitest and supertest, add the test script, and create the maintenance_test database with your migrations applied.
  2. Add the DATABASE_URL guard clause so tests refuse to run outside a _test database.
  3. Extract your permission rules into pure functions in src/services/permissions.ts if they are still embedded in middleware or routes.
  4. Write unit tests covering every cell of Day 52's permission matrix — allowed and denied, both directions, including admin-over-another-user's-record.
  5. Split app.ts from server.ts if you have not already, so tests can import the app.
  6. Write tests/fixtures.ts with createUser and a createEquipment helper, using fixed values.
  7. Write integration tests for POST /equipment: success, 401 anonymous, 400 invalid body, 409 duplicate serial. Truncate in beforeEach.
  8. Run the suite twice in a row without recreating the database. Identical results, or your cleanup is wrong.
  9. 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 TRUNCATE and 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.

  1. This lesson imported describe, it, and expect explicitly. Vitest also offers a globals option. Find it, read what it does, and note whether you consider the explicit import worth keeping and why.
  2. 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

  1. 0–5 min Recall

    Without notes, state yesterday’s main idea and one unresolved question.

  2. 5–20 min Learn

    Read only the listed concept notes and official reference sections needed today.

  3. 20–48 min Build

    Test permission logic as units and equipment creation against a test database.

  4. 48–55 min Explain and verify

    Run the result, inspect evidence, and explain the data/control flow in your own words.

  5. 55–60 min Quiz and commit

    Complete the quiz, record one lesson, and commit the verified change when applicable.

What to hand in

Deliverable

Automated tests with repeatable setup and cleanup.

Working with AI today

AI as pair programmer

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

Q1 What does an integration test verify?
Q2 Which result best proves today’s work is complete?
Q3 Before accepting an AI-generated code change, what should you do?

Explain-back gate

Pass the quiz above to unlock completion.

Quiz + explain-back checks required.