Skip to main content

๐Ÿ—„๏ธ Database Testing

Your database is the foundation everything else stands on. Mock it, and your tests happily pass while a broken query, a missing constraint, or a bad migration ships to production. This lesson shows the professional alternative: a real test database, spun up cheaply and reset perfectly between every test.

Week 12 · Day 2 (Tuesday: Integration Testing) · Lecture 3

๐ŸŽฏ Learning Objectives

By the end of this lesson, you will be able to:

  • Explain why a real test database beats mocking for data-layer confidence
  • Compare the setup options: dedicated test DB, Docker, Testcontainers, and in-memory
  • Stand up a throwaway Postgres for tests with Docker Compose or Testcontainers
  • Isolate every test with transaction rollback or truncation
  • Seed fixtures with factories and keep tests order-independent
  • Test transactions, constraints, and migrations that plain unit tests can't reach

Estimated Time: 70 minutes

Practice: Wire up a test-database setup with per-test isolation and prove a rollback protects a failed multi-step operation.

In This Lesson

Why Real, Not Mocked

A mocked database is a stunt double that always hits its mark. You tell it "when asked for user 1, return this object," and it obediently does โ€” so your test passes. But it never runs your actual SQL, never enforces your UNIQUE constraint, never applies a migration, never coerces a type. All the bugs that only a database can produce are invisible to it.

๐Ÿ’ธ A $440 million database bug

In 2012 Knight Capital deployed data-layer code that reactivated dormant, untested behavior. In 45 minutes their systems fired off millions of unintended trades, losing about $440 million and nearly destroying the firm. The failure lived in data handling that had never been exercised against a real database. Tests against actual data behavior are not academic โ€” they protect real money.

The principle from the integration-testing lesson holds here in its sharpest form: for the data layer, use a real database. Run the same engine you use in production โ€” Postgres against Postgres, Mongo against Mongo โ€” just a disposable test instance. Only then do your tests catch broken queries, constraint violations, and migration errors before your users do.

A mocked database skips SQL and constraints while a real test database runs them, catching bugs Mocked DB returns canned objects no SQL ยท no constraints bugs slip through โœ— Real test DB runs your actual queries enforces constraints ยท migrations catches real bugs โœ“
Mocks prove your test setup; a real test database proves your data layer.

Test Database Options

"Use a real database" leaves a choice of how real and how it's provisioned. Each option trades realism against speed and setup effort.

ApproachRealismSpeedBest for
Dedicated test DB (a *_test schema)HighGoodLocal dev when the engine is already installed
Docker Compose serviceHighGoodConsistent CI and team environments
TestcontainersHighGoodThrowaway, per-run isolation from inside the test
In-memory (e.g. mongodb-memory-server)MediumFastestSpeed-critical suites that tolerate small differences
MockedLowestInstantNot the data layer โ€” pure logic only

๐Ÿ’ก The default recommendation

Reach for Testcontainers or a Docker Compose service running the same engine as production. Both give you a genuine database with zero "works on my machine" drift. Use an in-memory database only when its behavior is close enough to production and speed truly dominates โ€” and know that in-memory engines can silently differ on constraints, transactions, and SQL dialect.

Standing One Up

Two clean ways to get a real Postgres for your tests.

Option A โ€” Docker Compose

Declare a dedicated test database as a service, mapped to a non-default port so it never collides with a local dev database. A healthcheck lets your CI wait until it's genuinely ready.

# docker-compose.test.yml
services:
  postgres-test:
    image: postgres:16
    environment:
      POSTGRES_USER: testuser
      POSTGRES_PASSWORD: testpass
      POSTGRES_DB: app_test
    ports:
      - "5433:5432"          # 5433 on the host avoids clashing with local dev
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U testuser -d app_test"]
      interval: 5s
      timeout: 5s
      retries: 5

Start it before the suite (docker compose -f docker-compose.test.yml up -d), point your test config at port 5433, and stop it after.

Option B โ€” Testcontainers (self-contained)

Testcontainers starts a real container from inside your test run and hands you its connection details, then disposes of it automatically. No manual docker commands, and each run is fully isolated.

const { PostgreSqlContainer } = require('@testcontainers/postgresql');
const { Pool } = require('pg');

let container, pool;

beforeAll(async () => {
    // Boot a throwaway Postgres โ€” real engine, random free port
    container = await new PostgreSqlContainer('postgres:16').start();

    pool = new Pool({ connectionString: container.getConnectionUri() });

    // Apply the schema so tables exist
    await pool.query(`
        CREATE TABLE users (
            id    SERIAL PRIMARY KEY,
            name  TEXT NOT NULL,
            email TEXT UNIQUE NOT NULL
        );
    `);
});

afterAll(async () => {
    await pool.end();          // close the pool
    await container.stop();    // dispose of the container
});

โš ๏ธ Never point tests at production

