QA automation path · Across boundaries · Topic 5 of 12

Integration tests, test data and environments

Not started

An integration test checks that your code works with the real thing it talks to: a database, a queue, a cache, the file system. You give up some speed and get confidence that the SQL, the serialisation and the configuration are right. Containers make this practical: each test run can start a real Postgres, use it and throw it away.

Why it matters: Many bugs live where your code meets something else: a SQL dialect, a unique constraint, a message format. Only a real dependency shows them.

  1. 1Real dependencies

    Run the same database, broker or cache engine and version that production uses. A container makes that cheap and disposable.

  2. 2Each test builds its own data

    Don't lean on a shared seed that other tests also change. Create what the test needs, with unique values, right before it runs.

  3. 3Reset by rebuilding

    Start each test from a known state, by truncating tables, rolling back a transaction or restoring a snapshot, instead of trusting the previous test to clean up.

  4. 4Environments that match

    Throwaway environments per branch beat one shared staging that everyone breaks for each other.

1

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:

⌘/Ctrl + Enter
OutputNot run yet

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.

2

Check yourself

3 questions. Get them all right to finish this topic.

  1. 1.Why run repository tests against real Postgres instead of SQLite or an in-memory fake?
  2. 2.Two tests run in parallel and both insert a user with email test@example.com into a column with a unique index. One fails at random. The best fix?
  3. 3.Why rebuild a known state before each test rather than clean up after it?

0 of 3 right so far. Get all three to finish this topic.