● David R. Longnecker
Chapter 15 · Architecture & Practice

Automated Testing

The honest reason to write tests isn't "best practice" — it's that you want to change code six months from now without manually re-clicking through every screen to make sure you didn't break something. A good test suite is a fast, repeatable substitute for that manual check. This chapter covers the two workhorse test types you'll use constantly, and one honest opinion about mocking.

Why automated tests exist #

Without tests, "does this still work" can only be answered by a human clicking through the app. That doesn't scale: it's slow, it's easy to forget a corner case, and it has to be redone after every change. An assertion is a machine-checkable version of "does this still work" for one specific behavior. A test runner finds every test file in your project, runs every assertion in it, and tells you exactly which ones failed — in seconds, not minutes of clicking. This course uses Vitest, a fast test runner built on the same tooling as modern JavaScript build tools.

Unit tests: pure functions in isolation #

The cheapest, fastest tests to write are for pure functions — functions whose output depends only on their input, with no database, no network, no hidden state. Take a small utility from a todo-list app that decides whether a task is overdue:

the function under test
// src/utils/is-overdue.ts
export function isOverdue(dueDate: Date, now: Date): boolean {
  return dueDate.getTime() < now.getTime();
}
a unit test for it
// src/utils/is-overdue.test.ts
import { describe, it, expect } from "vitest";
import { isOverdue } from "./is-overdue";

describe("isOverdue", () => {
  it("returns true when the due date is in the past", () => {
    const due = new Date("2026-01-01");
    const now = new Date("2026-01-02");
    expect(isOverdue(due, now)).toBe(true);
  });

  it("returns false when the due date is in the future", () => {
    const due = new Date("2026-06-01");
    const now = new Date("2026-01-02");
    expect(isOverdue(due, now)).toBe(false);
  });
});

describe groups related tests under a label, it (or its alias test) declares one behavior in plain English, and expect(...).toBe(...) is the assertion. Notice that now is passed in as a parameter instead of the function calling new Date() internally — that's what makes it pure, and it's exactly what makes it trivially testable. A function that silently reads the current time can't be pinned down to a single expected answer.

Integration tests: a real, temporary database #

Unit tests can't tell you whether a note actually survives being written to and read back from SQL storage. For that you need an integration test: instead of faking the database, spin up a real one — just a temporary file on disk that gets deleted afterward.

an integration test against a real, temporary SQLite file
// src/notes/notes-repo.test.ts
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import Database from "better-sqlite3";
import { NotesRepo } from "./notes-repo";

describe("NotesRepo", () => {
  let dbPath: string;
  let repo: NotesRepo;

  beforeEach(() => {
    const dir = mkdtempSync(path.join(tmpdir(), "notes-test-"));
    dbPath = path.join(dir, "test.db");
    repo = new NotesRepo(new Database(dbPath));
    repo.migrate();
  });

  afterEach(() => {
    rmSync(path.dirname(dbPath), { recursive: true, force: true });
  });

  it("persists a note across a write and a read", () => {
    const id = repo.create({ title: "Groceries", body: "Milk, eggs" });
    const saved = repo.getById(id);
    expect(saved?.title).toBe("Groceries");
  });
});

beforeEach and afterEach are fixture hooks: setup that runs before every test in the block, and cleanup that runs after every test, whether it passed or failed. Here the fixture creates a fresh, empty database file per test and deletes it afterward, so tests never leak state into each other and never leave junk files behind. mkdtempSync and tmpdir are Node's built-in way to get a real, unique, automatically-namespaced temporary directory (so parallel test runs never collide on the same path), and path.join builds a file path in a way that works on both Windows and Unix-style systems without you hand-assembling slashes.

A real file on disk is also the slowest option available: SQLite supports an in-memory database (new Database(':memory:')) that skips disk I/O entirely and is meaningfully faster, which matters once a test suite has hundreds of these. The trade-off is realism — an in-memory database can't catch bugs specific to how your code interacts with the filesystem, like file permissions or concurrent access from another process. This chapter uses a real temp file because that trade-off leans toward realism for teaching purposes; a large test suite under real time pressure often makes the opposite choice for its bulk of integration tests, reserving on-disk tests for the handful that specifically need them.

A test pyramid: many unit tests at the base, fewer integration tests in the middle, still fewer end-to-end tests at the top End-to-end a few, slow, whole app Integration tests real temp database, fewer, slower Unit tests pure functions, many, fast, cheap to write
Most of your tests should be cheap unit tests. Integration tests cost more to run but catch what units can't: real persistence.

The top tier, end-to-end tests, drives the whole running application the way a real user would — a browser automation tool clicking through an actual UI against an actual server, rather than calling functions or repository methods directly. They catch the fewest bugs per test written and are the slowest and most brittle to maintain, which is exactly why the pyramid shape recommends having the fewest of them. This chapter doesn't cover writing one; the same describe/it/expect vocabulary applies, just driving a browser instead of calling a function.

A short, honest note on mocking #

A mock replaces a real dependency with a fake one so a test can run without it. Mocks earn their keep at true boundaries: a call to a third-party weather API, an email provider, anything slow, flaky, or billed per request. You don't want a test suite that fails because an external API happened to be down.

Mocks are risky when you use them to avoid testing your own code's real behavior. Mocking your database layer to test your database layer proves nothing except that your mock does what you told it to do — it can't catch a wrong SQL column name, a broken foreign key, or a migration that silently fails. Prefer a real, temporary database in tests over a mocked one, precisely because the bugs that matter most in a data layer are the ones only a real database will surface.

Rule of thumb

Mock the edges of your system (network calls, clocks, external services). Don't mock the middle (your own database queries, your own business logic). If you're not sure which one you're doing, ask whether the mock could hide a bug that would actually happen in production — if yes, it's the middle.

Try it

Write a unit test for a pure function that formats a relative time string ("3 days overdue," "due today"). Then sketch — you don't have to run it — an integration test that creates a todo in a temporary SQLite file, marks it done, and asserts the change persisted after re-reading it from the database.