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
13 changes: 13 additions & 0 deletions docs/features/compose.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,19 @@ const environment = await new DockerComposeEnvironment(composeFilePath, composeF
.up();
```

### With auto cleanup disabled

By default Testcontainers registers the compose project with Ryuk so the stack is torn down automatically when the process exits. You can disable that registration for a specific compose stack:

```js
const environment = await new DockerComposeEnvironment(composeFilePath, composeFile)
.withProjectName("test")
.withAutoCleanup(false)
.up();
```

This only disables automatic cleanup. Explicit calls to `.down()`, `.stop()`, or `await using` disposal still tear the stack down as usual.

### With custom client options

See [docker-compose](https://github.com/PDMLab/docker-compose/) library.
Expand Down
10 changes: 10 additions & 0 deletions docs/features/containers.md
Original file line number Diff line number Diff line change
Expand Up @@ -400,6 +400,16 @@ const container = await new GenericContainer("alpine")
await container.stop({ remove: true }); // The container is stopped *AND* removed
```

You can also disable automatic Ryuk cleanup for a specific container while leaving it enabled for the rest of the test session:

```js
const container = await new GenericContainer("alpine")
.withAutoCleanup(false)
.start();
```

This only affects automatic cleanup when the process exits unexpectedly or the container is otherwise left running. Explicit calls to `.stop()` still use the normal stop and removal behavior, so combine this with `.withAutoRemove(false)` or `.stop({ remove: false })` if you also want explicit stops to keep the container.

Keep in mind that disabling ryuk (set `TESTCONTAINERS_RYUK_DISABLED` to `true`) **and** disabling automatic removal of containers will make containers persist after you're done working with them.


Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { ContainerInfo } from "dockerode";
import { ContainerRuntimeClient } from "../container-runtime";
import { DockerComposeEnvironment } from "./docker-compose-environment";

let mockClient: ContainerRuntimeClient;
let mockGetReaper = vi.fn();

vi.mock("../container-runtime", async () => ({
...(await vi.importActual("../container-runtime")),
getContainerRuntimeClient: vi.fn(async () => mockClient),
}));

vi.mock("../reaper/reaper", async () => ({
...(await vi.importActual("../reaper/reaper")),
getReaper: vi.fn(async (client: ContainerRuntimeClient) => await mockGetReaper(client)),
}));

describe.sequential("DockerComposeEnvironment auto cleanup", () => {
beforeEach(() => {
vi.clearAllMocks();
mockGetReaper = vi.fn();
});

it("should register the compose project with the reaper by default", async () => {
mockClient = createMockContainerRuntimeClient();
const addComposeProject = vi.fn();
mockGetReaper.mockResolvedValue({
sessionId: "session-id",
containerId: "reaper-container-id",
addComposeProject,
addSession: vi.fn(),
});

await new DockerComposeEnvironment("/tmp", "docker-compose.yml").withProjectName("my-project").up();

expect(mockGetReaper).toHaveBeenCalledWith(mockClient);
expect(addComposeProject).toHaveBeenCalledWith("my-project");
});

it("should not register the compose project with the reaper when auto cleanup is disabled", async () => {
mockClient = createMockContainerRuntimeClient();

await new DockerComposeEnvironment("/tmp", "docker-compose.yml")
.withProjectName("my-project")
.withAutoCleanup(false)
.up();

expect(mockGetReaper).not.toHaveBeenCalled();
});
});

function createMockContainerRuntimeClient(): ContainerRuntimeClient {
return {
compose: {
down: vi.fn(),
pull: vi.fn(),
stop: vi.fn(),
up: vi.fn(),
},
container: {
list: vi.fn(async () => [] as ContainerInfo[]),
},
image: {},
info: {
containerRuntime: {
host: "localhost",
hostIps: [{ address: "127.0.0.1", family: 4 }],
},
node: {
architecture: "x64",
platform: "linux",
version: process.version,
},
},
network: {},
} as unknown as ContainerRuntimeClient;
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export class DockerComposeEnvironment {

private projectName: string;
private build = false;
private autoCleanup = true;
private recreate = true;
private environmentFile = "";
private profiles: string[] = [];
Expand All @@ -39,6 +40,11 @@ export class DockerComposeEnvironment {
return this;
}

public withAutoCleanup(autoCleanup: boolean): this {
this.autoCleanup = autoCleanup;
return this;
}

public withEnvironment(environment: Environment): this {
this.environment = { ...this.environment, ...environment };
return this;
Expand Down Expand Up @@ -95,8 +101,10 @@ export class DockerComposeEnvironment {
public async up(services?: Array<string>): Promise<StartedDockerComposeEnvironment> {
log.info(`Starting DockerCompose environment "${this.projectName}"...`);
const client = await getContainerRuntimeClient();
const reaper = await getReaper(client);
reaper.addComposeProject(this.projectName);
if (this.autoCleanup) {
const reaper = await getReaper(client);
reaper.addComposeProject(this.projectName);
}

const {
composeOptions: clientComposeOptions = [],
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import { Container, ContainerCreateOptions, ContainerInspectInfo } from "dockerode";
import { Readable } from "stream";
import { ContainerRuntimeClient } from "../container-runtime";
import { LABEL_TESTCONTAINERS_SESSION_ID } from "../utils/labels";
import { WaitStrategy } from "../wait-strategies/wait-strategy";
import { GenericContainer } from "./generic-container";

let mockClient: ContainerRuntimeClient;
let mockGetReaper = vi.fn();

vi.mock("../container-runtime", async () => ({
...(await vi.importActual("../container-runtime")),
getContainerRuntimeClient: vi.fn(async () => mockClient),
}));

vi.mock("../reaper/reaper", async () => ({
...(await vi.importActual("../reaper/reaper")),
getReaper: vi.fn(async (client: ContainerRuntimeClient) => await mockGetReaper(client)),
}));

describe.sequential("GenericContainer auto cleanup", () => {
beforeEach(() => {
vi.clearAllMocks();
mockGetReaper = vi.fn();
});

it("should register the container with the reaper by default", async () => {
const { client, inspectResult } = createMockContainerRuntimeClient();
mockClient = client;
mockGetReaper.mockResolvedValue({
sessionId: "session-id",
containerId: "reaper-container-id",
addComposeProject: vi.fn(),
addSession: vi.fn(),
});

const container = await new GenericContainer("alpine").withWaitStrategy(createNoopWaitStrategy()).start();

expect(mockGetReaper).toHaveBeenCalledWith(client);
expect(container.getLabels()[LABEL_TESTCONTAINERS_SESSION_ID]).toBe("session-id");
expect(inspectResult.Config.Labels[LABEL_TESTCONTAINERS_SESSION_ID]).toBe("session-id");
});

it("should not register the container with the reaper when auto cleanup is disabled", async () => {
const { client, inspectResult } = createMockContainerRuntimeClient();
mockClient = client;

const container = await new GenericContainer("alpine")
.withAutoCleanup(false)
.withWaitStrategy(createNoopWaitStrategy())
.start();

expect(mockGetReaper).not.toHaveBeenCalled();
expect(container.getLabels()[LABEL_TESTCONTAINERS_SESSION_ID]).toBeUndefined();
expect(inspectResult.Config.Labels[LABEL_TESTCONTAINERS_SESSION_ID]).toBeUndefined();
});
});

function createMockContainerRuntimeClient(): {
client: ContainerRuntimeClient;
inspectResult: ContainerInspectInfo;
} {
const inspectResult = {
Config: {
Hostname: "mock-hostname",
Labels: {},
},
HostConfig: {
PortBindings: {},
},
Name: "/mock-container",
NetworkSettings: {
Networks: {},
Ports: {},
},
State: {
FinishedAt: "0001-01-01T00:00:00Z",
Running: true,
StartedAt: new Date().toISOString(),
Status: "running",
},
} as ContainerInspectInfo;

const container = {
id: "mock-container-id",
} as Container;

const client = {
compose: {},
container: {
attach: vi.fn(),
commit: vi.fn(),
connectToNetwork: vi.fn(),
create: vi.fn(async (opts: ContainerCreateOptions) => {
inspectResult.Config.Labels = opts.Labels ?? {};
return container;
}),
dockerode: {} as never,
events: vi.fn(),
exec: vi.fn(),
fetchArchive: vi.fn(),
fetchByLabel: vi.fn(),
getById: vi.fn(),
inspect: vi.fn(async () => inspectResult),
list: vi.fn(),
logs: vi.fn(async () => Readable.from([])),
putArchive: vi.fn(),
remove: vi.fn(),
restart: vi.fn(),
start: vi.fn(),
stop: vi.fn(),
},
image: {
pull: vi.fn(),
},
info: {
containerRuntime: {
host: "localhost",
hostIps: [{ address: "127.0.0.1", family: 4 }],
},
node: {
architecture: "x64",
platform: "linux",
version: process.version,
},
},
network: {
getById: vi.fn(),
},
} as unknown as ContainerRuntimeClient;

return { client, inspectResult };
}

function createNoopWaitStrategy(): WaitStrategy {
return {
waitUntilReady: vi.fn(async () => undefined),
withStartupTimeout: vi.fn().mockReturnThis(),
} as unknown as WaitStrategy;
}
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ export class GenericContainer implements TestContainer {
protected environment: Record<string, string> = {};
protected exposedPorts: PortWithOptionalBinding[] = [];
protected reuse = false;
protected autoCleanup = true;
protected autoRemove = true;
protected networkMode?: string;
protected networkAliases: string[] = [];
Expand Down Expand Up @@ -112,7 +113,7 @@ export class GenericContainer implements TestContainer {
return this.reuseOrStartContainer(client);
}

if (!this.isReaper()) {
if (!this.isReaper() && this.autoCleanup) {
const reaper = await getReaper(client);
this.createOpts.Labels = { ...this.createOpts.Labels, [LABEL_TESTCONTAINERS_SESSION_ID]: reaper.sessionId };
}
Expand Down Expand Up @@ -461,6 +462,11 @@ export class GenericContainer implements TestContainer {
return this;
}

public withAutoCleanup(autoCleanup: boolean): this {
this.autoCleanup = autoCleanup;
return this;
}

public withAutoRemove(autoRemove: boolean): this {
this.autoRemove = autoRemove;
return this;
Expand Down
1 change: 1 addition & 0 deletions packages/testcontainers/src/test-container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export interface TestContainer {
withUser(user: string): this;
withPullPolicy(pullPolicy: ImagePullPolicy): this;
withReuse(): this;
withAutoCleanup(autoCleanup: boolean): this;
withAutoRemove(autoRemove: boolean): this;
withCopyFilesToContainer(filesToCopy: FileToCopy[]): this;
withCopyDirectoriesToContainer(directoriesToCopy: DirectoryToCopy[]): this;
Expand Down
Loading