testcontainers
Testcontainers for Python starts disposable Docker containers from tests and tears them down afterward. It includes ready-made classes for databases, queues, browsers, cloud-service emulators, and other infrastructure, plus a DockerContainer base for arbitrary images. The payoff is testing against the real service and protocol instead of an in-memory substitute. It is test orchestration, not a mocking library, and Docker availability is part of every test run.
Use it when fidelity to a real dependency is worth Docker startup cost and CI plumbing. Do not make every test a container test; keep the unit suite cheap and reserve this layer for behavior that fakes routinely miss.
Use it if
- Your integration tests need the behavior of a real PostgreSQL, Redis, Kafka, LocalStack, or another Dockerized service
- You want isolated random host ports so developers and parallel CI jobs do not fight over fixed infrastructure
- You need service-specific connection URLs and readiness checks instead of maintaining shell scripts around docker run
- Your team accepts slower integration tests in exchange for finding migration, driver, protocol, and configuration bugs
- Your CI runners cannot reach a Docker-compatible daemon or are forbidden to mount its socket: host detection ends in DockerHostError when no daemon is discoverable
- You need a fast unit-test loop: every cold run may pull images, start the Ryuk cleanup container, wait for services, and then remove resources
- Your tests must work identically on restricted rootless, remote, or Docker-in-Docker setups without environment-specific wiring: the docs expose separate host, socket, TLS, and connection-mode settings because auto-detection is not universal
- You only need a small fake with deterministic edge cases: a purpose-built fake is faster and easier to force into failures than a real service container
- You cannot pin and maintain container image tags: module defaults such as PostgresContainer use latest, so unpinned tests can change underneath you when an image is refreshed
Setup reality
`pip install testcontainers` installs the Python wrapper and its Docker SDK dependencies, but it does not install or start Docker. Python 4.15.0 requires Python 3.10 or newer, and the process must be able to find and access a Docker-compatible daemon. Local Docker Desktop often works immediately; CI, rootless Docker, remote daemons, and Docker-in-Docker are where setup expands. Host discovery considers `TC_HOST`, `TESTCONTAINERS_HOST_OVERRIDE`, `DOCKER_HOST`, Docker contexts, default sockets, and rootless socket locations. Containers such as Ryuk and Docker Compose may also need `TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE` because the socket path visible inside a container can differ from the client path. Ryuk starts by default as a cleanup sidecar; hardened runners may block it, while disabling it with `TESTCONTAINERS_RYUK_DISABLED` transfers orphan cleanup to you. Private images need Docker credentials, and first runs pay image-pull time. Service modules often have optional client dependencies, so install the appropriate extra or database driver, not just the base package. Postgres URLs default to the psycopg2 driver name, for example, and will fail if that driver is absent. Always pin image tags, use context managers or fixtures with finalizers, expose random host ports, and add a service-specific wait strategy. A running container is not necessarily a ready service. In parallel CI, fixed ports, shared networks, rate-limited registries, disk pressure, and leaked containers are the common surprises.
Patterns
Run a PostgreSQL integration testtest-postgres
import sqlalchemy
from testcontainers.postgres import PostgresContainer
with PostgresContainer('postgres:16') as postgres:
engine = sqlalchemy.create_engine(postgres.get_connection_url())
with engine.begin() as connection:
value = connection.scalar(sqlalchemy.text('select 1'))
assert value == 1Pin the image tag. The generated URL defaults to a psycopg2 dialect, so install that driver or select another supported driver.
Start an arbitrary image on a random portrun-generic-container
from testcontainers.core.container import DockerContainer
with DockerContainer('nginx:1.27-alpine').with_exposed_ports(80) as nginx:
host = nginx.get_container_host_ip()
port = nginx.get_exposed_port(80)
url = f'http://{host}:{port}'Use the mapped host port returned after startup; hardcoding 80 or a fixed host binding breaks parallel runs.
Pass environment variables and a commandset-container-environment
from testcontainers.core.container import DockerContainer
container = (
DockerContainer('redis:7-alpine')
.with_env('ALLOW_EMPTY_PASSWORD', 'yes')
.with_command(['redis-server', '--save', ''])
)List commands avoid shell-tokenization surprises; secrets passed as environment variables remain inspectable in Docker metadata.
Wait for a readiness logwait-for-log
from testcontainers.core.container import DockerContainer
from testcontainers.core.wait_strategies import LogMessageWaitStrategy
container = (
DockerContainer('my-api:test')
.waiting_for(LogMessageWaitStrategy(r'Listening on .*:8080'))
)The message is treated as a regular expression. Match a stable readiness line, not a log that appears before dependencies are ready.
Wait for an HTTP health endpointwait-for-http
from testcontainers.core.container import DockerContainer
from testcontainers.core.wait_strategies import HttpWaitStrategy
container = (
DockerContainer('my-api:test')
.with_exposed_ports(8080)
.waiting_for(HttpWaitStrategy(8080, '/health'))
)The default accepted status is 200; use a startup timeout appropriate to cold CI image pulls and service initialization.
Share a container with a pytest fixturepytest-fixture
import pytest
from testcontainers.postgres import PostgresContainer
@pytest.fixture(scope='session')
def postgres_url():
with PostgresContainer('postgres:16') as postgres:
yield postgres.get_connection_url()Session scope saves startup time but shares database state; reset schemas or use function scope when test isolation matters more.
Execute a command inside the containerexecute-command
from testcontainers.core.container import DockerContainer
with DockerContainer('alpine:3.21') as container:
result = container.exec(['sh', '-c', 'printf ready'])
assert result.exit_code == 0
assert result.output == b'ready'A list is passed as argv; a plain string is tokenized and is not automatically interpreted as a shell pipeline.
Capture stdout and stderr after failureinspect-logs
with container:
try:
run_assertions(container)
except Exception:
stdout, stderr = container.get_logs()
print(stdout.decode(errors='replace'))
print(stderr.decode(errors='replace'))
raiseget_logs returns two byte strings. Capture them before the context manager removes the container.
Mount a host configuration filemount-file
from pathlib import Path
from testcontainers.core.container import DockerContainer
container = DockerContainer('nginx:1.27-alpine').with_volume_mapping(
Path('tests/nginx.conf').resolve(),
'/etc/nginx/nginx.conf',
mode='ro',
)The Docker daemon must be able to see the host path; remote daemons and Docker-in-Docker often require a different transfer strategy.
Connect containers by network aliasnetwork-containers
from testcontainers.core.container import DockerContainer
from testcontainers.core.network import Network
with Network() as network:
db = DockerContainer('redis:7-alpine').with_network(network).with_network_aliases('cache')
app = DockerContainer('my-app:test').with_network(network).with_env('REDIS_URL', 'redis://cache:6379')
with db, app:
run_checks(app)Use the container port and alias for container-to-container traffic; mapped host ports are for the test process on the host.
Point CI at a remote Docker daemonconfigure-docker-host
# CI environment
DOCKER_HOST=tcp://docker:2376
DOCKER_TLS_VERIFY=1
DOCKER_CERT_PATH=/run/docker-certs
TESTCONTAINERS_CONNECTION_MODE=docker_hostRemote TLS setup also needs valid client certificates, and Ryuk may require a separate socket override depending on the runner topology.
Disable the cleanup sidecar when policy blocks itdisable-ryuk
# Only when the runner supplies its own reliable cleanup
TESTCONTAINERS_RYUK_DISABLED=trueDisabling Ryuk can leave containers, networks, and volumes behind after crashes; replace it with runner-level cleanup rather than ignoring leaks.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| pytest-docker | PyPI | You prefer pytest fixtures around a checked-in Docker Compose file and need less service-specific Python API |
| docker | PyPI | You want direct Docker SDK control and are willing to implement readiness, lifecycle, and cleanup yourself |
| pytest-testcontainers | PyPI | You want a smaller pytest-oriented container fixture package for simpler test setups |