๐๏ธ 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.
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.
| Approach | Realism | Speed | Best for |
|---|---|---|---|
Dedicated test DB (a *_test schema) | High | Good | Local dev when the engine is already installed |
| Docker Compose service | High | Good | Consistent CI and team environments |
| Testcontainers | High | Good | Throwaway, per-run isolation from inside the test |
| In-memory (e.g. mongodb-memory-server) | Medium | Fastest | Speed-critical suites that tolerate small differences |
| Mocked | Lowest | Instant | Not 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.
| Hook | Database job |
|---|---|
beforeAll | Start container, connect, run migrations / create schema |
beforeEach | Begin a transaction (or truncate), seed fixtures |
afterEach | Roll back the transaction |
afterAll | Close 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
_testbefore 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 inbeforeEach - 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
- Testcontainers for Node.js โ real databases in tests
- node-postgres (pg) โ the Postgres client used here
- Jest โ setup and teardown hooks
- PostgreSQL โ TRUNCATE and RESTART IDENTITY
๐ 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.