Chapter 9 gave us a real PostgreSQL and proved the tests can reach it. But the moment tests share a real database, they also share its state. In this chapter we reproduce that failure honestly, then fix it once and for all so every database test starts clean.

This chapter is about one failure mode and its cure. Here's the language in plain terms.
Test isolation. The guarantee that each test runs as if it were the only test, unaffected by data any other test left behind. Example: test A inserts a row, test B starts and sees an empty table.
Cross-test leakage. The opposite of isolation: state one test leaves behind that pollutes the next. A real database leaks because it persists, unlike an in-memory Map Jest hands each test file fresh.
Order-dependent / flaky test. A test that passes alone but fails alongside others, because it's polluted by state another test left behind. The classic symptom is green on your machine, red in CI, purely because the ordering shifted.
Truncate. The SQL TRUNCATE statement, which empties a table of all its rows at once, faster than deleting row by row. For instance, TRUNCATE TABLE "urls_demo" wipes every row but leaves the schema in place.
One note on the table you'll see below. is a throwaway teaching table created directly via SQL. It exists only to hold real, persisted rows so the leak is demonstrable, and it won't collide with the real table Prisma introduces in chapter 11.
urls_demoUrlThere is exactly one urlshortener_test database, and every test talks to it through the same connection pool. Rows you insert in one test are still there when the next runs. The database persists, which is the whole reason we use it, and also the whole reason it breaks naive tests. An in-memory Map doesn't have this problem because Jest hands each test file a fresh module; a shared database has no such reset built in.
Without a reset, tests become order-dependent. A test that's correct in isolation starts failing the moment another test runs first and leaves a row behind, and that failure shows up as a flake: green alone, red together, green again when the ordering shifts. A suite that flakes is worse than no suite, because it trains you to shrug at red, and the one time red means something real, you miss it.
So before we put a real Url table behind the app in chapter 11, we have to make database tests deterministic, so that every test starts from a known, clean database no matter what ran before it. The rest of this chapter reproduces the leak honestly, then fixes it once, centrally, for the whole suite.
There's an honesty problem to solve first. The app still stores URLs in the in-memory Map from chapter 6; Prisma and the real Url table don't arrive until chapter 11. With no application table, the chapter 9 truncateAllTables helper has nothing to truncate and the leak has nothing to leak. We can't demonstrate cross-test pollution if nothing actually persists.
So this chapter creates its own throwaway table, urls_demo, directly via SQL, purely as a teaching vehicle. It's the smallest thing that can hold real, persisted rows: an identity id and a url column.
typescript// __tests__/integration/helpers/urls-demo.tsimport { pool } from "../../../src/db/pool";export const createUrlsDemoTable = async (): Promise<void> => {await pool.query(`CREATE TABLE IF NOT EXISTS urls_demo (id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,url TEXT NOT NULL)`);};export const insertDemoUrl = async (url: string): Promise<void> => {await pool.query("INSERT INTO urls_demo (url) VALUES ($1)", [url]);};export const countDemoUrls = async (): Promise<number> => {const result = await pool.query<{ count: string }>("SELECT COUNT(*) AS count FROM urls_demo");return Number(result.rows[0].count);};
Two details matter here. First, CREATE TABLE IF NOT EXISTS makes the setup idempotent: the suite calls it in beforeAll and it's a no-op if a previous run already created the table. Second, the name urls_demo is chosen so it can never collide with chapter 11's Prisma table. Prisma's model maps to a table called Url; this one is urls_demo, a different relation with a different lifecycle, created and owned entirely by the integration suite.
countDemoUrls parses the result of COUNT(*) with Number(...) because Postgres returns a count as a bigint, which the pg driver hands back as a string to avoid losing precision. Compare it to a number without parsing and expect(...).toBe(1) would fail against "1".
Start from the red branch and install dependencies. The lockfile hasn't changed since chapter 9, because the fix uses only Jest's built-in setupFilesAfterEnv and the existing helpers, so no new packages. Run npm install anyway after a checkout:
bashgit checkout 10-test-isolation-foundations-startnpm install
The start branch ships one new test file. Both tests do the exact same thing: insert one row, then assert the table holds exactly one row. Read on their own, each is obviously correct.
typescript// __tests__/integration/urls-isolation.test.tsimport { pool } from "../../src/db/pool";import {createUrlsDemoTable,insertDemoUrl,countDemoUrls,} from "./helpers/urls-demo";describe("urls_demo isolation (no cleanup — leaks between tests)", () => {beforeAll(async () => {await createUrlsDemoTable();});afterAll(async () => {await pool.end();});it("first insert leaves exactly one row", async () => {await insertDemoUrl("https://example.com/first");expect(await countDemoUrls()).toBe(1);});it("second insert also expects exactly one row (but sees the leftover)", async () => {await insertDemoUrl("https://example.com/second");expect(await countDemoUrls()).toBe(1);});});
Notice what's missing: there is no cleanup. Neither test removes its row, and there's no beforeEach to reset the table. The beforeAll creates the table once for the file; the afterAll only closes the pool so Jest exits cleanly. Between the two tests, nothing touches the data.
Start Postgres (the compose file lives on the chapter branch, added in chapter 9) and run the integration suite:
bashcp .env.example .env # first time onlydocker compose up -d --wait # starts Postgres, blocks until healthynpm run test:integration