Guard against catastrophe. Read the connection string from an environment variable that only the test config sets, and add a hard assertion that the database name ends in _test before any destructive operation. A single TRUNCATE against the wrong database is a career-defining mistake.

The Setup & Teardown Lifecycle

The same four hooks you met in the integration lesson orchestrate the database. The expensive work โ€” start the container, apply the schema โ€” happens once; the cheap reset that guarantees isolation happens before every test.

sequenceDiagram participant R as Test runner participant DB as Test database R->>DB: beforeAll start container and create schema R->>DB: beforeEach begin a transaction R->>DB: run a test that inserts and reads rows R->>DB: afterEach roll back so nothing persists R->>DB: beforeEach begin a fresh transaction R->>DB: run the next test in a clean state R->>DB: afterAll stop the container and close the pool
HookDatabase job
beforeAllStart container, connect, run migrations / create schema
beforeEachBegin a transaction (or truncate), seed fixtures
afterEachRoll back the transaction
afterAllClose the pool, stop the container

Isolation: Rollback vs. Truncate

Every test must start from an identical, known state. There are two industry-standard ways to guarantee that, and it's worth knowing when each shines.

Transaction rollback (fast)

Open a transaction in beforeEach, let the test do its inserts and updates, then ROLLBACK in afterEach. Because nothing is ever committed, the database is pristine for the next test โ€” and rollback is faster than physically deleting rows.

let client;

beforeEach(async () => {
    client = await pool.connect();
    await client.query('BEGIN');      // start an isolated transaction
});

afterEach(async () => {
    await client.query('ROLLBACK');   // undo everything this test did
    client.release();
});

test('inserting a user is visible within the same transaction', async () => {
    await client.query(
        'INSERT INTO users (name, email) VALUES ($1, $2)',
        ['Ada', 'ada@example.com']
    );
    const { rows } = await client.query('SELECT COUNT(*) FROM users');
    expect(Number(rows[0].count)).toBe(1);
});
// After ROLLBACK the row is gone โ€” the next test sees zero users.

๐Ÿ“– The catch with rollback

Rollback isolation requires that the code under test uses the same connection that opened the transaction โ€” uncommitted rows are invisible to other connections. That's easy in a repository test, but if your code checks out its own connection from a pool, the transaction won't wrap it. In those cases, truncation is simpler and more reliable.

Truncation (simple & robust)

In beforeEach, empty every table. It's obviously correct, works no matter how many connections the code uses, and RESTART IDENTITY resets auto-increment counters so ids are predictable.

beforeEach(async () => {
    await pool.query('TRUNCATE users, orders, products RESTART IDENTITY CASCADE');
});

Rule of thumb: reach for rollback when the code under test shares your test's connection (fast, elegant); reach for truncation when it doesn't or when you want the simplest thing that always works.

Seeding Fixtures

A test that needs rows should create them in its own setup โ€” never lean on data another test left behind. A factory produces valid records with unique values, and lets a test override just the field it cares about.

// A factory: sensible defaults + unique values + easy overrides
function makeUser(overrides = {}) {
    const n = Math.random().toString(36).slice(2, 8);
    return {
        name: 'Test User',
        email: `user-${n}@example.com`,   // unique โ€” no UNIQUE-constraint clashes
        role: 'user',
        ...overrides,
    };
}

async function seedUser(pool, overrides = {}) {
    const u = makeUser(overrides);
    const { rows } = await pool.query(
        'INSERT INTO users (name, email, role) VALUES ($1, $2, $3) RETURNING *',
        [u.name, u.email, u.role]
    );
    return rows[0];
}

test('an admin fixture has the admin role', async () => {
    const admin = await seedUser(pool, { role: 'admin' });   // arrange its own data
    expect(admin.role).toBe('admin');
});

โœ… Why unique-by-default matters

Hard-coding email: 'test@example.com' in a fixture works once โ€” then a second test (or a second row) trips the UNIQUE constraint and you get a mysterious, order-dependent failure. Generating a unique value per record makes fixtures composable and keeps tests independent.

Transactions, Constraints & Migrations

These are exactly the behaviors a mock can never verify โ€” and precisely why you went to the trouble of a real database.

Test that a rollback protects a multi-step operation

An order that reserves inventory and writes an order row must be atomic: if the second step fails, the first must be undone. Prove it.

test('a failed order leaves inventory untouched', async () => {
    await seedProduct(pool, { id: 1, name: 'Widget', inventory: 5 });

    // The service wraps both steps in a transaction; the second step throws
    await expect(
        orderService.placeOrder({ productId: 1, quantity: 999 })   // exceeds stock
    ).rejects.toThrow(/insufficient inventory/i);

    // Because the transaction rolled back, inventory is unchanged and no order exists
    const product = await pool.query('SELECT inventory FROM products WHERE id = 1');
    expect(product.rows[0].inventory).toBe(5);

    const orders = await pool.query('SELECT COUNT(*) FROM orders');
    expect(Number(orders.rows[0].count)).toBe(0);
});

