Mocking a database in tests is one of those things that feels responsible at the time and creates problems later. Your mocks pass when your real database would fail, you’re testing against a different dialect than you run in production, and the confidence you think you’re getting from a green test suite doesn’t actually transfer to the deployed service.
Testcontainers is the answer that most teams eventually land on: real Docker containers running actual PostgreSQL (or MySQL, Redis, Kafka, MongoDB, Elasticsearch) spun up per test run and torn down after. No manual setup, no shared state between runs, no “works on my machine” database configuration. Just real infrastructure, on demand.
How It Works
Testcontainers is a library (available for Java, Python, Node.js, Go, Rust, and others) that manages Docker container lifecycles from within your test code. Your test declares what it needs, Testcontainers pulls the image and starts the container, waits for it to be healthy, exposes the connection details, runs your test, and then removes the container.
# Python example with pytest
from testcontainers.postgres import PostgresContainer
import psycopg2
def test_user_creation():
with PostgresContainer("postgres:16") as postgres:
conn = psycopg2.connect(postgres.get_connection_url())
cursor = conn.cursor()
# Run migrations
cursor.execute("""
CREATE TABLE users (
id SERIAL PRIMARY KEY,
email TEXT UNIQUE NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
)
""")
# Test your actual data access code
cursor.execute("INSERT INTO users (email) VALUES (%s)", ("test@example.com",))
cursor.execute("SELECT email FROM users WHERE email = %s", ("test@example.com",))
result = cursor.fetchone()
assert result[0] == "test@example.com"
The with block handles everything. The container starts, you get a connection URL pointing to it, your test runs, and the container is removed. No teardown code, no fixtures that accidentally persist state.
The Java and Node Patterns
If you’re on Java with JUnit 5, the @Testcontainers annotation handles container lifecycle automatically:
@Testcontainers
class UserRepositoryTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16")
.withDatabaseName("testdb")
.withUsername("test")
.withPassword("test");
@DynamicPropertySource
static void setProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
}
@Test
void shouldPersistAndRetrieveUser() {
// Your test against a real PostgreSQL instance
}
}
The @Container annotation with a static field means the container starts once per class rather than per test, which is important for performance. Individual test methods share the same container — use transactions and rollbacks to prevent state leaking between tests.
For Node.js, the pattern is similar:
import { PostgreSqlContainer } from "@testcontainers/postgresql";
import { Pool } from "pg";
describe("UserRepository", () => {
let container: StartedPostgreSqlContainer;
let pool: Pool;
beforeAll(async () => {
container = await new PostgreSqlContainer("postgres:16").start();
pool = new Pool({ connectionString: container.getConnectionUri() });
await runMigrations(pool); // Your migration function
});
afterAll(async () => {
await pool.end();
await container.stop();
});
test("creates and retrieves a user", async () => {
const repo = new UserRepository(pool);
await repo.create({ email: "test@example.com" });
const user = await repo.findByEmail("test@example.com");
expect(user?.email).toBe("test@example.com");
});
});
Multiple Containers and Compose
For services that depend on multiple infrastructure components, Testcontainers can start several containers and wire them together. A service that uses PostgreSQL and Redis together:
from testcontainers.postgres import PostgresContainer
from testcontainers.redis import RedisContainer
def test_cache_backed_query():
with PostgresContainer("postgres:16") as pg, \
RedisContainer("redis:7") as redis:
db_url = pg.get_connection_url()
cache_url = redis.get_connection_url()
service = MyService(db_url=db_url, cache_url=cache_url)
# test the real integration between your code, database, and cache
For more complex setups, Testcontainers supports Docker Compose — you can point it at an existing compose file and it manages the full stack lifecycle.
Performance: It’s Not as Slow as You Think
The main objection to Testcontainers is test speed. And yes, spinning up a PostgreSQL container takes a few seconds — something a mock doesn’t. The practical answer is that it doesn’t need to spin up per test, just per test run (or per test class for parallelised suites).
A few practices that keep things fast:
Ryuk: Testcontainers includes a resource reaper (Ryuk) that cleans up containers even if tests crash. This means you don’t need defensive teardown code, but you also need to make sure your CI environment allows the Docker socket to be used.
Container reuse: Testcontainers supports a .withReuse(true) mode that keeps containers running between test runs on your local machine, so subsequent runs skip the startup time. This is opt-in and shouldn’t be used in CI where you want clean state.
Schema setup: run your migrations inside the test setup rather than per-test. A schema migration that runs once per container startup is much faster than recreating tables per test case.
CI Setup
In GitHub Actions, Testcontainers works without any special configuration — Actions runners have Docker available by default:
- name: Run integration tests
run: pytest tests/integration/
env:
DOCKER_HOST: unix:///var/run/docker.sock
If you’re on a CI system where Docker-in-Docker (DinD) is required, add the Docker service and set DOCKER_HOST appropriately. Most modern CI platforms (GitHub Actions, GitLab CI with Docker runner, CircleCI machine executors) work out of the box.
When to Use Testcontainers vs a Shared Test Database
Testcontainers is not always the right answer. For unit tests that don’t need real database behaviour — testing business logic, validation, transformation — mocking or in-memory fakes are faster and appropriate. Testcontainers is for integration tests: verifying that your queries actually work against the database engine you use in production, including index behaviour, constraint enforcement, and query planner decisions.
For teams who’ve been burned by “tests pass but production fails” incidents, Testcontainers is usually the fix. It’s not overhead — it’s the cost of actually knowing your database integration works.