Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions docs/features/compose.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,12 +45,13 @@ const environment = await new DockerComposeEnvironment(composeFilePath, composeF

### With a default wait strategy

By default Testcontainers uses the "listening ports" wait strategy for all containers. If you'd like to override
the default wait strategy for all services, you can do so:
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.

If you'd like to override the default wait strategy for all services, you can do so:

```js
const environment = await new DockerComposeEnvironment(composeFilePath, composeFile)
.withDefaultWaitStrategy(Wait.forHealthCheck())
.withDefaultWaitStrategy(Wait.forListeningPorts())
.up();
```

Expand Down
15 changes: 10 additions & 5 deletions docs/features/wait-strategies.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,15 @@ const container = await new GenericContainer("alpine")
.start();
```

## Default wait strategy

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.

You can override this selection with `withWaitStrategy`.

## Listening ports

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

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

## Health check

Wait until the container's health check is successful:
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:

```js
const { GenericContainer, Wait } = require("testcontainers");
Expand All @@ -75,10 +81,10 @@ const container = await new GenericContainer("alpine")
.start();
```

Define your own health check. Note that time units are in seconds:
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:

```js
const { GenericContainer, Wait } = require("testcontainers");
const { GenericContainer } = require("testcontainers");

const container = await new GenericContainer("alpine")
.withHealthCheck({
Expand All @@ -88,7 +94,6 @@ const container = await new GenericContainer("alpine")
retries: 5,
startPeriod: 1000,
})
.withWaitStrategy(Wait.forHealthCheck())
.start();
```

Expand Down
13 changes: 9 additions & 4 deletions packages/modules/kafka/src/kafka-container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ export class KafkaContainer extends GenericContainer {
private zooKeeperHost?: string;
private zooKeeperPort?: number;
private saslSslConfig?: SaslSslListenerOptions;
private originalWaitinStrategy: WaitStrategy;
private originalWaitStrategy: WaitStrategy | undefined;

constructor(image: string) {
super(image);
Expand All @@ -81,7 +81,7 @@ export class KafkaContainer extends GenericContainer {
KAFKA_GROUP_INITIAL_REBALANCE_DELAY_MS: "0",
KAFKA_CONFLUENT_SUPPORT_METRICS_ENABLE: "false",
});
this.originalWaitinStrategy = this.waitStrategy;
this.originalWaitStrategy = this.waitStrategy;
}

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

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

const client = await getContainerRuntimeClient();
const dockerContainer = client.container.getById(container.getId());
const dockerInspectResult = await client.container.inspect(dockerContainer);
const boundPorts = BoundPorts.fromInspectResult(client.info.containerRuntime.hostIps, inspectResult).filter(
this.exposedPorts
);
await waitForContainer(client, dockerContainer, this.originalWaitinStrategy, boundPorts);
const waitStrategy = await this.selectWaitStrategy(client, dockerInspectResult, this.originalWaitStrategy);
if (this.startupTimeoutMs !== undefined) {
waitStrategy.withStartupTimeout(this.startupTimeoutMs);
}
await waitForContainer(client, dockerContainer, waitStrategy, boundPorts);

if (this.saslSslConfig && this.mode !== KafkaMode.KRAFT) {
await this.createUser(container, this.saslSslConfig.sasl);
Expand Down
13 changes: 9 additions & 4 deletions packages/modules/redpanda/src/redpanda-container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ const STARTER_SCRIPT = "/testcontainers_start.sh";
const WAIT_FOR_SCRIPT_MESSAGE = "Waiting for script...";

export class RedpandaContainer extends GenericContainer {
private originalWaitinStrategy: WaitStrategy;
private originalWaitStrategy: WaitStrategy | undefined;

constructor(image: string) {
super(image);
Expand All @@ -34,7 +34,7 @@ export class RedpandaContainer extends GenericContainer {
target: "/etc/redpanda/.bootstrap.yaml",
},
]);
this.originalWaitinStrategy = this.waitStrategy;
this.originalWaitStrategy = this.waitStrategy;
}

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

const client = await getContainerRuntimeClient();
const dockerContainer = client.container.getById(container.getId());
const dockerInspectResult = await client.container.inspect(dockerContainer);
const boundPorts = BoundPorts.fromInspectResult(client.info.containerRuntime.hostIps, inspectResult).filter(
this.exposedPorts
);
await waitForContainer(client, dockerContainer, this.originalWaitinStrategy, boundPorts);
const waitStrategy = await this.selectWaitStrategy(client, dockerInspectResult, this.originalWaitStrategy);
if (this.startupTimeoutMs !== undefined) {
waitStrategy.withStartupTimeout(this.startupTimeoutMs);
}
await waitForContainer(client, dockerContainer, waitStrategy, boundPorts);
}

private renderRedpandaFile(host: string, port: number): string {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
version: "3.5"

services:
container:
image: cristianrgreco/testcontainer:1.1.14
command: ["sh", "-c", "rm -f /tmp/ready; (sleep 4; touch /tmp/ready) & node index.js"]
ports:
- 8080
healthcheck:
test: "test -f /tmp/ready"
interval: 1s
timeout: 1s
retries: 10
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { PullPolicy } from "../utils/pull-policy";
import {
checkEnvironmentContainerIsHealthy,
getDockerEventStream,
getHealthCheckStatus,
getRunningContainerNames,
getVolumeNames,
waitForDockerEvent,
Expand Down Expand Up @@ -88,11 +89,22 @@ describe("DockerComposeEnvironment", { timeout: 180_000 }, () => {
expect(responseBody["IS_OVERRIDDEN"]).toBe("true");
});

it("should support default wait strategy", async () => {
await using startedEnvironment = await new DockerComposeEnvironment(fixtures, "docker-compose-with-healthcheck.yml")
.withDefaultWaitStrategy(Wait.forHealthCheck())
.up();
it("should support configuring a default wait strategy", async () => {
await using startedEnvironment = await new DockerComposeEnvironment(fixtures, "docker-compose.yml")
.withDefaultWaitStrategy(Wait.forLogMessage("Listening on port 8080"))
.up(["container"]);

await checkEnvironmentContainerIsHealthy(startedEnvironment, "container-1");
});

it("should wait for a healthcheck defined in a service", async () => {
await using startedEnvironment = await new DockerComposeEnvironment(
fixtures,
"docker-compose-with-delayed-healthcheck.yml"
).up();
const container = startedEnvironment.getContainer("container-1");

expect(await getHealthCheckStatus(container)).toBe("healthy");
await checkEnvironmentContainerIsHealthy(startedEnvironment, "container-1");
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { Environment } from "../types";
import { BoundPorts } from "../utils/bound-ports";
import { mapInspectResult } from "../utils/map-inspect-result";
import { ImagePullPolicy, PullPolicy } from "../utils/pull-policy";
import { Wait } from "../wait-strategies/wait";
import { selectWaitStrategy } from "../wait-strategies/utils/wait-strategy-selector";
import { waitForContainer } from "../wait-strategies/wait-for-container";
import { WaitStrategy } from "../wait-strategies/wait-strategy";
import { StartedDockerComposeEnvironment } from "./started-docker-compose-environment";
Expand All @@ -24,7 +24,7 @@ export class DockerComposeEnvironment {
private profiles: string[] = [];
private environment: Environment = {};
private pullPolicy: ImagePullPolicy = PullPolicy.defaultPolicy();
private defaultWaitStrategy: WaitStrategy = Wait.forListeningPorts();
private defaultWaitStrategy: WaitStrategy | undefined;
private waitStrategy: { [containerName: string]: WaitStrategy } = {};
private startupTimeoutMs?: number;
private clientOptions: Partial<ComposeOptions> = {};
Expand Down Expand Up @@ -174,9 +174,11 @@ export class DockerComposeEnvironment {
const inspectResult = await client.container.inspect(container);
const mappedInspectResult = mapInspectResult(inspectResult);
const boundPorts = BoundPorts.fromInspectResult(client.info.containerRuntime.hostIps, mappedInspectResult);
const waitStrategy = this.waitStrategy[containerName]
? this.waitStrategy[containerName]
: this.defaultWaitStrategy;
const waitStrategy = await selectWaitStrategy({
client,
inspectResult,
waitStrategy: this.waitStrategy[containerName] ?? this.defaultWaitStrategy,
});
if (this.startupTimeoutMs !== undefined) {
waitStrategy.withStartupTimeout(this.startupTimeoutMs);
}
Expand Down
40 changes: 28 additions & 12 deletions packages/testcontainers/src/generic-container/generic-container.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import archiver from "archiver";
import AsyncLock from "async-lock";
import { Container, ContainerCreateOptions, HostConfig } from "dockerode";
import { Container, ContainerCreateOptions, ContainerInspectInfo, HostConfig } from "dockerode";
import { promises as fs } from "fs";
import { Readable } from "stream";
import { containerLog, hash, log, toNanos } from "../common";
Expand Down Expand Up @@ -31,7 +31,7 @@ import { createLabels, LABEL_TESTCONTAINERS_CONTAINER_HASH, LABEL_TESTCONTAINERS
import { mapInspectResult } from "../utils/map-inspect-result";
import { getContainerPort, getProtocol, hasHostBinding, PortWithOptionalBinding } from "../utils/port";
import { ImagePullPolicy, PullPolicy } from "../utils/pull-policy";
import { Wait } from "../wait-strategies/wait";
import { selectWaitStrategy } from "../wait-strategies/utils/wait-strategy-selector";
import { waitForContainer } from "../wait-strategies/wait-for-container";
import { WaitStrategy } from "../wait-strategies/wait-strategy";
import { GenericContainerBuilder } from "./generic-container-builder";
Expand All @@ -50,7 +50,7 @@ export class GenericContainer implements TestContainer {

protected imageName: ImageName;
protected startupTimeoutMs?: number;
protected waitStrategy: WaitStrategy = Wait.forListeningPorts();
protected waitStrategy: WaitStrategy | undefined;
protected environment: Record<string, string> = {};
protected exposedPorts: PortWithOptionalBinding[] = [];
protected reuse = false;
Expand Down Expand Up @@ -121,6 +121,20 @@ export class GenericContainer implements TestContainer {
return this.startContainer(client);
}

protected async selectWaitStrategy(
client: ContainerRuntimeClient,
inspectResult: ContainerInspectInfo,
waitStrategy: WaitStrategy | undefined = this.waitStrategy
): Promise<WaitStrategy> {
return selectWaitStrategy({
client,
inspectResult,
waitStrategy,
healthCheck: this.healthCheck,
imageNames: [this.imageName.string],
});
}

private async reuseOrStartContainer(client: ContainerRuntimeClient) {
const containerHash = hash(JSON.stringify(this.createOpts));
this.createOpts.Labels = { ...this.createOpts.Labels, [LABEL_TESTCONTAINERS_CONTAINER_HASH]: containerHash };
Expand Down Expand Up @@ -153,19 +167,20 @@ export class GenericContainer implements TestContainer {
const boundPorts = BoundPorts.fromInspectResult(client.info.containerRuntime.hostIps, mappedInspectResult).filter(
this.exposedPorts
);
const waitStrategy = await this.selectWaitStrategy(client, inspectResult);
if (this.startupTimeoutMs !== undefined) {
this.waitStrategy.withStartupTimeout(this.startupTimeoutMs);
waitStrategy.withStartupTimeout(this.startupTimeoutMs);
}

await waitForContainer(client, container, this.waitStrategy, boundPorts);
await waitForContainer(client, container, waitStrategy, boundPorts);

return new StartedGenericContainer(
container,
client.info.containerRuntime.host,
inspectResult,
boundPorts,
inspectResult.Name,
this.waitStrategy,
waitStrategy,
this.autoRemove
);
}
Expand Down Expand Up @@ -209,10 +224,6 @@ export class GenericContainer implements TestContainer {
this.exposedPorts
);

if (this.startupTimeoutMs !== undefined) {
this.waitStrategy.withStartupTimeout(this.startupTimeoutMs);
}

if (containerLog.enabled() || this.logConsumer !== undefined) {
if (this.logConsumer !== undefined) {
this.logConsumer(await client.container.logs(container));
Expand All @@ -229,15 +240,20 @@ export class GenericContainer implements TestContainer {
await this.containerStarting(mappedInspectResult, false);
}

await waitForContainer(client, container, this.waitStrategy, boundPorts);
const waitStrategy = await this.selectWaitStrategy(client, inspectResult);
if (this.startupTimeoutMs !== undefined) {
waitStrategy.withStartupTimeout(this.startupTimeoutMs);
}

await waitForContainer(client, container, waitStrategy, boundPorts);

const startedContainer = new StartedGenericContainer(
container,
client.info.containerRuntime.host,
inspectResult,
boundPorts,
inspectResult.Name,
this.waitStrategy,
waitStrategy,
this.autoRemove
);

Expand Down
52 changes: 52 additions & 0 deletions packages/testcontainers/src/utils/map-inspect-result.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { ContainerInspectInfo } from "dockerode";
import { mapInspectResult } from "./map-inspect-result";

const inspectResult = (health?: { Status: string }): ContainerInspectInfo =>
({
Name: "container",
Config: {
Hostname: "hostname",
Labels: {},
},
State: {
Status: "running",
Running: true,
StartedAt: "2026-05-14T10:00:00.000Z",
FinishedAt: "0001-01-01T00:00:00.000Z",
Health: health,
},
NetworkSettings: {
Ports: {},
Networks: {},
},
}) as unknown as ContainerInspectInfo;

const podmanInspectResult = (healthcheck?: { Status: string }): ContainerInspectInfo =>
({
...inspectResult(),
State: {
Status: "running",
Running: true,
StartedAt: "2026-05-14T10:00:00.000Z",
FinishedAt: "0001-01-01T00:00:00.000Z",
Healthcheck: healthcheck,
},
}) as unknown as ContainerInspectInfo;

describe("mapInspectResult", () => {
it("should map missing health status to none", () => {
expect(mapInspectResult(inspectResult()).healthCheckStatus).toBe("none");
});

it("should map empty health status to none", () => {
expect(mapInspectResult(inspectResult({ Status: "" })).healthCheckStatus).toBe("none");
});

it("should map health status", () => {
expect(mapInspectResult(inspectResult({ Status: "healthy" })).healthCheckStatus).toBe("healthy");
});

it("should map Podman health status", () => {
expect(mapInspectResult(podmanInspectResult({ Status: "starting" })).healthCheckStatus).toBe("starting");
});
});
Loading
Loading