Test that a constraint is actually enforced

test('a duplicate email is rejected by the UNIQUE constraint', async () => {
    await seedUser(pool, { email: 'dup@example.com' });

    await expect(
        seedUser(pool, { email: 'dup@example.com' })   // same email
    ).rejects.toThrow();   // the DB throws โ€” a mock never would
});

Test a migration up and down

Migrations are code too, and a bad one can lose data. Run the migration against a real test database, assert the new shape exists, then run the down-migration and assert it's cleanly reversed.

test('the add-profiles migration applies and reverts', async () => {
    await migration.up(pool);
    const after = await pool.query(`SELECT to_regclass('public.profiles') AS t`);
    expect(after.rows[0].t).toBe('profiles');    // table now exists

    await migration.down(pool);
    const reverted = await pool.query(`SELECT to_regclass('public.profiles') AS t`);
    expect(reverted.rows[0].t).toBeNull();        // cleanly removed
});

๐Ÿ’ก Concurrency is testable too

Fire several conflicting operations at once with Promise.allSettled and assert the database's guarantees hold โ€” e.g. exactly one of ten concurrent "decrement if in stock" updates on a single unit succeeds. This surfaces race conditions that no single-threaded unit test can reveal.

Practice & Quiz

๐Ÿ‹๏ธ Exercise 1: Wire up per-test isolation

Goal: Given a connected pool, write the lifecycle hooks that create a users table once and truncate it before every test, so tests are order-independent.

๐Ÿ’ก Hint

Schema creation is once-per-file work (beforeAll). The reset that guarantees a clean slate belongs in beforeEach. Use TRUNCATE ... RESTART IDENTITY so ids reset too.

โœ… Solution
beforeAll(async () => {
    await pool.query(`
        CREATE TABLE IF NOT EXISTS users (
            id    SERIAL PRIMARY KEY,
            name  TEXT NOT NULL,
            email TEXT UNIQUE NOT NULL
        );
    `);
});

beforeEach(async () => {
    await pool.query('TRUNCATE users RESTART IDENTITY CASCADE');
});

afterAll(async () => {
    await pool.end();
});

๐Ÿ‹๏ธ Exercise 2: Prove the constraint

Goal: Write a test that inserts a user, then asserts inserting a second user with the same email is rejected by the database.

โœ… Solution
test('rejects a duplicate email', async () => {
    await pool.query(
        'INSERT INTO users (name, email) VALUES ($1, $2)',
        ['Ada', 'ada@example.com']
    );

    await expect(
        pool.query(
            'INSERT INTO users (name, email) VALUES ($1, $2)',
            ['Grace', 'ada@example.com']   // same email
        )
    ).rejects.toThrow();
});

๐ŸŽฏ Quick Quiz

Question 1: Why can a mocked database never catch a broken UNIQUE constraint?

Question 2: What's the main appeal of transaction-rollback isolation over truncation?

Question 3: What does Testcontainers give you that a shared local database doesn't?

Best Practices & Pitfalls

โœ… Do

  • Test the data layer against a real database of the same engine as production
  • Provision it with Testcontainers or a Docker Compose service for drift-free CI
  • Isolate every test with transaction rollback or truncation in beforeEach
  • Seed fixtures with factories that generate unique values
  • Test the things only a database can do: transactions, constraints, migrations, concurrency
  • Assert the database name ends in _test before any destructive query

โŒ Don't

  • Mock the database when the data layer itself is what you're testing
  • Let tests share rows or depend on execution order
  • Hard-code duplicate unique values in fixtures
  • Assume an in-memory engine behaves exactly like your production database
  • Ever run a test suite against a production or shared dev database

โš ๏ธ Leaked connections hang the suite

If afterAll forgets to pool.end() and stop the container, Jest warns that it "did not exit one second after the test run" and open handles linger into the next file. Always close the pool and stop the container โ€” clean teardown is part of a correct test, not an afterthought.

Summary

๐ŸŽ‰ Key Takeaways

  • For the data layer, use a real test database โ€” mocks hide the bugs that matter most
  • Provision it cleanly with Testcontainers or a Docker Compose service
  • Do expensive setup once in beforeAll/afterAll; reset per test in beforeEach
  • Isolate with transaction rollback (fast, shared connection) or truncation (simple, always works)
  • Seed fixtures with factories that generate unique values
  • Test transactions, constraints, and migrations โ€” the behaviors only a real database exhibits

๐Ÿ“š Additional Resources

๐Ÿš€ What's Next?

You've now covered the whole integration band โ€” strategies, endpoints, and the database beneath them. Next we climb to the top of the pyramid: End-to-End Testing Concepts โ€” driving the whole application through a real browser, exactly as a user would.

๐ŸŽ‰ Excellent!

Your foundation is solid โ€” literally. You can now test the layer everything else depends on, and trust every green checkmark.