How it works
The explanation, with examples you can run.
A real database per test run
Testcontainers starts Docker containers from your test code and removes them when the run ends. This Vitest file tests a repository against the same Postgres version production uses:
import { PostgreSqlContainer, type StartedPostgreSqlContainer } from "@testcontainers/postgresql"import { Client } from "pg"import { afterAll, beforeAll, beforeEach, expect, test } from "vitest"import { migrate } from "../db/migrate"import { createOrder, findOrdersByCustomer } from "../db/orders" let container: StartedPostgreSqlContainerlet db: Client beforeAll(async () => { container = await new PostgreSqlContainer("postgres:16-alpine").start() db = new Client({ connectionString: container.getConnectionUri() }) await db.connect() await migrate(db) // the real migrations, so they get tested too}, 60_000) afterAll(async () => { await db.end() await container.stop()}) beforeEach(async () => { await db.query("TRUNCATE orders RESTART IDENTITY CASCADE")}) test("finds only the customer's own orders", async () => { await createOrder(db, { customerId: "c1", totalCents: 500 }) await createOrder(db, { customerId: "c2", totalCents: 900 }) const orders = await findOrdersByCustomer(db, "c1") expect(orders.map((o) => o.totalCents)).toEqual([500])})Starting the container takes a few seconds, so start one per test file (or per worker), not per test, and reset the data between tests. Other ways to reset: run each test inside a transaction that is rolled back at the end, or use the Postgres module's snapshot() and restoreSnapshot().
Test data: build it, don't share it
A shared seed file ("user 1 is an admin, order 7 is refunded") turns into hidden coupling: one test changes order 7 and a test in another file starts failing. Instead, have each test create exactly what it needs through a factory with sensible defaults, overriding only what matters for that test:
Press Run to see what your code prints, in order.
Runs in this browser, in an isolated frame with no access to this page. console.log, info, warn and error are shown; code runs as a classic script, so use an async function for await.
The override makes the point of the test visible (role: "admin"), and the sequence number keeps values unique, so tests can run in parallel against the same database without colliding on unique indexes. Use reserved domains such as example.com or .test for fake emails so nothing is ever sent to a real person.
Environments
- Locally and in CI: containers, started by the tests themselves or by Docker Compose. Same images, same versions as production.
- Per pull request: a short-lived preview environment for end-to-end tests, created and destroyed by the pipeline.
- Shared staging: useful for a final check, but shared state there makes failures hard to reproduce. Don't make every merge depend on it.
Whatever the environment, keep configuration in one place and close to production's. Many "works on staging" incidents are really configuration differences that no test could see.
Check yourself
3 questions. Get them all right to finish this topic.
0 of 3 right so far. Get all three to finish this topic.