Skip to content

Commit 5e9bfff

Browse files
authored
Use configured health checks as the default wait strategy (#1096)
1 parent 7f40327 commit 5e9bfff

17 files changed

Lines changed: 545 additions & 45 deletions

docs/features/compose.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,12 +45,13 @@ const environment = await new DockerComposeEnvironment(composeFilePath, composeF
4545

4646
### With a default wait strategy
4747

48-
By default Testcontainers uses the "listening ports" wait strategy for all containers. If you'd like to override
49-
the default wait strategy for all services, you can do so:
48+
By default, Testcontainers waits for a service health check when one is defined in the Compose service or image. If no health check is defined, or the service disables health checks, it waits for listening ports.
49+
50+
If you'd like to override the default wait strategy for all services, you can do so:
5051

5152
```js
5253
const environment = await new DockerComposeEnvironment(composeFilePath, composeFile)
53-
.withDefaultWaitStrategy(Wait.forHealthCheck())
54+
.withDefaultWaitStrategy(Wait.forListeningPorts())
5455
.up();
5556
```
5657

docs/features/wait-strategies.md

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,15 @@ const container = await new GenericContainer("alpine")
1010
.start();
1111
```
1212

13+
## Default wait strategy
14+
15+
By default, Testcontainers waits for a container health check when one is defined by the image or configured with `withHealthCheck`. If no health check is defined, or the image disables health checks with `HEALTHCHECK NONE`, it waits up to 60 seconds for mapped network ports to be bound.
16+
17+
You can override this selection with `withWaitStrategy`.
18+
1319
## Listening ports
1420

15-
The default wait strategy used by Testcontainers. It will wait up to 60 seconds for the container's mapped network ports to be bound.
21+
Wait up to 60 seconds for the container's mapped network ports to be bound.
1622

1723
```js
1824
const { GenericContainer } = require("testcontainers");
@@ -65,7 +71,7 @@ const container = await new GenericContainer("alpine")
6571

6672
## Health check
6773

68-
Wait until the container's health check is successful:
74+
Explicitly wait until the container's health check is successful. This is optional when the image already defines a health check because Testcontainers uses that as the default wait strategy:
6975

7076
```js
7177
const { GenericContainer, Wait } = require("testcontainers");
@@ -75,10 +81,10 @@ const container = await new GenericContainer("alpine")
7581
.start();
7682
```
7783

78-
Define your own health check. Note that time units are in seconds:
84+
Define your own health check. Testcontainers uses this as the default wait strategy unless you explicitly set another wait strategy. Note that time units are in milliseconds:
7985

8086
```js
81-
const { GenericContainer, Wait } = require("testcontainers");
87+
const { GenericContainer } = require("testcontainers");
8288

8389
const container = await new GenericContainer("alpine")
8490
.withHealthCheck({
@@ -88,7 +94,6 @@ const container = await new GenericContainer("alpine")
8894
retries: 5,
8995
startPeriod: 1000,
9096
})
91-
.withWaitStrategy(Wait.forHealthCheck())
9297
.start();
9398
```
9499

packages/modules/kafka/src/kafka-container.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ export class KafkaContainer extends GenericContainer {
6161
private zooKeeperHost?: string;
6262
private zooKeeperPort?: number;
6363
private saslSslConfig?: SaslSslListenerOptions;
64-
private originalWaitinStrategy: WaitStrategy;
64+
private originalWaitStrategy: WaitStrategy | undefined;
6565

6666
constructor(image: string) {
6767
super(image);
@@ -81,7 +81,7 @@ export class KafkaContainer extends GenericContainer {
8181
KAFKA_GROUP_INITIAL_REBALANCE_DELAY_MS: "0",
8282
KAFKA_CONFLUENT_SUPPORT_METRICS_ENABLE: "false",
8383
});
84-
this.originalWaitinStrategy = this.waitStrategy;
84+
this.originalWaitStrategy = this.waitStrategy;
8585
}
8686

8787
public withZooKeeper(host: string, port: number): this {
@@ -138,7 +138,7 @@ export class KafkaContainer extends GenericContainer {
138138

139139
// Change the wait strategy to wait for a log message from a fake starter script
140140
// so that we can put a real starter script in place at that moment
141-
this.originalWaitinStrategy = this.waitStrategy;
141+
this.originalWaitStrategy = this.waitStrategy;
142142
this.waitStrategy = Wait.forLogMessage(WAIT_FOR_SCRIPT_MESSAGE);
143143
this.withEntrypoint(["sh"]);
144144
this.withCommand([
@@ -190,10 +190,15 @@ export class KafkaContainer extends GenericContainer {
190190

191191
const client = await getContainerRuntimeClient();
192192
const dockerContainer = client.container.getById(container.getId());
193+
const dockerInspectResult = await client.container.inspect(dockerContainer);
193194
const boundPorts = BoundPorts.fromInspectResult(client.info.containerRuntime.hostIps, inspectResult).filter(
194195
this.exposedPorts
195196
);
196-
await waitForContainer(client, dockerContainer, this.originalWaitinStrategy, boundPorts);
197+
const waitStrategy = await this.selectWaitStrategy(client, dockerInspectResult, this.originalWaitStrategy);
198+
if (this.startupTimeoutMs !== undefined) {
199+
waitStrategy.withStartupTimeout(this.startupTimeoutMs);
200+
}
201+
await waitForContainer(client, dockerContainer, waitStrategy, boundPorts);
197202

198203
if (this.saslSslConfig && this.mode !== KafkaMode.KRAFT) {
199204
await this.createUser(container, this.saslSslConfig.sasl);

packages/modules/redpanda/src/redpanda-container.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ const STARTER_SCRIPT = "/testcontainers_start.sh";
2121
const WAIT_FOR_SCRIPT_MESSAGE = "Waiting for script...";
2222

2323
export class RedpandaContainer extends GenericContainer {
24-
private originalWaitinStrategy: WaitStrategy;
24+
private originalWaitStrategy: WaitStrategy | undefined;
2525

2626
constructor(image: string) {
2727
super(image);
@@ -34,7 +34,7 @@ export class RedpandaContainer extends GenericContainer {
3434
target: "/etc/redpanda/.bootstrap.yaml",
3535
},
3636
]);
37-
this.originalWaitinStrategy = this.waitStrategy;
37+
this.originalWaitStrategy = this.waitStrategy;
3838
}
3939

4040
public override async start(): Promise<StartedRedpandaContainer> {
@@ -44,7 +44,7 @@ export class RedpandaContainer extends GenericContainer {
4444
protected override async beforeContainerCreated(): Promise<void> {
4545
// Change the wait strategy to wait for a log message from a fake starter script
4646
// so that we can put a real starter script in place at that moment
47-
this.originalWaitinStrategy = this.waitStrategy;
47+
this.originalWaitStrategy = this.waitStrategy;
4848
this.waitStrategy = Wait.forLogMessage(WAIT_FOR_SCRIPT_MESSAGE);
4949
this.withEntrypoint(["sh"]);
5050
this.withCommand([
@@ -68,10 +68,15 @@ export class RedpandaContainer extends GenericContainer {
6868

6969
const client = await getContainerRuntimeClient();
7070
const dockerContainer = client.container.getById(container.getId());
71+
const dockerInspectResult = await client.container.inspect(dockerContainer);
7172
const boundPorts = BoundPorts.fromInspectResult(client.info.containerRuntime.hostIps, inspectResult).filter(
7273
this.exposedPorts
7374
);
74-
await waitForContainer(client, dockerContainer, this.originalWaitinStrategy, boundPorts);
75+
const waitStrategy = await this.selectWaitStrategy(client, dockerInspectResult, this.originalWaitStrategy);
76+
if (this.startupTimeoutMs !== undefined) {
77+
waitStrategy.withStartupTimeout(this.startupTimeoutMs);
78+
}
79+
await waitForContainer(client, dockerContainer, waitStrategy, boundPorts);
7580
}
7681

7782
private renderRedpandaFile(host: string, port: number): string {
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
version: "3.5"
2+
3+
services:
4+
container:
5+
image: cristianrgreco/testcontainer:1.1.14
6+
command: ["sh", "-c", "rm -f /tmp/ready; (sleep 4; touch /tmp/ready) & node index.js"]
7+
ports:
8+
- 8080
9+
healthcheck:
10+
test: "test -f /tmp/ready"
11+
interval: 1s
12+
timeout: 1s
13+
retries: 10

packages/testcontainers/src/docker-compose-environment/docker-compose-environment.test.ts

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { PullPolicy } from "../utils/pull-policy";
55
import {
66
checkEnvironmentContainerIsHealthy,
77
getDockerEventStream,
8+
getHealthCheckStatus,
89
getRunningContainerNames,
910
getVolumeNames,
1011
waitForDockerEvent,
@@ -88,11 +89,22 @@ describe("DockerComposeEnvironment", { timeout: 180_000 }, () => {
8889
expect(responseBody["IS_OVERRIDDEN"]).toBe("true");
8990
});
9091

91-
it("should support default wait strategy", async () => {
92-
await using startedEnvironment = await new DockerComposeEnvironment(fixtures, "docker-compose-with-healthcheck.yml")
93-
.withDefaultWaitStrategy(Wait.forHealthCheck())
94-
.up();
92+
it("should support configuring a default wait strategy", async () => {
93+
await using startedEnvironment = await new DockerComposeEnvironment(fixtures, "docker-compose.yml")
94+
.withDefaultWaitStrategy(Wait.forLogMessage("Listening on port 8080"))
95+
.up(["container"]);
96+
97+
await checkEnvironmentContainerIsHealthy(startedEnvironment, "container-1");
98+
});
99+
100+
it("should wait for a healthcheck defined in a service", async () => {
101+
await using startedEnvironment = await new DockerComposeEnvironment(
102+
fixtures,
103+
"docker-compose-with-delayed-healthcheck.yml"
104+
).up();
105+
const container = startedEnvironment.getContainer("container-1");
95106

107+
expect(await getHealthCheckStatus(container)).toBe("healthy");
96108
await checkEnvironmentContainerIsHealthy(startedEnvironment, "container-1");
97109
});
98110

packages/testcontainers/src/docker-compose-environment/docker-compose-environment.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { Environment } from "../types";
77
import { BoundPorts } from "../utils/bound-ports";
88
import { mapInspectResult } from "../utils/map-inspect-result";
99
import { ImagePullPolicy, PullPolicy } from "../utils/pull-policy";
10-
import { Wait } from "../wait-strategies/wait";
10+
import { selectWaitStrategy } from "../wait-strategies/utils/wait-strategy-selector";
1111
import { waitForContainer } from "../wait-strategies/wait-for-container";
1212
import { WaitStrategy } from "../wait-strategies/wait-strategy";
1313
import { StartedDockerComposeEnvironment } from "./started-docker-compose-environment";
@@ -24,7 +24,7 @@ export class DockerComposeEnvironment {
2424
private profiles: string[] = [];
2525
private environment: Environment = {};
2626
private pullPolicy: ImagePullPolicy = PullPolicy.defaultPolicy();
27-
private defaultWaitStrategy: WaitStrategy = Wait.forListeningPorts();
27+
private defaultWaitStrategy: WaitStrategy | undefined;
2828
private waitStrategy: { [containerName: string]: WaitStrategy } = {};
2929
private startupTimeoutMs?: number;
3030
private clientOptions: Partial<ComposeOptions> = {};
@@ -174,9 +174,11 @@ export class DockerComposeEnvironment {
174174
const inspectResult = await client.container.inspect(container);
175175
const mappedInspectResult = mapInspectResult(inspectResult);
176176
const boundPorts = BoundPorts.fromInspectResult(client.info.containerRuntime.hostIps, mappedInspectResult);
177-
const waitStrategy = this.waitStrategy[containerName]
178-
? this.waitStrategy[containerName]
179-
: this.defaultWaitStrategy;
177+
const waitStrategy = await selectWaitStrategy({
178+
client,
179+
inspectResult,
180+
waitStrategy: this.waitStrategy[containerName] ?? this.defaultWaitStrategy,
181+
});
180182
if (this.startupTimeoutMs !== undefined) {
181183
waitStrategy.withStartupTimeout(this.startupTimeoutMs);
182184
}

packages/testcontainers/src/generic-container/generic-container.ts

Lines changed: 28 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import archiver from "archiver";
22
import AsyncLock from "async-lock";
3-
import { Container, ContainerCreateOptions, HostConfig } from "dockerode";
3+
import { Container, ContainerCreateOptions, ContainerInspectInfo, HostConfig } from "dockerode";
44
import { promises as fs } from "fs";
55
import { Readable } from "stream";
66
import { containerLog, hash, log, toNanos } from "../common";
@@ -31,7 +31,7 @@ import { createLabels, LABEL_TESTCONTAINERS_CONTAINER_HASH, LABEL_TESTCONTAINERS
3131
import { mapInspectResult } from "../utils/map-inspect-result";
3232
import { getContainerPort, getProtocol, hasHostBinding, PortWithOptionalBinding } from "../utils/port";
3333
import { ImagePullPolicy, PullPolicy } from "../utils/pull-policy";
34-
import { Wait } from "../wait-strategies/wait";
34+
import { selectWaitStrategy } from "../wait-strategies/utils/wait-strategy-selector";
3535
import { waitForContainer } from "../wait-strategies/wait-for-container";
3636
import { WaitStrategy } from "../wait-strategies/wait-strategy";
3737
import { GenericContainerBuilder } from "./generic-container-builder";
@@ -50,7 +50,7 @@ export class GenericContainer implements TestContainer {
5050

5151
protected imageName: ImageName;
5252
protected startupTimeoutMs?: number;
53-
protected waitStrategy: WaitStrategy = Wait.forListeningPorts();
53+
protected waitStrategy: WaitStrategy | undefined;
5454
protected environment: Record<string, string> = {};
5555
protected exposedPorts: PortWithOptionalBinding[] = [];
5656
protected reuse = false;
@@ -121,6 +121,20 @@ export class GenericContainer implements TestContainer {
121121
return this.startContainer(client);
122122
}
123123

124+
protected async selectWaitStrategy(
125+
client: ContainerRuntimeClient,
126+
inspectResult: ContainerInspectInfo,
127+
waitStrategy: WaitStrategy | undefined = this.waitStrategy
128+
): Promise<WaitStrategy> {
129+
return selectWaitStrategy({
130+
client,
131+
inspectResult,
132+
waitStrategy,
133+
healthCheck: this.healthCheck,
134+
imageNames: [this.imageName.string],
135+
});
136+
}
137+
124138
private async reuseOrStartContainer(client: ContainerRuntimeClient) {
125139
const containerHash = hash(JSON.stringify(this.createOpts));
126140
this.createOpts.Labels = { ...this.createOpts.Labels, [LABEL_TESTCONTAINERS_CONTAINER_HASH]: containerHash };
@@ -153,19 +167,20 @@ export class GenericContainer implements TestContainer {
153167
const boundPorts = BoundPorts.fromInspectResult(client.info.containerRuntime.hostIps, mappedInspectResult).filter(
154168
this.exposedPorts
155169
);
170+
const waitStrategy = await this.selectWaitStrategy(client, inspectResult);
156171
if (this.startupTimeoutMs !== undefined) {
157-
this.waitStrategy.withStartupTimeout(this.startupTimeoutMs);
172+
waitStrategy.withStartupTimeout(this.startupTimeoutMs);
158173
}
159174

160-
await waitForContainer(client, container, this.waitStrategy, boundPorts);
175+
await waitForContainer(client, container, waitStrategy, boundPorts);
161176

162177
return new StartedGenericContainer(
163178
container,
164179
client.info.containerRuntime.host,
165180
inspectResult,
166181
boundPorts,
167182
inspectResult.Name,
168-
this.waitStrategy,
183+
waitStrategy,
169184
this.autoRemove
170185
);
171186
}
@@ -209,10 +224,6 @@ export class GenericContainer implements TestContainer {
209224
this.exposedPorts
210225
);
211226

212-
if (this.startupTimeoutMs !== undefined) {
213-
this.waitStrategy.withStartupTimeout(this.startupTimeoutMs);
214-
}
215-
216227
if (containerLog.enabled() || this.logConsumer !== undefined) {
217228
if (this.logConsumer !== undefined) {
218229
this.logConsumer(await client.container.logs(container));
@@ -229,15 +240,20 @@ export class GenericContainer implements TestContainer {
229240
await this.containerStarting(mappedInspectResult, false);
230241
}
231242

232-
await waitForContainer(client, container, this.waitStrategy, boundPorts);
243+
const waitStrategy = await this.selectWaitStrategy(client, inspectResult);
244+
if (this.startupTimeoutMs !== undefined) {
245+
waitStrategy.withStartupTimeout(this.startupTimeoutMs);
246+
}
247+
248+
await waitForContainer(client, container, waitStrategy, boundPorts);
233249

234250
const startedContainer = new StartedGenericContainer(
235251
container,
236252
client.info.containerRuntime.host,
237253
inspectResult,
238254
boundPorts,
239255
inspectResult.Name,
240-
this.waitStrategy,
256+
waitStrategy,
241257
this.autoRemove
242258
);
243259

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { ContainerInspectInfo } from "dockerode";
2+
import { mapInspectResult } from "./map-inspect-result";
3+
4+
const inspectResult = (health?: { Status: string }): ContainerInspectInfo =>
5+
({
6+
Name: "container",
7+
Config: {
8+
Hostname: "hostname",
9+
Labels: {},
10+
},
11+
State: {
12+
Status: "running",
13+
Running: true,
14+
StartedAt: "2026-05-14T10:00:00.000Z",
15+
FinishedAt: "0001-01-01T00:00:00.000Z",
16+
Health: health,
17+
},
18+
NetworkSettings: {
19+
Ports: {},
20+
Networks: {},
21+
},
22+
}) as unknown as ContainerInspectInfo;
23+
24+
const podmanInspectResult = (healthcheck?: { Status: string }): ContainerInspectInfo =>
25+
({
26+
...inspectResult(),
27+
State: {
28+
Status: "running",
29+
Running: true,
30+
StartedAt: "2026-05-14T10:00:00.000Z",
31+
FinishedAt: "0001-01-01T00:00:00.000Z",
32+
Healthcheck: healthcheck,
33+
},
34+
}) as unknown as ContainerInspectInfo;
35+
36+
describe("mapInspectResult", () => {
37+
it("should map missing health status to none", () => {
38+
expect(mapInspectResult(inspectResult()).healthCheckStatus).toBe("none");
39+
});
40+
41+
it("should map empty health status to none", () => {
42+
expect(mapInspectResult(inspectResult({ Status: "" })).healthCheckStatus).toBe("none");
43+
});
44+
45+
it("should map health status", () => {
46+
expect(mapInspectResult(inspectResult({ Status: "healthy" })).healthCheckStatus).toBe("healthy");
47+
});
48+
49+
it("should map Podman health status", () => {
50+
expect(mapInspectResult(podmanInspectResult({ Status: "starting" })).healthCheckStatus).toBe("starting");
51+
});
52+
});

0 commit comments

Comments
 (0)