
Stop Mocking Databases: Use Testcontainers for Testing
Mocking databases in unit tests leads to false confidence. Learn how to use Testcontainers to spin up real Docker databases for reliable integration testing.
If you've been working in backend development for a while, you have almost certainly experienced this exact scenario. You write a beautifully structured unit test for your data access layer. You carefully mock your database driver or ORM—perhaps using Jest to mock Prisma, or Sinon to stub out your pg client. You set it up to return the exact JSON object your business logic expects. The test passes. Your Continuous Integration (CI) pipeline shows a reassuring sea of green checkmarks. You deploy to production.
And then, disaster strikes. Your application crashes with a relation "users" does not exist error, or a syntax error at or near "WHERE".
Why did this happen? Because your mock didn't know that someone renamed a column in the actual database schema. Your mock didn't care that you used a MySQL-specific function on a PostgreSQL database. Mocking a database is like practicing swimming on your living room floor. It feels perfectly safe, you can practice the motions, but it completely fails to prepare you for the actual water.
It's time to stop mocking our databases. The solution to reliable, confidence-inspiring database testing is Testcontainers.
The Illusion of Safety in Database Testing
When we mock databases, we are fundamentally testing the wrong thing. We aren't testing our data access logic; we are just testing our ability to write elaborate mocks.
Over the years, the software engineering community has tried several workarounds to avoid mocking, but each comes with its own set of painful drawbacks:
1. The In-Memory SQLite Fallback
Many developers set up an in-memory SQLite database for their tests, while running PostgreSQL or MySQL in production. This seems like a great idea because it's fast. However, SQL dialects differ wildly. If your production application uses PostgreSQL-specific features like JSONB, UUID columns, or window functions, SQLite will simply choke. You end up writing generic, lowest-common-denominator SQL just so your tests can pass, which severely limits the power of your actual database.
2. The Shared Staging Database Another common approach is maintaining a shared database dedicated to running tests. This is a nightmare for parallel execution. If your CI pipeline runs multiple PRs concurrently, they will step on each other's toes, mutating the same data and causing random, flaky test failures. You spend more time debugging the test environment than debugging your actual code.
3. The Manual Docker-Compose
You might require developers to run docker-compose up before running tests. This works locally but makes the CI pipeline setup brittle and complex. It's an external dependency that your test runner has no direct control over.
Enter Testcontainers

Testcontainers is a powerful library (available in Java, Go, Node.js, Python, and more) that provides lightweight, throwaway instances of common databases, Selenium web browsers, or anything else that can run in a Docker container.
Instead of mocking the database or managing external scripts, Testcontainers allows your test code itself to spin up a fresh Docker container right before your test suite starts, and cleanly tear it down when it finishes. It is completely code-driven.
A Practical Example in Node.js

Let's look at how easy it is to set this up in a TypeScript Node.js project using PostgreSQL. We will use the @testcontainers/postgresql package.
First, install the necessary dependencies:
npm install -D @testcontainers/postgresql pg @types/pg
Now, let's write an integration test for a simple UserRepository. Notice how we don't mock anything. We interact with a real Postgres database running inside a temporary Docker container.
import { PostgreSqlContainer, StartedPostgreSqlContainer } from "@testcontainers/postgresql";
import { Client } from "pg";
describe("UserRepository Integration", () => {
let container: StartedPostgreSqlContainer;
let client: Client;
// 1. Spin up the container before all tests
beforeAll(async () => {
// This downloads the image (if not cached) and starts a real Postgres instance
container = await new PostgreSqlContainer("postgres:15-alpine").start();
// 2. Connect our PostgreSQL client to the ephemeral container
client = new Client({ connectionString: container.getConnectionUri() });
await client.connect();
// 3. Run migrations to create our schema
await client.query(`
CREATE TABLE users (
id SERIAL PRIMARY KEY,
email VARCHAR(255) UNIQUE NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
);
`);
}, 30000); // Give it extra timeout to pull the Docker image
// 4. Clean up after tests are done
afterAll(async () => {
await client.end();
await container.stop();
});
// 5. Write real tests with high confidence
it("should insert a user and fetch them by email", async () => {
const email = "[email protected]";
// Execute real SQL syntax
await client.query("INSERT INTO users (email) VALUES ($1)", [email]);
const res = await client.query("SELECT * FROM users WHERE email = $1", [email]);
expect(res.rows).toHaveLength(1);
expect(res.rows[0].email).toBe(email);
});
});
Optimizing for Speed
One common criticism of Testcontainers is speed. Spinning up a Docker container takes a few seconds. If you have 50 different test files and each one spins up a new container, your test suite will grind to a halt.
To keep your feedback loop fast, you should adopt the following best practices:
1. Share Containers Across the Test Suite
In frameworks like Jest or Vitest, use globalSetup and globalTeardown to spin up a single database container for the entire test run, rather than doing it in beforeAll of every individual file.
2. Isolate Tests via Transactions
Since you are sharing a database, tests can pollute each other's data. Instead of recreating the database, wrap each test in a SQL transaction (BEGIN), and roll it back (ROLLBACK) in the afterEach hook. This ensures that every test runs in complete isolation and leaves the database in pristine condition, all in a matter of milliseconds.
Alternatively, you can run a simple TRUNCATE TABLE command on your relevant tables before each test to guarantee a clean slate.
Conclusion
Mocking databases creates a dangerous illusion of safety. It allows you to write tests that pass locally but fail spectacularly in production because they completely bypass the actual database engine.
By utilizing Testcontainers, you bridge the gap between testing and production environments. You gain the confidence that your SQL syntax is correct, your constraints are enforced, and your data layer works exactly as intended. Yes, integration tests using Docker are slightly slower than purely mocked unit tests, but the trade-off is absolutely worth it. It is much better to spend a few extra seconds running reliable integration tests than spending hours debugging production incidents.
Stop practicing on the living room floor. Dive into the real water with Testcontainers.
written by
Nguyên Tech
Responses
Loading comments…