mrkeyoor.com_
Tue 22 Sept 18:48 UTC
PyPITestingupdated 22 Sept 2026

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.

Verdict

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

Lab card: what happened when we installed testcontainersScreenshot of testcontainers documentation
Install✓ · 0.3s10 packages on disk · 5 MB
Importimport testcontainers in 0.02s · pure Python · py.typed · requires Python >=3.10
Known vulns0(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.

API stability3/5The context-manager lifecycle, DockerContainer methods, exposed-port lookup, and service-specific connection helpers remain familiar. The 4.15 line is also moving callers toward structured wait strategies and a reorganized module layout while preserving legacy imports with deprecation notices. Pin the minor version and run fixture imports plus readiness behavior in CI before upgrading, especially for community modules.
Docs4/5The official documentation covers daemon discovery, rootless sockets, connection modes, Ryuk configuration, waits, networking, file transfer, command execution, Compose, and many service modules. The README gives a short PostgreSQL path and lists cleanup variables. Some examples and older articles still use legacy imports or wait helpers, so confirm current module paths and strategy classes against the installed release rather than copying snippets blindly.
Maintenance5/5Version 4.15.0 shipped on July 24, 2026 and finalized the CrateDB module after release candidates added Podman support, Docker-context discovery, typed-package coverage, container file copying, better timeout logs, and several community-module fixes. GitHub showed an August 21 push, 2,279 stars, 178 open issues and pull requests, and an unarchived repository with separate core and community test workflows.
Ecosystem5/5PyPI metadata lists extras for SQL databases, document stores, queues, cloud emulators, search engines, browsers, identity systems, vector databases, and other services, while DockerContainer covers unlisted images. The supplied weekly snapshot records 7,921,341 downloads. This breadth is useful, but each module still depends on an external image, a matching client driver, daemon behavior, and registry availability.

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.
Skip it if

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 == 1

Pin 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'))
        raise

get_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=true

Use this only when policy blocks Ryuk and the runner reliably removes containers, networks, and volumes after crashes.

Alternatives

PackageRegistryPick it when
pytest-dockerPyPIChoose it when checked-in Docker Compose services and pytest fixtures are the preferred control plane.
dockerPyPIChoose the Docker SDK directly when custom lifecycle behavior justifies implementing readiness and cleanup yourself.
pytest-postgresqlPyPIChoose 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.