Integration tests with Testcontainers in Python explained
Your Python backend works fine locally against a real database, but your unit tests run in isolation and miss entire categories of bugs. Integration tests with Testcontainers in Python explained gives you the best of both worlds: real Postgres and Redis containers spun up automatically in CI and locally, without the headache of Docker Compose files or manual setup. We'll walk through how to write fast, reliable integration tests that catch the bugs your mocks never will.
Why Testcontainers matters for your test suite
Unit tests are fast because they mock everything. Integration tests are slow because they use real services. Testcontainers splits the difference by running isolated Docker containers for each test session, giving you genuine Postgres queries, actual Redis expiry, and real connection pooling—all within your pytest run.
In production, you'll use real databases. Your tests should too. Testcontainers handles the orchestration: it pulls the container image, starts it, waits for readiness, tears it down. All you do is inherit from a base class and write your tests.
testcontainers-python starts Docker containers
The testcontainers-python library provides a Python API to lifecycle Docker containers during test execution. Instead of manually running docker run, you declare what you need and Testcontainers handles startup, health checks, and cleanup.
Install it alongside pytest:
pip install testcontainers pytest pytest-asyncioHere's a minimal example with Postgres:
import pytest
from testcontainers.postgres import PostgresContainer
import psycopg2
from psycopg2.extensions import connection
@pytest.fixture(scope="session")
def postgres_container():
container = PostgresContainer("postgres:15")
container.start()
yield container
container.stop()
@pytest.fixture
def postgres_conn(postgres_container):
conn = psycopg2.connect(
host=postgres_container.get_container_host_ip(),
port=postgres_container.get_exposed_port(5432),
database=postgres_container.dbname,
user=postgres_container.username,
password=postgres_container.password,
)
yield conn
conn.close()
def test_insert_user(postgres_conn):
cursor = postgres_conn.cursor()
cursor.execute(
"INSERT INTO users (name, email) VALUES (%s, %s)",
("Alice", "alice@example.com"),
)
postgres_conn.commit()
cursor.execute("SELECT name FROM users WHERE email = %s", ("alice@example.com",))
result = cursor.fetchone()
assert result[0] == "Alice"When pytest runs, Testcontainers pulls the Postgres 15 image, starts a container, exposes it on a random port, and yields the container object. Your fixture then extracts the host and port to connect just like you would in production.
Session-scoped fixtures for speed
If you start and stop a container for every test, your suite will crawl. Start it once per session, then reuse it across all tests. This is why we use scope="session" in the fixture above.
Testcontainers will start the container once when the first test needs it, and stop it when all tests complete. This cuts test time dramatically. A container startup typically takes 1–3 seconds; with 50 tests, session scope saves 50–150 seconds.
Here's a more realistic setup for an async web app with multiple services:
import pytest
from testcontainers.postgres import PostgresContainer
from testcontainers.redis import RedisContainer
@pytest.fixture(scope="session")
def postgres_container():
with PostgresContainer("postgres:15") as container:
yield container
@pytest.fixture(scope="session")
def redis_container():
with RedisContainer("redis:7-alpine") as container:
yield container
@pytest.fixture
def app_config(postgres_container, redis_container):
"""Return app config pointing to test containers."""
return {
"DATABASE_URL": postgres_container.get_connection_string(),
"REDIS_URL": f"redis://{redis_container.get_container_host_ip()}:{redis_container.get_exposed_port(6379)}",
}
@pytest.fixture
async def async_client(app_config, postgres_container):
"""Fixture for FastAPI TestClient or httpx.AsyncClient."""
from myapp import create_app
app = create_app(config=app_config)
async with AsyncClient(app=app, base_url="http://test") as client:
yield clientNotice the context manager syntax: with PostgresContainer(...) as container:. This is cleaner than manual start() and stop() calls and ensures cleanup even if an exception occurs.
Run migrations once per session
Your test database is empty until you run migrations. You could run them before every test, but that's wasteful. Run them once per session, right after the container starts and before any test accesses the schema.
Here's a fixture that runs Alembic migrations on startup:
import pytest
from testcontainers.postgres import PostgresContainer
from alembic.config import Config as AlembicConfig
from alembic import command
import sqlalchemy as sa
@pytest.fixture(scope="session")
def postgres_container():
container = PostgresContainer("postgres:15")
container.start()
# Run migrations
alembic_cfg = AlembicConfig("alembic.ini")
engine = sa.create_engine(container.get_connection_string())
with engine.begin() as conn:
alembic_cfg.set_main_option("sqlalchemy.url", container.get_connection_string())
command.upgrade(alembic_cfg, "head")
yield container
container.stop()
@pytest.fixture
def db_session(postgres_container):
"""Provide a SQLAlchemy session for each test."""
engine = sa.create_engine(postgres_container.get_connection_string())
Session = sa.orm.sessionmaker(bind=engine)
session = Session()
yield session
session.close()Now every test gets a clean session connected to the same migrated schema. The migration runs once at the start of the entire test suite, not once per test.
Truncate tables between tests
Migrations run once, but data must not leak between tests. If test A inserts a user and test B checks that no users exist, test B will fail. Truncate (or delete) data after each test so the next test starts with a clean state.
The simplest approach is to delete all rows in affected tables:
@pytest.fixture
def db_session(postgres_container):
"""Provide a SQLAlchemy session; truncate tables after each test."""
engine = sa.create_engine(postgres_container.get_connection_string())
Session = sa.orm.sessionmaker(bind=engine)
session = Session()
yield session
# Clean up: delete all rows
with engine.begin() as conn:
conn.execute(sa.text("DELETE FROM orders"))
conn.execute(sa.text("DELETE FROM users"))
session.close()For larger schemas with many tables and foreign keys, use TRUNCATE with CASCADE:
def truncate_all_tables(engine, exclude=None):
"""Truncate all tables except those in exclude."""
if exclude is None:
exclude = []
with engine.begin() as conn:
# Get all table names
inspector = sa.inspect(engine)
tables = [t for t in inspector.get_table_names() if t not in exclude]
# Disable foreign key checks (Postgres doesn't need this, but good practice)
for table in tables:
conn.execute(sa.text(f"TRUNCATE TABLE {table} CASCADE"))
@pytest.fixture
def db_session(postgres_container):
engine = sa.create_engine(postgres_container.get_connection_string())
Session = sa.orm.sessionmaker(bind=engine)
session = Session()
yield session
truncate_all_tables(engine, exclude=["alembic_version"])
session.close()This automatically handles all tables and respects foreign key relationships. Exclude alembic_version so future tests don't think migrations need to run again.
Common mistakes
Starting a container per test: Don't use scope="function" for the container fixture unless you have a specific reason (e.g., testing container failure scenarios). Session scope is almost always faster.
Forgetting to wait for readiness: A container can be running but not ready to accept connections. Testcontainers handles this automatically with health checks, but if you bypass Testcontainers and use Docker directly, add explicit waits.
Hardcoding localhost: Always call get_container_host_ip() and get_exposed_port(). On CI runners or when Docker runs in a different network, localhost won't work.
Not truncating between tests: Shared state between tests causes flaky failures that are hard to debug. Always reset data after each test. If you forget, tests will pass or fail randomly depending on execution order.
Mixing mocks and real containers: If you mock some services and use Testcontainers for others, you'll still catch integration bugs. But be intentional about which layer you're testing. Don't mock the database while using a real one—pick one approach per test.
Key takeaways
- Testcontainers-python automates Docker container lifecycle in your test suite, giving you real Postgres, Redis, and other services without manual Docker Compose management.
- Use session-scoped fixtures to start containers once per test session and reuse them across all tests, saving seconds per test run.
- Run migrations once at the start of the session, not before every test, to keep your suite fast.
- Truncate or delete table data after each test to prevent data leakage and ensure tests are independent.
- Always use
get_container_host_ip()andget_exposed_port()instead of hardcoding localhost; this ensures your tests work locally and in CI. - Integration tests with real databases catch bugs that mocks never will; they're slower than unit tests but essential for production confidence.






Comments
Loading comments…