Red, with Expected: 1, Received: 2. The first test inserted its row and passed; the second inserted its own row into the same table, which still held the first test's leftover, and counted two. The other green suite is chapter 9's db.test.ts plus the first urls-isolation test, so the lone failure is the second one tripping over the leftover.
A failing assertion alone doesn't prove the test is flaky; maybe the test is simply incorrect. The way to prove order-dependence is to run the offending test by itself and watch it pass. First reset the table so it's empty, then run only the second test:
bash# reset state first so the table is emptydocker exec url-shortener-postgres psql -U postgres -d urlshortener_test -c "DROP TABLE IF EXISTS urls_demo;"npm run test:integration -- urls-isolation -t "second insert"
The first test is skipped and the second passes. The same assertion that failed a moment ago now goes green, because no other test ran first to leave a row behind. That is the precise definition of an order-dependent suite: each test is correct in isolation, and the failure only appears when they run together. Once the isolation is fixed, both tests pass in any order.
There are two standard ways to give each test a clean database. We'll implement the first, but both are worth understanding so the choice is deliberate.
1. Truncate every table before each test (what we implement). Before a test runs, TRUNCATE every table in the database so it starts empty.
RESTART IDENTITY keeps IDs deterministic across runs, and CASCADE makes truncation order-independent across foreign keys. The database state after a run is real and inspectable, which makes debugging easy.2. Wrap each test in a transaction and roll back (the alternative). BEGIN before each test, ROLLBACK after; the test's writes are discarded without any explicit truncation.
LISTEN/NOTIFY, multiple pooled connections, or DDL all fall outside a single rollback-able transaction.For a course that injects a pg pool now and a Prisma client soon, truncate-in-beforeEach stays simpler and matches how the app actually runs, because the test and the app don't have to share a transaction. That's the strategy we'll wire in.
Switch to the finish branch:
bashgit checkout 10-test-isolation-foundations-finishnpm install
The fix is two small pieces: a setup file that registers one beforeEach, and a single line of Jest config to load it. The test file's bodies don't change. Only its describe title and docstring are updated to reflect that cleanup is now centralized.
The entire fix is this file. It imports the truncateAllTables helper we already wrote in chapter 9 and registers a single global beforeEach that runs it.
typescript// __tests__/integration/setup-isolation.tsimport { truncateAllTables } from "./helpers/truncate";beforeEach(async () => {await truncateAllTables();});
This reuses the chapter 9 helper unchanged. That helper discovers tables dynamically rather than hardcoding names:
typescript// __tests__/integration/helpers/truncate.tsexport const getPublicTableNames = async (): Promise<string[]> => {const result = await pool.query<{ tablename: string }>("SELECT tablename FROM pg_tables WHERE schemaname = 'public'");return result.rows.map((row) => row.tablename);};export const truncateAllTables = async (): Promise<void> => {const tables = await getPublicTableNames();if (tables.length === 0) {return;}const quoted = tables.map((name) => `"${name}"`).join(", ");await pool.query(`TRUNCATE TABLE ${quoted} RESTART IDENTITY CASCADE`);};
Because it asks pg_tables what exists rather than naming urls_demo explicitly, it picks up the demo table automatically, and it'll pick up chapter 11's Url table just as automatically, with no change. The cleanup logic written in chapter 9 was already correct and connection-agnostic; this chapter just gives it a trigger.
setupFilesAfterEnv, not setupFilesThe setup file does nothing until Jest loads it. That's a one-line change in the integration config, and which key you use is the part that trips people up:
typescript// jest.integration.config.tsconst config: Config = {preset: "ts-jest",testEnvironment: "node",roots: ["<rootDir>/__tests__/integration"],setupFiles: ["<rootDir>/__tests__/integration/setup-env.ts"],setupFilesAfterEnv: ["<rootDir>/__tests__/integration/setup-isolation.ts"],testMatch: ["<rootDir>/__tests__/integration/**/*.test.ts"],};
There are two setup hooks in this config, and they run at different times for a reason:
setupFiles runs before the test framework is installed. That's the right window for loading .env (chapter 9's setup-env.ts), because the connection strings must exist before src/db/pool.ts is imported. But because Jest's globals don't exist yet, setupFiles cannot register a beforeEach; there is no beforeEach to call.setupFilesAfterEnv runs after Jest installs beforeEach/afterEach into the global scope. That's exactly where a per-test hook belongs, and where our setup-isolation.ts registers its beforeEach.A common beginner mistake is to reach for a setupFilesAfterEach key. It doesn't exist. The real key is setupFilesAfterEnv, meaning "after the test environment is set up," not "after each test." Register beforeEach there and it applies to every test in every integration file, which is precisely the point of centralizing cleanup: one hook, wired once, covers the whole suite.
beforeEach, not afterEachWe clean before each test rather than after, and the reason is robustness. If a test crashes partway through and you relied on an afterEach to clean up, that cleanup gets skipped, and the crashed test's debris poisons the next test, producing a confusing cascade of failures that have nothing to do with the code being tested. Cleaning in beforeEach makes a test's correctness independent of whether the previous test cleaned up after itself: the next test wipes the slate first, unconditionally. As a bonus, the final test's data is still in the database after the run, so you can inspect what the last test left behind.
With the hook in place, the two tests pass, unchanged. Run the suite:
bashnpm run test:integration

