testcontainers review
Testcontainers for Python starts temporary Docker-compatible services from tests and removes them afterward. Ready-made modules cover databases, brokers, cloud emulators, search systems, browsers, and other infrastructure; DockerContainer handles an arbitrary image. Version 4.15.0 adds the CrateDB community module after a release line that also introduced Podman handling, Docker-context discovery, typed-package coverage, copy-to-container support, and structured wait-strategy fixes. Our package import took 0.02 seconds, but a passing integration test still depends on a reachable container daemon and image startup.
testcontainers 4.15.0 installed in 0.3 seconds, used 5 MB, and imported in 0.02 seconds with 0 audit findings in our sandbox. Use it for the integration checks where a real service matters, while keeping fast unit tests independent of Docker.
We installed it
| Install | ✓ · 0.3s | 10 packages on disk · 5 MB |
| Import | ✓ | import testcontainers in 0.02s · pure Python · py.typed · requires Python >=3.10 |
| Known vulns | 0 | (pip-audit) |
Answers from our run
Does testcontainers install cleanly?
Yes. In a fresh container with an empty cache, pip install testcontainers finished in 0.3s, leaving 10 packages and 5 MB on disk. pip-audit reported no known vulnerabilities.
What does testcontainers need to run?
Python >=3.10, and nothing compiled: it is pure Python. In our run import testcontainers succeeded in 0.02s, and the package ships py.typed for type checkers.
testcontainers or pytest-docker: which should you use?
pytest-docker: Choose it when checked-in Docker Compose services and pytest fixtures are the preferred control plane. testcontainers 4.15.0 installed in 0.3 seconds, used 5 MB, and imported in 0.02 seconds with 0 audit findings in our sandbox.
When should you not use testcontainers?
The runner cannot access Docker or a compatible daemon. Installing the Python wheel does not install or start that service.
Use it if
- A test must exercise real PostgreSQL, Redis, Kafka, LocalStack, or another service protocol rather than an in-memory substitute.
- Parallel jobs need isolated containers and random mapped host ports instead of one shared development database.
- Python fixtures should own image configuration, readiness, connection URLs, logs, and cleanup in one place.
- Migration, driver, configuration, or protocol behavior is important enough to justify slower integration tests.
- The runner cannot access Docker or a compatible daemon. Installing the Python wheel does not install or start that service.
- Most tests need subsecond feedback. Cold image pulls, Ryuk startup, service initialization, and teardown belong in a smaller integration layer.
- Security policy forbids a daemon socket, privileged cleanup sidecars, or remote container control. These are environmental requirements, not settings the library can hide.
- A deterministic fake can reproduce the few failure modes under test. Real containers are harder to force into timeouts, malformed responses, and rare service states.
- Your team will not pin image tags and manage registry availability. An unpinned or missing image can change behavior or fail before application assertions run.
Setup reality
We installed testcontainers 4.15.0 in a fresh Python 3.12 Bookworm container. pip completed in 0.3 seconds, left ten packages, and used 5 MB on disk. The package declares 52 direct dependencies, requires Python 3.10 or newer, is pure Python, and includes py.typed. import testcontainers worked in 0.02 seconds. pip-audit found no known vulnerabilities. Package metadata labels the license as Apache Software License.
No container started during that import check. Tests still need a reachable Docker-compatible daemon, permission to create containers and networks, and registry access to pull images. Local Docker Desktop often needs little configuration; rootless Docker, Podman, remote TLS daemons, and Docker-in-Docker need explicit host and socket choices. Version 4.15 improves current Docker-context and Podman detection, but CI topology still decides which environment variables are correct.
Ryuk runs as a cleanup sidecar by default. Some locked-down runners block it or expose a socket path different from the client path. TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE configures the sidecar view. Disabling Ryuk transfers cleanup responsibility to the runner, including resources left after a killed test process. Private images also need Docker registry credentials, and cold or rate-limited pulls can dominate a suite's first run.
A running container is not proof that its service accepts requests. Use a service-specific LogMessageWaitStrategy, HttpWaitStrategy, or other structured strategy and set a realistic timeout. Pin image tags, ask the container for its mapped host port, and use context managers or fixture finalizers. Module extras bring the client drivers they declare; verify the connection URL dialect matches what is installed. Capture logs before teardown when assertions fail.
Patterns
Run a PostgreSQL assertion test-postgresql
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 and install the database driver named by the generated SQLAlchemy URL.
Expose an arbitrary container port start-generic-image
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 returned host mapping. Hardcoded host ports make parallel jobs collide.
Set environment and command configure-container
from testcontainers.core.container import DockerContainer
redis = (
DockerContainer('redis:7-alpine')
.with_env('LOG_LEVEL', 'warning')
.with_command(['redis-server', '--save', ''])
)Environment values remain visible in Docker metadata, so use a secret-specific mechanism for sensitive data.
Wait for a stable startup log wait-for-log-message
from testcontainers.core.container import DockerContainer
from testcontainers.core.wait_strategies import LogMessageWaitStrategy
api = (
DockerContainer('my-api:test')
.waiting_for(LogMessageWaitStrategy(r'Listening on .*:8080'))
)The message is a regular expression. Choose a line emitted only after the service is ready.
Poll an HTTP readiness endpoint wait-for-http-health
from testcontainers.core.container import DockerContainer
from testcontainers.core.wait_strategies import HttpWaitStrategy
api = (
DockerContainer('my-api:test')
.with_exposed_ports(8080)
.waiting_for(HttpWaitStrategy(8080, '/health'))
)Container process startup can precede HTTP readiness. Set a timeout that accounts for cold CI starts.
Share one database per test session provide-pytest-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 data. Reset schemas or use function scope when isolation is more important.
Run a command inside a container execute-container-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'Pass a list for explicit argv. Shell operators require an explicit shell as shown.
Print logs before teardown collect-failure-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 bytes. Read them while the container still exists.
Mount a host configuration mount-readonly-config
from pathlib import Path
from testcontainers.core.container import DockerContainer
server = DockerContainer('nginx:1.27-alpine').with_volume_mapping(
Path('tests/nginx.conf').resolve(),
'/etc/nginx/nginx.conf',
mode='ro',
)The daemon must see the host path. Remote and nested Docker setups may need copying instead of a bind mount.
Copy test data into an image copy-file-to-container
from testcontainers.core.container import DockerContainer
container = DockerContainer('alpine:3.21').with_copy_to(
'tests/fixture.json',
'/data/fixture.json',
)with_copy_to is part of the 4.15 release line and avoids relying on daemon-visible host bind paths.
Address a service by network alias connect-container-network
from testcontainers.core.container import DockerContainer
from testcontainers.core.network import Network
with Network() as network:
cache = 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 cache, app:
run_checks(app)Container-to-container traffic uses the alias and container port, not the mapped host port.
Hand cleanup to the CI runner disable-ryuk-explicitly
TESTCONTAINERS_RYUK_DISABLED=trueUse this only when policy blocks Ryuk and the runner reliably removes containers, networks, and volumes after crashes.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| pytest-docker | PyPI | Choose it when checked-in Docker Compose services and pytest fixtures are the preferred control plane. |
| docker | PyPI | Choose the Docker SDK directly when custom lifecycle behavior justifies implementing readiness and cleanup yourself. |
| pytest-postgresql | PyPI | Choose it for PostgreSQL-focused fixtures when a general container framework is unnecessary. |
More testing guides
pytest · chai · jsdom · vitest · playwright · coverage · the whole shelf →
How this guide is made: grounded in the library's documentation, release notes, changelog, and issue history, on a fixed rubric — not a hands-on install of every release. The 50 most-downloaded entries are additionally install-verified in clean containers. Corrections: contact the desk.

