Up to chapter 8, every test ran against an in-memory Map. In this chapter, we stand up a real PostgreSQL database using Docker Compose. We will prove the test suite can reach it using the smallest possible integration test: SELECT 1. The app's storage stays the in-memory Map for now. This chapter is purely about infrastructure.

postgres:16-alpine container locally. It includes a healthcheck so the test run never races a database that is still booting up.urlshortener for development and urlshortener_test for tests. This ensures destructive tests never touch your development data.pg connection pool that reads the correct database from the environment. A SELECT 1 integration test proves it works, kept separate from the fast, Docker-free unit suite.A few terms run through this chapter. The most important concepts are the distinction between test types and the healthcheck that makes this setup reliable.
Container. An isolated, disposable box running one piece of software with everything it needs bundled inside. A postgres:16-alpine container is a ready-to-run PostgreSQL server you can throw away and recreate for a clean slate. Docker Compose is how we describe it. A file declares the services a project needs, allowing to bring the container up identically for you, a teammate, or a continuous integration (CI) server.
docker-compose.ymldocker compose up -dUnit test vs integration test. A unit test exercises one piece of code in isolation with nothing else running. An integration test exercises how your code talks to a real external system. In this case, that system is a real Postgres database, which means the test is slower and needs the database running.
Healthcheck. A command Postgres runs on a loop so Docker knows whether the container is merely started or actually ready to accept connections. We use pg_isready, and the command docker compose up -d --wait blocks until the database reports healthy.
Keep this straight as we go: the app's storage is still the in-memory Map. We are standing up a real Postgres database and proving the tests can reach it, but no application code reads or writes to it yet. The actual storage swap comes later.
The instinct in a test-driven course is often to mock the database. A mock is fast, has no dependencies, and keeps the test pure. Why drag Docker into it?
A mock proves the wrong thing for this layer. A mocked database test asserts that your code calls the functions you think it calls. It verifies that pool.query was invoked with a specific string. However, it says nothing about whether that string is valid SQL. It cannot tell you if the driver is wired up correctly, if the connection string and credentials reach a live server, or if Postgres behaves the way your code assumes. A mock will happily return a green check for SQL that a real Postgres database would reject outright. That class of bug then only shows up in production.
A real database closes that gap. SELECT 1 returning 1 is a tiny query, but passing it validates the entire connection stack at once. It proves the environment variables resolved, the pg pool opened a socket, Docker mapped the port, the credentials authenticated, and Postgres answered. That is true integration confidence. A mock structurally cannot give you this.
This is not an anti-mock chapter. Mocks remain the right tool for pure logic and for the service layer, where you want speed and isolation. The point is narrower. A real database earns its keep on the one thing a mock cannot prove: that your code can actually talk to the database. The cost is honest and bounded. It is slower and requires Docker to be running, which is exactly why we split the two suites later in this chapter.
Integration tests are destructive by nature. They insert rows, truncate tables, and rewrite state so each test starts clean. You do not want that running against the same database you use for local development. One stray TRUNCATE and your hand-seeded development data is gone.
Because of this, the tests get their own database: urlshortener_test. It is completely separate from the development database, urlshortener. Tests can wipe it freely, and your development data is never in the blast radius.
The interesting decision is how to provide that second database. We use one Postgres instance hosting two databases, rather than two separate Postgres services. Here is why:
Per-worker parallel databases (one database per Jest worker) are a real technique, but they belong in an advanced isolation chapter. For now, a single instance with two databases is plenty.
The docker-compose.yml file specifies postgres:16-alpine, not postgres:latest. That 16 is doing real work.
The latest tag is a moving target. Whoever runs docker compose up tomorrow, next month, or in CI might pull a different major version than the one you developed against. Postgres major versions change behavior, defaults, and occasionally the on-disk format. A test suite that passed on your machine can fail on a teammate's purely because the database underneath shifted. Pinning the version makes the database a reproducible part of the project, the same way a lockfile pins your npm dependencies. Everyone runs the exact same Postgres: you, your teammates, and CI. The -alpine variant simply keeps the image small.
The connection details (host, port, user, password, database name) live in environment variables. They are not hardcoded in the source. There are two reasons for this.
The obvious reason is secrets. Credentials do not belong in version-controlled code. We commit a .env.example file documenting the shape of the configuration with safe local development defaults, and we gitignore the real .env file.
The subtler reason is the dev/test split. The exact same src/db/pool.ts file needs to point at the development database in normal runs and the test database under Jest. Environment variables make that a one-line decision. The code reads TEST_DATABASE_URL when NODE_ENV === "test" (which Jest sets automatically), and otherwise reads DATABASE_URL. There are no complex code branches per environment and no separate test builds. It is just a different value in the environment.
Start from the red branch and install the dependencies:
bashgit checkout 09-postgresql-docker-setup-startnpm install
The start branch ships the integration test, but it lacks what it needs to run. The test imports a connection pool that does not exist yet:
typescript// __tests__/integration/db.test.tsimport { pool } from "../../src/db/pool";describe("database connectivity", () => {afterAll(async () => {await pool.end();});it("connects to Postgres and runs a trivial query", async () => {const result = await pool.query<{ result: number }>("SELECT 1 as result");expect(result.rows[0].result).toBe(1);});});
This is the specification. The test borrows a connection, runs SELECT 1 as result, expects the value back as 1, and closes the pool in afterAll. Closing the pool ensures Jest exits cleanly instead of hanging on an open socket.
The unit suite already ignores this folder. On the start branch, we add one line to jest.config.ts so npm test never looks at the integration tests:
typescript// jest.config.tsimport type { Config } from "jest";const config: Config = {preset: "ts-jest",testEnvironment: "node",roots: ["<rootDir>/__tests__"],testPathIgnorePatterns: ["/node_modules/", "<rootDir>/__tests__/integration/"],};export default config;
The fast unit tests are untouched and stay green, since they never touch a database:
bashnpm test
The integration suite is the one that fails. Run it with the Postgres container already up. This ensures the failure is unmistakably "the module doesn't exist," rather than "the database is unreachable":
bashnpm run test:integration

