time.sleep(10) after container.start() is the most common line in a Testcontainers fixture and the source of most of their flakiness. It is too long on a warm laptop, where the service was ready in two seconds, and too short on a loaded CI runner, where it needed twelve. Every service has a better signal than elapsed time, and waiting on it makes start-up both faster and reliable.
Prerequisites
testcontainers >= 4.0.- Knowledge of what the service prints or answers when it is genuinely ready — its documentation or one manual
docker logsrun is enough. pytest >= 8.0, and the lifecycle model from spinning up services with Testcontainers.
Solution
Layer two signals: a log line the service prints when it believes it is ready, then one real request that proves it.
import time
import pytest
import redis
from testcontainers.core.container import DockerContainer
from testcontainers.core.waiting_utils import wait_for_logs
def wait_for_request(check, *, timeout=30.0, interval=0.2, what="service"):
"""Retry a real request until it succeeds or the deadline passes."""
deadline = time.monotonic() + timeout
last_error = None
while time.monotonic() < deadline:
try:
check()
return
except Exception as exc: # connection refused, reset, …
last_error = exc
time.sleep(interval)
raise TimeoutError(f"{what} not ready after {timeout}s: {last_error!r}")
@pytest.fixture(scope="session")
def redis_client():
container = DockerContainer("redis:7.4-alpine").with_exposed_ports(6379)
with container:
# 1. The service's own "I am ready" message.
wait_for_logs(container, "Ready to accept connections", timeout=30)
client = redis.Redis(
host=container.get_container_host_ip(),
port=int(container.get_exposed_port(6379)),
)
# 2. Belt and braces: one real round trip before any test runs.
wait_for_request(client.ping, what="redis")
yield client
Why this works
wait_for_logs tails the container's output and returns the moment a regex matches, so it costs exactly as long as start-up takes. The subsequent real request closes the small window where a service logs readiness slightly before it can serve — Postgres, for instance, prints "ready to accept connections" twice during initialisation, once for a temporary server used by the entrypoint scripts and once for the real one.
Both waits are bounded. A service that never becomes ready fails the session within thirty seconds with a message naming the service and the last error, instead of running until the CI job's global limit and dying silently.
Edge cases and failure modes
- A log line printed twice. Postgres's double "ready" message is the classic case; matching the first one connects to a server about to restart. The official module handles this; a hand-rolled fixture must match the second occurrence or confirm with a query.
- A pattern that never matches after an upgrade. Services reword their startup messages between versions. Pin the image tag and keep the real-request confirmation as a backstop.
- Readiness checks against
localhost. Under Docker-in-Docker the container's host differs.get_container_host_ip()is correct everywhere. - Health checks defined in the image. Some images declare a Docker
HEALTHCHECK; polling the container's health status is a good signal where it exists, and it reflects whatever the image author considered ready. - Retrying on every exception. Catching
Exceptionin the retry loop also hides configuration errors such as a wrong password. Retry on connection errors only, and let authentication failures surface immediately.
Diagnosing a service that never becomes ready
When the wait times out, the most important evidence is the container's own output, which disappears with the container unless the fixture captures it first.
import pytest
from testcontainers.core.container import DockerContainer
from testcontainers.core.waiting_utils import wait_for_logs
@pytest.fixture(scope="session")
def search_service():
container = DockerContainer("ghcr.io/acme/search:3.2.0").with_exposed_ports(9200)
with container:
try:
wait_for_logs(container, r"started \[node=", timeout=90)
except Exception:
stdout, stderr = container.get_logs()
# Put the reason in the test output before the container is removed.
pytest.fail(
"search service never became ready.\n"
f"--- stdout ---\n{stdout.decode()[-4000:]}\n"
f"--- stderr ---\n{stderr.decode()[-4000:]}",
pytrace=False,
)
yield container
The last four thousand characters of the logs almost always contain the answer: an out-of-memory kill, a missing environment variable, a port conflict inside the container, a licence check. Without them the failure is "timed out after 90s" and a re-run with extra instrumentation; with them it is usually a one-line fix.
Readiness for services that depend on each other
A single container is simple. A stack — an application that needs a database, a broker and a cache — adds ordering, and the temptation is a sleep between each start. The better arrangement starts everything in parallel and waits on each dependency's own signal before starting whatever needs it.
import concurrent.futures
import pytest
@pytest.fixture(scope="session")
def stack(postgres, redis_client, kafka):
# postgres, redis_client and kafka are independent session fixtures,
# each waiting on its own readiness signal. pytest starts them in
# dependency order; the app fixture below only runs once all three yield.
return {"db": postgres, "cache": redis_client, "broker": kafka}
@pytest.fixture(scope="session")
def app_container(stack):
container = build_app_container(env=connection_env(stack))
with container:
wait_for_request(lambda: http_get(container, "/health"), what="app", timeout=60)
yield container
Fixture dependencies already express the ordering: the application fixture requests the stack, the stack requests each service, and pytest resolves them depth-first. Each service fixture owns its own readiness wait, so by the time the application starts every dependency is proven usable — no sleeps, no guessing, and each failure attributed to the service that caused it.
One refinement is worth making once the stack grows past three or four services: start the independent containers concurrently rather than one after another. pytest resolves session fixtures sequentially, so three services with four-second start-ups cost twelve seconds in series. Starting them together from a single fixture with a thread pool — each thread calling start() and then its own readiness wait — brings that down to roughly the slowest single start-up, and every readiness failure is still reported against the service it belongs to because each wait raises its own named error. It is a small change with a large effect on the feedback loop.
Using the image's own health check
Many images ship a Docker HEALTHCHECK — a command the daemon runs periodically inside the container, whose result appears as the container's health status. Where it exists it is an excellent readiness signal, because it encodes the image author's definition of ready and it keeps working after the service's log messages change.
import time
import docker
def wait_until_healthy(container_id: str, *, timeout: float = 60.0) -> None:
client = docker.from_env()
deadline = time.monotonic() + timeout
status = "unknown"
while time.monotonic() < deadline:
state = client.api.inspect_container(container_id)["State"]
status = state.get("Health", {}).get("Status", "none")
if status == "healthy":
return
if status == "unhealthy" or not state["Running"]:
break # fail fast; waiting longer is pointless
time.sleep(0.5)
raise TimeoutError(f"container never became healthy (last status: {status})")
The early exit on unhealthy or a stopped container is the refinement worth copying. A service that has crashed will never become ready, and a readiness loop that keeps polling until its timeout wastes a minute of every failing run to report something that was knowable after two seconds.
For images without a declared health check, adding one in a small test-only Dockerfile is sometimes cleaner than encoding readiness in Python — particularly when several suites use the same image. The health command then lives with the image, and every consumer gets the same definition of ready without each writing its own poll.
Whichever signal is chosen, write it down next to the fixture with the reason. "Waits for the second 'ready to accept connections' line because the first comes from the init server" is the comment that stops the next person from simplifying the pattern and reintroducing the flake it was written to remove.
Frequently Asked Questions
Why is 'the container is running' not the same as 'the service is ready'? Docker reports a container as running once its entry process has started. The service inside may still be initialising a data directory, replaying a log, or waiting for a cluster to form, and connections during that window are refused or reset. Readiness has to be checked against the service itself.
Which readiness signal is most reliable?
A real request that exercises the service: SELECT 1 for a database, a PING for Redis, a metadata request for Kafka. A log line is a good second choice because it is emitted by the service when it believes it is ready. An open port is the weakest signal, since many services bind before they can serve.
How long should the readiness timeout be? Long enough for a cold start on a loaded runner — thirty to sixty seconds for most databases, longer for Kafka or Elasticsearch — because it only matters when something is wrong. The poll returns as soon as the service is ready, so a generous timeout costs nothing on healthy runs.
Related
- Spinning Up Services with Testcontainers — lifecycles and reaping around these waits.
- Starting Postgres with testcontainers-python — a fixture where the official module already handles readiness.
- Replacing Sleep-Based Waits with Polling Assertions — the same principle inside test bodies.
- Capturing Artifacts from a Failed CI Test Run — keeping container logs beyond the fixture.
← Back to Spinning Up Services with Testcontainers