Testcontainers Python: Integration Tests with Real Services

Testcontainers Python: Integration Tests with Real Services

Mocking database calls in Python tests creates a false sense of security. testcontainers-python spins up real PostgreSQL, Redis, and MongoDB containers via Docker during your pytest run, giving you genuine integration coverage without managing external services.

Key Takeaways

testcontainers-python integrates with pytest through fixtures. Define containers in conftest.py and share them across test modules without restart overhead.

Containers bind to random available ports. No port conflicts between parallel test runs or other local services.

Works identically in CI. Any GitHub Actions, GitLab CI, or CircleCI runner with Docker support runs your tests without additional configuration.

Session-scoped fixtures start containers once per pytest session. This keeps test suite runtime fast even with multiple container dependencies.

Installation

pip install testcontainers
# Or with specific extras
pip install testcontainers[postgres,redis,mongodb]

The package automatically pulls the required Docker images when tests run.

Basic PostgreSQL Example

The simplest case: a single container for one test file.

from testcontainers.postgres import PostgresContainer
import psycopg2

def test_database_connection():
    with PostgresContainer("postgres:15") as postgres:
        conn = psycopg2.connect(postgres.get_connection_url())
        cursor = conn.cursor()
        cursor.execute("SELECT 1")
        result = cursor.fetchone()
        assert result[0] == 1

The with statement starts the container before the test body and stops it after — even if the test fails.

Pytest Fixtures: The Right Pattern

For a real test suite, you want fixtures. Define containers in conftest.py so they're available to all tests:

# conftest.py
import pytest
import psycopg2
from testcontainers.postgres import PostgresContainer
from testcontainers.redis import RedisContainer

@pytest.fixture(scope="session")
def postgres_container():
    with PostgresContainer("postgres:15-alpine") as container:
        yield container

@pytest.fixture(scope="session")
def redis_container():
    with RedisContainer("redis:7-alpine") as container:
        yield container

@pytest.fixture(scope="session")
def db_conn(postgres_container):
    conn = psycopg2.connect(postgres_container.get_connection_url())
    # Run schema migrations
    with conn.cursor() as cur:
        cur.execute("""
            CREATE TABLE IF NOT EXISTS users (
                id SERIAL PRIMARY KEY,
                email VARCHAR(255) UNIQUE NOT NULL,
                name VARCHAR(255) NOT NULL,
                created_at TIMESTAMP DEFAULT NOW()
            )
        """)
    conn.commit()
    yield conn
    conn.close()

@pytest.fixture(autouse=True)
def clean_tables(db_conn):
    """Roll back data between tests."""
    yield
    with db_conn.cursor() as cur:
        cur.execute("TRUNCATE users CASCADE")
    db_conn.commit()

With scope="session", containers start once for the entire pytest session — not once per test. The autouse=True cleanup fixture rolls back data between tests while keeping the container running.

Testing a Repository Layer

# test_user_repository.py
from repositories.user import UserRepository

def test_create_user(db_conn):
    repo = UserRepository(db_conn)
    user = repo.create(email="alice@example.com", name="Alice")

    assert user["id"] is not None
    assert user["email"] == "alice@example.com"

def test_find_user_by_email(db_conn):
    repo = UserRepository(db_conn)
    repo.create(email="bob@example.com", name="Bob")

    found = repo.find_by_email("bob@example.com")
    assert found["name"] == "Bob"

def test_find_nonexistent_user_returns_none(db_conn):
    repo = UserRepository(db_conn)
    result = repo.find_by_email("nobody@example.com")
    assert result is None

def test_duplicate_email_raises(db_conn):
    repo = UserRepository(db_conn)
    repo.create(email="dup@example.com", name="First")

    with pytest.raises(Exception):  # psycopg2.errors.UniqueViolation
        repo.create(email="dup@example.com", name="Second")

These tests hit real PostgreSQL. The uniqueness constraint test in the last example would silently pass with most database mocks — it only catches the real behavior because it's talking to a real database.

Redis Integration Test

# conftest.py addition
import redis as redis_client

@pytest.fixture(scope="session")
def redis(redis_container):
    return redis_client.Redis(
        host=redis_container.get_container_host_ip(),
        port=redis_container.get_exposed_port(6379),
        decode_responses=True
    )

# test_cache.py
from services.cache import CacheService

def test_cache_miss_returns_none(redis):
    cache = CacheService(redis)
    result = cache.get("nonexistent-key")
    assert result is None

def test_cache_set_and_get(redis):
    cache = CacheService(redis)
    cache.set("session:abc123", {"user_id": 42}, ttl=300)

    retrieved = cache.get("session:abc123")
    assert retrieved["user_id"] == 42

def test_cache_expiry(redis):
    cache = CacheService(redis)
    cache.set("expiring-key", "value", ttl=1)

    import time
    time.sleep(1.1)

    assert cache.get("expiring-key") is None

MongoDB Example

from testcontainers.mongodb import MongoDbContainer
from pymongo import MongoClient

@pytest.fixture(scope="session")
def mongo_container():
    with MongoDbContainer("mongo:7") as container:
        yield container

@pytest.fixture(scope="session")
def mongo_db(mongo_container):
    client = MongoClient(mongo_container.get_connection_url())
    return client["testdb"]

def test_document_insert_and_find(mongo_db):
    collection = mongo_db["products"]
    collection.insert_one({"name": "Widget", "price": 9.99, "stock": 100})

    product = collection.find_one({"name": "Widget"})
    assert product["price"] == 9.99
    assert product["stock"] == 100

SQLAlchemy Integration

Most Python apps use SQLAlchemy rather than raw psycopg2:

# conftest.py
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from testcontainers.postgres import PostgresContainer
from myapp.models import Base

@pytest.fixture(scope="session")
def engine(postgres_container):
    url = postgres_container.get_connection_url()
    engine = create_engine(url)
    Base.metadata.create_all(engine)
    return engine

@pytest.fixture
def db_session(engine):
    connection = engine.connect()
    transaction = connection.begin()
    session = sessionmaker(bind=connection)()

    yield session

    session.close()
    transaction.rollback()
    connection.close()

The nested transaction pattern rolls back every test's changes without truncating tables — faster than TRUNCATE for large schemas.

GitHub Actions CI Setup

name: Python Tests
on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: '3.12'

      - name: Install dependencies
        run: pip install -r requirements.txt

      - name: Run tests
        run: pytest tests/ -v
        # Docker is available on ubuntu-latest — Testcontainers just works

No special Docker setup needed. GitHub-hosted ubuntu-latest runners include Docker. Your tests run the same way locally and in CI.

Avoiding Common Pitfalls

Don't use scope="function" for containers. Starting a fresh PostgreSQL container for every test function adds 3-5 seconds per test. Use scope="session" with data cleanup fixtures instead.

Use alpine images in CI. postgres:15-alpine is ~100MB vs ~400MB for postgres:15. Faster image pulls, faster CI.

Wait for container readiness automatically. testcontainers-python includes wait strategies — containers are ready before your test code runs. Don't add time.sleep() calls.

Set TESTCONTAINERS_RYUK_DISABLED=true only if your environment blocks privileged containers. Ryuk is the cleanup daemon that removes containers after tests. Disabling it means containers linger if your process exits unexpectedly.

Going Beyond Unit Tests

Testcontainers handles the data layer. But once your application is running, you need to verify the full user experience — workflows, forms, authentication flows, dashboards.

HelpMeTest automates end-to-end browser tests in plain English. Write your integration tests with Testcontainers, write your E2E tests with HelpMeTest, and run both in CI. Together they cover every layer from SQL queries to user clicks.

Read more