Red. The suite cannot even compile: Cannot find module '../../src/db/pool'. There is no connection module, pg is not installed, and there is no docker-compose.yml. Running npm run typecheck reports the same gap as a TS2307 type error. Fixing that missing module is the entire to-do list for this chapter.
Switch to the finish branch:
bashgit checkout 09-postgresql-docker-setup-finishnpm install
The heart of the chapter is the compose file. It defines one pinned Postgres service with a healthcheck, a persistent volume, and a non-default host port.
yaml# docker-compose.ymlname: url-shortenerservices:postgres:image: postgres:16-alpinecontainer_name: url-shortener-postgresrestart: unless-stoppedenvironment:POSTGRES_USER: ${POSTGRES_USER:-postgres}POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}POSTGRES_DB: ${POSTGRES_DB:-urlshortener}ports:- "${POSTGRES_PORT:-5433}:5432"volumes:- postgres-data:/var/lib/postgresql/data- ./docker/init:/docker-entrypoint-initdb.d:rohealthcheck:test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres} -d ${POSTGRES_DB:-urlshortener}"]interval: 5stimeout: 5sretries: 5volumes:postgres-data:
A few choices in this file are worth calling out:
image: postgres:16-alpine is pinned, as discussed earlier. Everyone gets the exact same Postgres."${POSTGRES_PORT:-5433}:5432" maps host port 5433 to the container's standard 5432. Postgres inside the container still listens on 5432. From your machine, you reach it on 5433. We use 5433 because the default 5432 is frequently taken by other local projects. It is overridable via POSTGRES_PORT, but the repository is consistent on 5433, so we will use 5433 throughout../docker/init mount is a small but important trick. Postgres runs every *.sql file in /docker-entrypoint-initdb.d exactly once on first boot, provided the data volume is empty. This is how we create the second database without any manual steps.healthcheck runs pg_isready on a loop. It exists so that docker compose up -d --wait can block until Postgres is ready to accept connections, rather than returning the moment the container starts. Without it, the test run can race a database that is still initializing and fail intermittently.The init script is just one line:
sql-- docker/init/01-create-test-db.sqlCREATE DATABASE urlshortener_test;
Postgres creates urlshortener automatically from the POSTGRES_DB variable. This script adds urlshortener_test alongside it. A single instance ends up hosting both databases.
The .env.example file documents every variable, including the container settings and the two connection strings:
bash# .env.examplePOSTGRES_USER=postgresPOSTGRES_PASSWORD=postgresPOSTGRES_DB=urlshortenerPOSTGRES_PORT=5433DATABASE_URL=postgres://postgres:postgres@localhost:5433/urlshortenerTEST_DATABASE_URL=postgres://postgres:postgres@localhost:5433/urlshortener_test
Both connection strings point at localhost:5433, the host side of the port mapping. They differ only in the database name at the end. The real .env file (which is gitignored) holds the same values. You create it by copying the example. The repository's .gitignore already ignores .env and .env.* while un-ignoring !.env.example, so nothing changes there.
The connection module is small on purpose. It opens one shared pg pool and decides which database to hit based on NODE_ENV:
typescript// src/db/pool.tsimport { Pool } from "pg";const connectionString =process.env.NODE_ENV === "test"? process.env.TEST_DATABASE_URL: process.env.DATABASE_URL;if (!connectionString) {const expected =process.env.NODE_ENV === "test" ? "TEST_DATABASE_URL" : "DATABASE_URL";throw new Error(`Missing ${expected}. Copy .env.example to .env and start Postgres with \`docker compose up -d\`.`);}export const pool = new Pool({ connectionString });
A connection pool is a set of reusable open connections that the driver hands out and reclaims. This matters because opening a fresh TCP connection and authentication handshake on every query is slow. The pg library manages that pool for you. We create exactly one pool, export it, and everything else borrows from it via pool.query(...).
The dev/test selection is the only logic in the file. It lives here so no other module ever has to think about it. When Jest runs, it sets NODE_ENV=test automatically. The pool reads TEST_DATABASE_URL, and every query lands in urlshortener_test. The guard that throws an error on a missing connection string is deliberate. A clear "copy .env.example to .env" message is much better than pg silently trying to reach a default localhost database and failing confusingly. A later chapter replaces this with proper, schema-validated configuration loaded at startup.
The integration tests need a real Postgres database; the unit tests do not. Forcing every npm test run to require Docker would slow down the fast feedback loop that TDD depends on. Because of this, the integration suite gets its own Jest config and its own script.
typescript// jest.integration.config.tsimport type { Config } from "jest";const config: Config = {preset: "ts-jest",testEnvironment: "node",roots: ["<rootDir>/__tests__/integration"],setupFiles: ["<rootDir>/__tests__/integration/setup-env.ts"],testMatch: ["<rootDir>/__tests__/integration/**/*.test.ts"],};export default config;
Two additions over the start-branch config make this work. First, setupFiles runs setup-env.ts before any test module is imported. This is exactly the window where the connection strings must exist, because src/db/pool.ts reads them the moment it is imported. Second, testMatch restricts what counts as a test to files ending in .test.ts. This ensures Jest does not try to run the setup file or the helpers (which have no test cases) as if they were test suites.
The setup file is a one-liner that loads .env into process.env via dotenv:
typescript// __tests__/integration/setup-env.tsimport { config } from "dotenv";config({ quiet: true });
The two npm scripts make the split explicit:
json"scripts": {"start": "tsx src/server.ts","dev": "tsx watch src/server.ts","test": "jest --verbose","test:integration": "jest --config jest.integration.config.ts --verbose","typecheck": "tsc --noEmit"},
Running npm test executes the fast unit suite with no Docker required, so you can run it any time, on any machine. Running npm run test:integration executes the database suite and requires the container to be up. The finish branch also adds three dependencies for this to work: pg and @types/pg for the driver, and dotenv for loading .env.
The integration test cannot pass against a database that is not running, so start the container first. The --wait flag blocks until the healthcheck reports healthy:
bashcp .env.example .env # first time onlydocker compose up -d --wait # starts Postgres, blocks until healthy
The --wait flag is the healthcheck paying off. The command only returns once Postgres is accepting connections. On this first boot, the init script has also created both databases (urlshortener and urlshortener_test). Now run the integration suite:
bashnpm run test:integration