Green: both suites pass, four tests total, namely chapter 9's db.test.ts and the two isolation tests. The second test now starts from an empty urls_demo, since the beforeEach truncated it before the test ran, inserts its one row, and counts exactly one. No test body changed; the cure lives entirely in shared test infrastructure. That is what centralizing cleanup buys: tests stay focused on their own behaviour and physically cannot leak state into each other.
The isolation even holds across runs. Run the suite a second time back-to-back, when the database still holds the previous run's rows, and it passes 4/4 again, because the beforeEach truncates that leftover before the first test of the new run. The fast unit suite, meanwhile, is untouched and still green at six suites and thirty-two tests, and npm run typecheck passes with no output.
Two things are intentionally out of scope, so you know they're coming rather than missing.
Parallel safety. Jest runs test files in parallel across multiple worker processes by default. Right now those workers all share the one urlshortener_test database, so two workers could truncate each other's rows mid-test. This chapter establishes correctness (clean state between tests within a worker). Making the suite correct and fast in parallel, by giving each worker its own database or schema keyed on JEST_WORKER_ID, is the explicit subject of chapter 22, Advanced Test Isolation & Parallel Safety.
The demo table won't conflict with Prisma. The urls_demo table is a teaching vehicle, and it's designed to coexist harmlessly with chapter 11's real table:
Url (capitalized, Prisma's default). The demo table is urls_demo. They're different relations and never reference each other.TRUNCATE only empties rows. The beforeEach truncate clears data; it never drops or alters schema. Once Url coexists in the test database, truncateAllTables simply truncates both, which is exactly what you want for isolation.CREATE TABLE IF NOT EXISTS is a no-op if the table already exists, so the demo setup never fights anything.In other words, the scaffolding from this chapter is replaced in spirit by the real Url table next chapter, but it's harmless to leave in place: the truncate helper handles whatever tables exist, automatically.
When you're done, stop the container:
bashdocker compose down -v # stop + remove the container and delete the data volume
Every database test now starts from a clean slate, so a real table can't make the suite flaky. Next we introduce Prisma and model the real Url table — the schema, first migration, and typed client.