Understanding Wait Strategies in Testcontainers
A container being started does not mean it is ready to accept connections. Docker marks the container as running the moment the entrypoint process starts. PostgreSQL needs another second or two to initialize storage, bind to port 5432, and start accepting connections. If your test code tries to connect before the database is ready, you get Connection refused.
The wrong solution is Thread.sleep(5000). That is a fixed delay: too short on a slow machine, too long on a fast one. Testcontainers’ wait strategies solve this correctly — they poll for a specific readiness signal and proceed as soon as the container responds.
What You’ll Learn
- Why
Thread.sleep()fails as a wait mechanism Wait.forLogMessage()— wait for a specific log outputWait.forHttp()— wait for an HTTP health endpointWait.forListeningPort()— wait for a TCP port to be openWait.forHealthcheck()— use the container’s own Docker healthcheck- Combining multiple wait conditions
withStartupTimeout()for slow containers- Custom wait strategies
The Problem with Thread.sleep()
// DON'T DO THIS
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");
@BeforeAll
static void setup() {
postgres.start();
Thread.sleep(3000); // wait 3 seconds for postgres to start... probably
}
The problems with this approach:
It is non-deterministic. On a developer laptop with a warm Docker cache and no other load, 3 seconds is excessive. On a loaded CI server pulling the image for the first time, 3 seconds may not be enough. The failure rate is environment-dependent.
It is always wrong. Either your tests wait too long (wasting time) or not long enough (failing intermittently). There is no configuration that is correct for all environments.
It gives no diagnostic information. When Thread.sleep() is not enough, your test fails with Connection refused. You have no information about whether the container even started.
Testcontainers’ wait strategies poll for actual container readiness. They proceed immediately when the container is ready and fail with a clear timeout message if it never becomes ready.
Default Wait Strategy
Every GenericContainer and specialized module has a default wait strategy. For most containers, the default is Wait.forListeningPort() — wait until the primary exposed port accepts TCP connections.
PostgreSQLContainer, MySQLContainer, KafkaContainer, and other dedicated modules set smarter defaults. For example, PostgreSQLContainer uses a log message wait strategy by default. You generally do not need to configure a wait strategy for dedicated modules unless you need a longer timeout.
Wait.forLogMessage()
Wait.forLogMessage() waits until the container’s stdout/stderr log matches a regex pattern. This is the most reliable approach when the container logs a clear “ready” message.
GenericContainer<?> redis = new GenericContainer<>("redis:7-alpine")
.withExposedPorts(6379)
.waitingFor(
Wait.forLogMessage(".*Ready to accept connections.*\\n", 1)
);
The second parameter is the number of times the pattern must match. For Redis, you want one occurrence.
Common log message patterns:
| Container | Pattern |
|---|---|
| Redis | .*Ready to accept connections.*\\n |
| PostgreSQL | .*database system is ready to accept connections.*\\n |
| MySQL | .*ready for connections.*\\n |
| Kafka | .*started.*\\n |
| Elasticsearch | .*"message": "started".*\\n |
// Custom container using log wait
GenericContainer<?> customService = new GenericContainer<>("my-service:1.0")
.withExposedPorts(8080)
.waitingFor(
Wait.forLogMessage(".*Application started on port 8080.*\\n", 1)
.withStartupTimeout(Duration.ofSeconds(60))
);
The regex is matched against each line of container output. Use .* at start and end to handle any prefix/suffix in the log line.
Wait.forHttp()
Wait.forHttp() sends an HTTP request to the container and waits for a specific HTTP status code. This is ideal for containers that expose a health or readiness endpoint.
GenericContainer<?> appContainer = new GenericContainer<>("my-spring-app:1.0")
.withExposedPorts(8080)
.waitingFor(
Wait.forHttp("/actuator/health")
.forStatusCode(200)
.withStartupTimeout(Duration.ofSeconds(120))
);
More precise control over the expected response:
GenericContainer<?> apiContainer = new GenericContainer<>("api-service:2.0")
.withExposedPorts(9090)
.waitingFor(
Wait.forHttp("/health")
.forStatusCodeMatching(code -> code >= 200 && code < 300)
.forResponsePredicate(body -> body.contains("\"status\":\"UP\""))
.usingTls() // use HTTPS
.withMethod("GET")
.withHeader("Accept", "application/json")
);
Wait.forHttp() by default expects a 200 status code. Use .forStatusCode() or .forStatusCodeMatching() to accept a different code or range.
For WireMock containers, you wait for the admin endpoint:
WireMockContainer wireMock = new WireMockContainer("wiremock/wiremock:3.3.1")
.waitingFor(
Wait.forHttp("/__admin/health")
.forStatusCode(200)
);
Wait.forListeningPort()
Wait.forListeningPort() waits until the specified (or primary exposed) TCP port is accepting connections. This is the simplest wait strategy and works for any network service.
GenericContainer<?> memcached = new GenericContainer<>("memcached:1.6-alpine")
.withExposedPorts(11211)
.waitingFor(Wait.forListeningPort());
By default, Wait.forListeningPort() checks the first exposed port. To check a specific port:
.waitingFor(Wait.forListeningPort())
Limitation: a port being open does not guarantee the service is ready to handle requests. PostgreSQL binds to port 5432 slightly before it is ready to accept queries. For databases, Wait.forLogMessage() is more reliable.
Wait.forHealthcheck()
If the Docker image defines a HEALTHCHECK instruction, Wait.forHealthcheck() uses it:
GenericContainer<?> customDb = new GenericContainer<>("my-custom-db:1.0")
.waitingFor(Wait.forHealthcheck());
Testcontainers polls the container’s health status (via Docker’s health check mechanism) until it reports healthy. You control the health check polling interval and retries through Docker configuration in the image, not in Testcontainers.
This strategy is cleanest because the health check logic lives in the Docker image where the service authors defined it. It is less useful when you do not control the image or when the image does not define a health check.
Combining Multiple Wait Conditions
Wait.forAll() requires all conditions to pass:
GenericContainer<?> complexService = new GenericContainer<>("complex-service:1.0")
.withExposedPorts(8080, 9090)
.waitingFor(
new WaitAllStrategy()
.withStrategy(Wait.forListeningPort())
.withStrategy(
Wait.forHttp("/health").forStatusCode(200)
)
.withStartupTimeout(Duration.ofSeconds(90))
);
Wait.forAll() waits until all specified conditions are satisfied. This is useful when a container exposes multiple ports or endpoints that need to be independently ready.
withStartupTimeout()
All wait strategies have a default timeout of 60 seconds. For slow containers (Keycloak can take 30–60 seconds to start, for example), you need to extend it:
KeycloakContainer keycloak = new KeycloakContainer("quay.io/keycloak/keycloak:24.0")
.waitingFor(
Wait.forHttp("/realms/master")
.forStatusCode(200)
.withStartupTimeout(Duration.ofMinutes(2))
);
If the container does not become ready within the timeout, Testcontainers throws a ContainerLaunchException with the container logs attached. This makes debugging startup failures much easier than a Connection refused error.
Custom Wait Strategy
For advanced cases, extend AbstractWaitStrategy:
public class DatabaseSchemaWaitStrategy extends AbstractWaitStrategy {
private final String schemaName;
public DatabaseSchemaWaitStrategy(String schemaName) {
this.schemaName = schemaName;
}
@Override
protected void waitUntilReady() {
String jdbcUrl = "jdbc:postgresql://"
+ waitStrategyTarget.getHost()
+ ":" + waitStrategyTarget.getMappedPort(5432)
+ "/test";
Unreliables.retryUntilTrue(
(int) startupTimeout.getSeconds(),
TimeUnit.SECONDS,
() -> {
try (Connection conn = DriverManager.getConnection(jdbcUrl, "test", "test");
Statement stmt = conn.createStatement()) {
ResultSet rs = stmt.executeQuery(
"SELECT schema_name FROM information_schema.schemata WHERE schema_name = '"
+ schemaName + "'"
);
return rs.next();
} catch (Exception e) {
return false;
}
}
);
}
}
Use it:
PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine")
.waitingFor(new DatabaseSchemaWaitStrategy("orders"));
Custom strategies are rare in practice. The built-in strategies cover almost every case. Consider a custom strategy only when no built-in strategy can verify what “ready” means for your specific container.
Wait Strategies for Common Containers
The specialized container modules configure sensible defaults. You usually only need to customize the timeout. Here is a reference:
// PostgreSQL — already configured, but you can override
PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine")
.waitingFor(
Wait.forLogMessage(".*database system is ready to accept connections.*\\n", 2)
.withStartupTimeout(Duration.ofSeconds(60))
);
// MySQL
MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0")
.waitingFor(
Wait.forLogMessage(".*ready for connections.*\\n", 2)
);
// Kafka
KafkaContainer kafka = new KafkaContainer(
DockerImageName.parse("confluentinc/cp-kafka:7.6.1")
); // default wait strategy is sufficient
// RabbitMQ
RabbitMQContainer rabbitmq = new RabbitMQContainer("rabbitmq:3.12-management-alpine")
.waitingFor(
Wait.forLogMessage(".*Server startup complete.*\\n", 1)
);
// Redis
GenericContainer<?> redis = new GenericContainer<>("redis:7-alpine")
.withExposedPorts(6379)
.waitingFor(
Wait.forLogMessage(".*Ready to accept connections.*\\n", 1)
);
// Elasticsearch
ElasticsearchContainer elasticsearch = new ElasticsearchContainer(
DockerImageName.parse("docker.elastic.co/elasticsearch/elasticsearch:8.11.1")
); // defaults to HTTP wait on /_cluster/health?wait_for_status=green
// Keycloak (slow starter — needs longer timeout)
KeycloakContainer keycloak = new KeycloakContainer("quay.io/keycloak/keycloak:24.0")
.waitingFor(
Wait.forHttp("/realms/master")
.forStatusCode(200)
.withStartupTimeout(Duration.ofMinutes(2))
);
Common Pitfalls
Not anchoring log message regex. "ready" matches any log line containing the word “ready”. This may match too early. Use ".*database system is ready to accept connections.*\\n" to match the specific readiness message.
Forgetting the newline in log message patterns. Wait.forLogMessage() matches against lines including their newline character. If your pattern does not end with \\n, it may never match.
Setting the timeout too low for CI. Developer laptops pull cached images quickly. CI runners may be pulling the image for the first time over a slow connection. What takes 5 seconds on your machine may take 45 seconds in CI. Set generous timeouts (90–120 seconds for complex services) and let the wait strategy exit early when the container is ready.
Summary
Wait strategies are how Testcontainers knows when a container is actually ready to serve requests, not just started. Use Wait.forLogMessage() for databases and services that log a clear readiness message. Use Wait.forHttp() for services with a health endpoint. Use Wait.forListeningPort() as a fallback for simple TCP services. Always set an explicit withStartupTimeout() for containers that take more than 30 seconds to start.
The next article puts this all together with a complete PostgreSQL testing deep-dive — Spring Data JPA repositories, @ServiceConnection, @DataJpaTest slices, and transaction testing.