Skip to content

Commit e00c98e

Browse files
Refine healthcheck wait strategy selection
1 parent a333af3 commit e00c98e

6 files changed

Lines changed: 152 additions & 179 deletions

File tree

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

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ describe("DockerComposeEnvironment", { timeout: 180_000 }, () => {
8989
expect(responseBody["IS_OVERRIDDEN"]).toBe("true");
9090
});
9191

92-
it("should support default wait strategy", async () => {
92+
it("should support configuring a default wait strategy", async () => {
9393
await using startedEnvironment = await new DockerComposeEnvironment(fixtures, "docker-compose.yml")
9494
.withDefaultWaitStrategy(Wait.forLogMessage("Listening on port 8080"))
9595
.up(["container"]);
@@ -108,13 +108,6 @@ describe("DockerComposeEnvironment", { timeout: 180_000 }, () => {
108108
await checkEnvironmentContainerIsHealthy(startedEnvironment, "container-1");
109109
});
110110

111-
it("should use listening ports if healthcheck is not defined in a service", async () => {
112-
await using startedEnvironment = await new DockerComposeEnvironment(fixtures, "docker-compose-with-name.yml").up();
113-
114-
await checkEnvironmentContainerIsHealthy(startedEnvironment, "custom_container_name");
115-
expect(await getHealthCheckStatus(startedEnvironment.getContainer("custom_container_name"))).toBeUndefined();
116-
});
117-
118111
it("should use listening ports if a service disables healthcheck", async () => {
119112
await using startedEnvironment = await new DockerComposeEnvironment(
120113
fixtures,

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

Lines changed: 8 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,13 @@
1-
import { ContainerInfo, ContainerInspectInfo } from "dockerode";
1+
import { ContainerInfo } from "dockerode";
22
import { containerLog, log, RandomUuid, Uuid } from "../common";
3-
import {
4-
ComposeOptions,
5-
ContainerRuntimeClient,
6-
getContainerRuntimeClient,
7-
ImageName,
8-
parseComposeContainerName,
9-
} from "../container-runtime";
3+
import { ComposeOptions, getContainerRuntimeClient, parseComposeContainerName } from "../container-runtime";
104
import { StartedGenericContainer } from "../generic-container/started-generic-container";
115
import { getReaper } from "../reaper/reaper";
126
import { Environment } from "../types";
137
import { BoundPorts } from "../utils/bound-ports";
148
import { mapInspectResult } from "../utils/map-inspect-result";
159
import { ImagePullPolicy, PullPolicy } from "../utils/pull-policy";
16-
import {
17-
hasDisabledHealthCheckConfig,
18-
hasHealthCheckConfig,
19-
hasHealthCheckStatus,
20-
} from "../wait-strategies/utils/health-check";
21-
import { Wait } from "../wait-strategies/wait";
10+
import { selectWaitStrategy } from "../wait-strategies/utils/wait-strategy-selector";
2211
import { waitForContainer } from "../wait-strategies/wait-for-container";
2312
import { WaitStrategy } from "../wait-strategies/wait-strategy";
2413
import { StartedDockerComposeEnvironment } from "./started-docker-compose-environment";
@@ -185,7 +174,11 @@ export class DockerComposeEnvironment {
185174
const inspectResult = await client.container.inspect(container);
186175
const mappedInspectResult = mapInspectResult(inspectResult);
187176
const boundPorts = BoundPorts.fromInspectResult(client.info.containerRuntime.hostIps, mappedInspectResult);
188-
const waitStrategy = await this.selectWaitStrategy(client, containerName, inspectResult);
177+
const waitStrategy = await selectWaitStrategy({
178+
client,
179+
inspectResult,
180+
waitStrategy: this.waitStrategy[containerName] ?? this.defaultWaitStrategy,
181+
});
189182
if (this.startupTimeoutMs !== undefined) {
190183
waitStrategy.withStartupTimeout(this.startupTimeoutMs);
191184
}
@@ -232,52 +225,6 @@ export class DockerComposeEnvironment {
232225
});
233226
}
234227

235-
private async selectWaitStrategy(
236-
client: ContainerRuntimeClient,
237-
containerName: string,
238-
inspectResult: ContainerInspectInfo
239-
): Promise<WaitStrategy> {
240-
const containerWaitStrategy = this.waitStrategy[containerName]
241-
? this.waitStrategy[containerName]
242-
: this.defaultWaitStrategy;
243-
if (containerWaitStrategy) return containerWaitStrategy;
244-
if (hasDisabledHealthCheckConfig(inspectResult)) {
245-
return Wait.forListeningPorts();
246-
}
247-
if (hasHealthCheckConfig(inspectResult) || hasHealthCheckStatus(inspectResult)) {
248-
return Wait.forHealthCheck();
249-
}
250-
if (await this.imageHasHealthCheck(client, inspectResult)) {
251-
return Wait.forHealthCheck();
252-
}
253-
return Wait.forListeningPorts();
254-
}
255-
256-
private async imageHasHealthCheck(
257-
client: ContainerRuntimeClient,
258-
inspectResult: ContainerInspectInfo
259-
): Promise<boolean> {
260-
const imageNames = Array.from(
261-
new Set(
262-
[inspectResult.Config.Image, inspectResult.Image].filter(
263-
(imageName): imageName is string => imageName !== undefined && imageName !== ""
264-
)
265-
)
266-
);
267-
268-
for (const imageName of imageNames) {
269-
try {
270-
if (hasHealthCheckConfig(await client.image.inspect(ImageName.fromString(imageName)))) {
271-
return true;
272-
}
273-
} catch (err) {
274-
log.debug(`Failed to inspect image "${imageName}" for health check config: ${err}`);
275-
}
276-
}
277-
278-
return false;
279-
}
280-
281228
private warnForUnusedWaitStrategies(startedContainerNames: Set<string>): void {
282229
const unusedWaitStrategyContainerNames = Object.keys(this.waitStrategy).filter(
283230
(configuredContainerName) => !startedContainerNames.has(configuredContainerName)

packages/testcontainers/src/generic-container/generic-container-wait-strategy-unit.test.ts

Lines changed: 0 additions & 74 deletions
This file was deleted.

packages/testcontainers/src/generic-container/generic-container-wait-strategy.test.ts

Lines changed: 76 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,89 @@
1+
import { ContainerInspectInfo, ImageInspectInfo } from "dockerode";
12
import path from "path";
23
import { randomUuid } from "../common/uuid";
4+
import { ContainerRuntimeClient } from "../container-runtime";
35
import { checkContainerIsHealthy, getHealthCheckStatus } from "../utils/test-helper";
6+
import { HealthCheckWaitStrategy } from "../wait-strategies/health-check-wait-strategy";
7+
import { HostPortWaitStrategy } from "../wait-strategies/host-port-wait-strategy";
48
import { Wait } from "../wait-strategies/wait";
59
import { GenericContainer } from "./generic-container";
610

711
const fixtures = path.resolve(__dirname, "..", "..", "fixtures", "docker");
812

13+
class TestGenericContainer extends GenericContainer {
14+
public selectWaitStrategyForTest(client: ContainerRuntimeClient, inspectResult: ContainerInspectInfo) {
15+
return this.selectWaitStrategy(client, inspectResult);
16+
}
17+
}
18+
19+
const containerInspectResult = (healthcheck?: { Test: string[] }): ContainerInspectInfo =>
20+
({
21+
Config: {
22+
Hostname: "hostname",
23+
Labels: {},
24+
Healthcheck: healthcheck,
25+
},
26+
State: {
27+
Status: "running",
28+
Running: true,
29+
StartedAt: "2026-05-14T10:00:00.000Z",
30+
FinishedAt: "0001-01-01T00:00:00.000Z",
31+
},
32+
NetworkSettings: {
33+
Ports: {},
34+
Networks: {},
35+
},
36+
}) as unknown as ContainerInspectInfo;
37+
38+
const client = (imageInspectResult: ImageInspectInfo): ContainerRuntimeClient =>
39+
({
40+
image: {
41+
inspect: vi.fn().mockResolvedValue(imageInspectResult),
42+
},
43+
}) as unknown as ContainerRuntimeClient;
44+
945
describe("GenericContainer default wait strategy", { timeout: 180_000 }, () => {
10-
it("should use listening ports if healthcheck is not defined in the image", async () => {
11-
await using container = await new GenericContainer("cristianrgreco/testcontainer:1.1.14")
12-
.withExposedPorts(8080)
13-
.start();
46+
it("should select listening ports when no healthcheck is configured", async () => {
47+
await expect(
48+
new TestGenericContainer("image:latest").selectWaitStrategyForTest(
49+
client({} as ImageInspectInfo),
50+
containerInspectResult()
51+
)
52+
).resolves.toBeInstanceOf(HostPortWaitStrategy);
53+
});
1454

15-
await checkContainerIsHealthy(container);
16-
expect(await getHealthCheckStatus(container)).toBeUndefined();
55+
it("should select image healthcheck when container inspect omits healthcheck config", async () => {
56+
const imageInspectResult = {
57+
Config: {
58+
Healthcheck: {
59+
Test: ["CMD-SHELL", "test -f /tmp/ready"],
60+
},
61+
},
62+
} as unknown as ImageInspectInfo;
63+
64+
await expect(
65+
new TestGenericContainer("image:latest").selectWaitStrategyForTest(
66+
client(imageInspectResult),
67+
containerInspectResult()
68+
)
69+
).resolves.toBeInstanceOf(HealthCheckWaitStrategy);
70+
});
71+
72+
it("should select listening ports when the container disables image healthchecks", async () => {
73+
const imageInspectResult = {
74+
Config: {
75+
Healthcheck: {
76+
Test: ["CMD-SHELL", "test -f /tmp/ready"],
77+
},
78+
},
79+
} as unknown as ImageInspectInfo;
80+
81+
await expect(
82+
new TestGenericContainer("image:latest").selectWaitStrategyForTest(
83+
client(imageInspectResult),
84+
containerInspectResult({ Test: ["NONE"] })
85+
)
86+
).resolves.toBeInstanceOf(HostPortWaitStrategy);
1787
});
1888

1989
it("should wait for a healthcheck configured with withHealthCheck", async () => {

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

Lines changed: 8 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -31,13 +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 {
35-
hasDisabledHealthCheckConfig,
36-
hasHealthCheck,
37-
hasHealthCheckConfig,
38-
hasHealthCheckStatus,
39-
} from "../wait-strategies/utils/health-check";
40-
import { Wait } from "../wait-strategies/wait";
34+
import { selectWaitStrategy } from "../wait-strategies/utils/wait-strategy-selector";
4135
import { waitForContainer } from "../wait-strategies/wait-for-container";
4236
import { WaitStrategy } from "../wait-strategies/wait-strategy";
4337
import { GenericContainerBuilder } from "./generic-container-builder";
@@ -132,29 +126,13 @@ export class GenericContainer implements TestContainer {
132126
inspectResult: ContainerInspectInfo,
133127
waitStrategy: WaitStrategy | undefined = this.waitStrategy
134128
): Promise<WaitStrategy> {
135-
if (waitStrategy) return waitStrategy;
136-
if (hasHealthCheck(this.healthCheck)) {
137-
return Wait.forHealthCheck();
138-
}
139-
if (hasDisabledHealthCheckConfig(inspectResult)) {
140-
return Wait.forListeningPorts();
141-
}
142-
if (hasHealthCheckConfig(inspectResult) || hasHealthCheckStatus(inspectResult)) {
143-
return Wait.forHealthCheck();
144-
}
145-
if (await this.imageHasHealthCheck(client)) {
146-
return Wait.forHealthCheck();
147-
}
148-
return Wait.forListeningPorts();
149-
}
150-
151-
private async imageHasHealthCheck(client: ContainerRuntimeClient): Promise<boolean> {
152-
try {
153-
return hasHealthCheckConfig(await client.image.inspect(this.imageName));
154-
} catch (err) {
155-
log.debug(`Failed to inspect image "${this.imageName.string}" for health check config: ${err}`);
156-
return false;
157-
}
129+
return selectWaitStrategy({
130+
client,
131+
inspectResult,
132+
waitStrategy,
133+
healthCheck: this.healthCheck,
134+
imageNames: [this.imageName.string],
135+
});
158136
}
159137

160138
private async reuseOrStartContainer(client: ContainerRuntimeClient) {
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { ContainerInspectInfo } from "dockerode";
2+
import { log } from "../../common";
3+
import { ContainerRuntimeClient, ImageName } from "../../container-runtime";
4+
import { HealthCheck } from "../../types";
5+
import { Wait } from "../wait";
6+
import { WaitStrategy } from "../wait-strategy";
7+
import {
8+
hasDisabledHealthCheckConfig,
9+
hasHealthCheck,
10+
hasHealthCheckConfig,
11+
hasHealthCheckStatus,
12+
} from "./health-check";
13+
14+
type WaitStrategySelectorOptions = {
15+
client: ContainerRuntimeClient;
16+
inspectResult: ContainerInspectInfo;
17+
waitStrategy?: WaitStrategy;
18+
healthCheck?: HealthCheck;
19+
imageNames?: string[];
20+
};
21+
22+
export const selectWaitStrategy = async ({
23+
client,
24+
inspectResult,
25+
waitStrategy,
26+
healthCheck,
27+
imageNames = getImageNames(inspectResult),
28+
}: WaitStrategySelectorOptions): Promise<WaitStrategy> => {
29+
if (waitStrategy) return waitStrategy;
30+
if (hasHealthCheck(healthCheck)) return Wait.forHealthCheck();
31+
if (hasDisabledHealthCheckConfig(inspectResult)) return Wait.forListeningPorts();
32+
if (hasHealthCheckConfig(inspectResult) || hasHealthCheckStatus(inspectResult)) return Wait.forHealthCheck();
33+
if (await imageHasHealthCheck(client, imageNames)) return Wait.forHealthCheck();
34+
return Wait.forListeningPorts();
35+
};
36+
37+
const getImageNames = (inspectResult: ContainerInspectInfo): string[] => {
38+
return Array.from(
39+
new Set(
40+
[inspectResult.Config.Image, inspectResult.Image].filter(
41+
(imageName): imageName is string => imageName !== undefined && imageName !== ""
42+
)
43+
)
44+
);
45+
};
46+
47+
const imageHasHealthCheck = async (client: ContainerRuntimeClient, imageNames: string[]): Promise<boolean> => {
48+
for (const imageName of imageNames) {
49+
try {
50+
if (hasHealthCheckConfig(await client.image.inspect(ImageName.fromString(imageName)))) {
51+
return true;
52+
}
53+
} catch (err) {
54+
log.warn(`Failed to inspect image "${imageName}" for health check config: ${err}`);
55+
}
56+
}
57+
58+
return false;
59+
};

0 commit comments

Comments
 (0)