Green. The pool connected to urlshortener_test, SELECT 1 came back as 1, and the second test (the cleanup seam, covered next) ran without error. The fast unit suite is unchanged and still green, and npm run typecheck now passes. The full connection stack is proven end to end.
That second passing test exercises a helper that is about to become important. The moment tests share a real database, they also share its state. One test's rows are visible to the next, meaning order suddenly matters and the suite goes flaky. The fix is to reset the database between tests. The truncateAllTables function is the seam where that reset will live:
typescript// __tests__/integration/helpers/truncate.tsimport { pool } from "../../../src/db/pool";export 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`);};
Right now, this is effectively a no-op. There are no application tables yet, since the Url table arrives with Prisma a couple of chapters later. Because of this, getPublicTableNames() returns an empty list and the function exits before running any destructive statement. The test only asserts that it runs against the real database without throwing an error.
The job here is to plant the seam and prove it works against a real Postgres database. The next chapter is where it gets rigorous. We will wire it into a beforeEach block, weigh truncation against transaction rollbacks, and centralize it so every database test starts from a clean slate.
When you are done, stop the container. The plain down command removes the container and network but keeps the data volume. Using down -v also deletes the volume, which means the init script will re-run (and recreate urlshortener_test) on the next up:
bashdocker compose down # stop + remove container & networkdocker compose down -v # ...and delete the data volume
The cleanup seam is still inert, so the tests do not reset state between runs yet. Next, we will lay the foundations for test isolation. We will build per-test cleanup that keeps every database test deterministic and order-independent.