Integration tests that use mocked databases are a trap. The mock passes; the real database fails. Your mock returns a list where the real query returns a cursor. Your mock doesn’t enforce foreign key constraints. Your mock doesn’t care about transaction isolation levels. Mocks are convenient to write, but they test your expectations about the database, not the database itself.

Testcontainers takes a different approach: start a real Docker container running the actual database (or broker, or cache), run your tests against it, then stop and remove the container. Each test run gets a clean, real instance. The mock is gone. The bugs that lived inside it are gone too.

What Testcontainers Is

Testcontainers is a library — available for Java, Go, Python, Node.js, .NET, Rust, and others — that provides an API for creating and managing Docker containers from your test code. It’s not a test framework or an assertion library. It’s specifically the part that handles container lifecycle: pull image, start container, wait until healthy, expose port, return connection string, clean up after.

The library handles the details that are annoying to get right manually:

  • Wait strategies: containers start before the service inside them is ready. Testcontainers has configurable wait strategies — wait for a log line, wait for a port to accept connections, wait for an HTTP endpoint to return 200 — so you’re not sleeping for an arbitrary number of seconds and hoping for the best.
  • Randomised ports: binds to a random available port on the host, which means tests can run in parallel without port conflicts.
  • Automatic cleanup: containers are removed after the test (or test suite) finishes, even if the test fails.
  • Ryuk reaper: a background container that tracks and removes any Testcontainers-managed containers if your test process exits uncleanly.

Python Example: PostgreSQL

import pytest
from testcontainers.postgres import PostgresContainer
import psycopg2

@pytest.fixture(scope="session")
def postgres():
    with PostgresContainer("postgres:16-alpine") as pg:
        yield pg

def test_user_creation(postgres):
    conn = psycopg2.connect(postgres.get_connection_url())
    cur = conn.cursor()
    
    cur.execute("""
        CREATE TABLE users (
            id SERIAL PRIMARY KEY,
            email TEXT NOT NULL UNIQUE,
            created_at TIMESTAMPTZ DEFAULT NOW()
        )
    """)
    
    cur.execute("INSERT INTO users (email) VALUES (%s) RETURNING id", ("test@example.com",))
    user_id = cur.fetchone()[0]
    
    cur.execute("SELECT email FROM users WHERE id = %s", (user_id,))
    result = cur.fetchone()
    
    assert result[0] == "test@example.com"
    conn.commit()

The scope="session" on the fixture starts one container for the whole test session. Alternatively, scope="function" gives each test its own clean container — much slower but completely isolated. For most projects, session scope plus a schema reset between tests is the right balance.

Go Example

package integration_test

import (
    "context"
    "testing"
    
    "github.com/testcontainers/testcontainers-go"
    "github.com/testcontainers/testcontainers-go/modules/postgres"
)

func TestWithPostgres(t *testing.T) {
    ctx := context.Background()
    
    pgContainer, err := postgres.RunContainer(ctx,
        testcontainers.WithImage("postgres:16-alpine"),
        postgres.WithDatabase("testdb"),
        postgres.WithUsername("test"),
        postgres.WithPassword("test"),
        testcontainers.WithWaitStrategy(
            wait.ForLog("database system is ready to accept connections"),
        ),
    )
    if err != nil {
        t.Fatal(err)
    }
    defer pgContainer.Terminate(ctx)
    
    connStr, err := pgContainer.ConnectionString(ctx)
    // use connStr to open a database connection and run your tests
}

What Testcontainers Has Built-In Modules For

The core library handles any Docker image, but pre-built modules exist for the most common services, handling image naming, port mapping, health checks, and connection string generation:

  • PostgreSQL — the most common use case, well-supported in all language clients
  • MySQL / MariaDB
  • Redis
  • MongoDB
  • Apache Kafka (including ZooKeeper setup or KRaft mode for newer Kafka versions)
  • RabbitMQ
  • Elasticsearch / OpenSearch
  • MinIO (S3-compatible object storage)
  • LocalStack (AWS service emulation — SQS, SNS, S3, DynamoDB, etc.)

For anything without a pre-built module, the generic GenericContainer API works with any Docker image.

Running Against Your Actual ORM Migrations

One of the strongest use cases for Testcontainers is running your ORM migrations against a real database during CI, then testing the resulting schema. This catches migration bugs before they hit production.

from testcontainers.postgres import PostgresContainer
from alembic import command
from alembic.config import Config

@pytest.fixture(scope="session")
def migrated_db():
    with PostgresContainer("postgres:16-alpine") as pg:
        # Run migrations against the container
        alembic_cfg = Config("alembic.ini")
        alembic_cfg.set_main_option("sqlalchemy.url", pg.get_connection_url())
        command.upgrade(alembic_cfg, "head")
        yield pg

The same works for Flyway, Liquibase, or raw SQL migration scripts. You’re testing the migration, the resulting schema, and your application code’s queries all in one pass.

CI/CD Considerations

Testcontainers requires Docker available in the CI environment. Most CI platforms support this:

  • GitHub Actions: Docker is available on ubuntu-latest runners by default. No extra configuration needed.
  • GitLab CI: Use Docker-in-Docker (dind service) or a runner with Docker socket mounted.
  • CircleCI: Use the machine executor (which has Docker available), not the docker executor for running Testcontainers.
  • Jenkins: Needs Docker installed on the build agent and the Jenkins user in the docker group.

Test execution time increases compared to mocked tests, because pulling images takes time. Mitigate this with Docker image caching in CI — cache the pulled images between runs so only the first run after an image version change incurs the pull cost.

A 60-second test that would have been 5 seconds with a mock is still far preferable to a production incident caused by a mock that didn’t match reality.

Common Gotchas

Container startup time on slow CI runners: The default wait strategy might time out on resource-constrained CI. Increase the timeout or use more lenient wait strategies (port availability rather than log parsing, which can be fragile).

Parallel test execution: Randomised ports prevent conflicts, but if multiple test suites share a Docker host, watch out for resource exhaustion. Too many simultaneous containers on a small runner causes slowness or failure.

Windows without WSL2: Testcontainers works on Windows but requires either WSL2 with Docker Desktop or Docker Desktop with Hyper-V. The Windows path adds complexity; WSL2 is the smoother route for Windows developer machines.

Database state leaking between tests: Session-scoped containers with per-test cleanup (truncating tables or wrapping each test in a rolled-back transaction) is faster than per-test containers but requires more deliberate test isolation. Pick one approach and be consistent.

The Case Against Mocking Databases

The argument is simple: databases are complex, stateful systems with their own behaviour — query planners, constraint enforcement, transaction semantics, locking, timezone handling. Every mock of a database is an approximation of that behaviour, and the approximation has gaps. Those gaps are where bugs live.

Testcontainers makes the alternative practical. Real databases, real schema, real constraints, minimal setup overhead, automatic cleanup. Once you’ve run integration tests against real containers for a while, the idea of trusting a mock to represent a database accurately starts to seem strange.

References