diff --git a/packages/testcontainers/src/docker-compose-environment/docker-compose-environment.ts b/packages/testcontainers/src/docker-compose-environment/docker-compose-environment.ts index e7664a957..d2cdf1935 100644 --- a/packages/testcontainers/src/docker-compose-environment/docker-compose-environment.ts +++ b/packages/testcontainers/src/docker-compose-environment/docker-compose-environment.ts @@ -169,6 +169,7 @@ export class DockerComposeEnvironment { boundPorts, containerName, waitStrategy + // not sure how to control 'remove' option for the whole compose stack, will use default value here (true) ); }) ) diff --git a/packages/testcontainers/src/generic-container/generic-container-reuse.test.ts b/packages/testcontainers/src/generic-container/generic-container-reuse.test.ts index 6d53b7cbc..fd409928e 100644 --- a/packages/testcontainers/src/generic-container/generic-container-reuse.test.ts +++ b/packages/testcontainers/src/generic-container/generic-container-reuse.test.ts @@ -153,6 +153,27 @@ describe("GenericContainer reuse", { timeout: 180_000 }, () => { await container2.stop(); }); + it("should reuse container when an existing reusable container created using withRemove(false) has stopped", async () => { + const name = `there_can_only_be_one_${randomUuid()}`; + const container1 = await new GenericContainer("cristianrgreco/testcontainer:1.1.14") + .withName(name) + .withExposedPorts(8080) + .withReuse() + .withRemove(false) + .start(); + await container1.stop({ timeout: 10000 }); + + const container2 = await new GenericContainer("cristianrgreco/testcontainer:1.1.14") + .withName(name) + .withExposedPorts(8080) + .withReuse() + .start(); + await checkContainerIsHealthy(container2); + + expect(container1.getId()).toBe(container2.getId()); + await container2.stop(); + }); + it("should keep the labels passed in when a new reusable container is created", async () => { const container = await new GenericContainer("cristianrgreco/testcontainer:1.1.14") .withName(`there_can_only_be_one_${randomUuid()}`) diff --git a/packages/testcontainers/src/generic-container/generic-container.test.ts b/packages/testcontainers/src/generic-container/generic-container.test.ts index 2618cafed..f153c9591 100644 --- a/packages/testcontainers/src/generic-container/generic-container.test.ts +++ b/packages/testcontainers/src/generic-container/generic-container.test.ts @@ -7,6 +7,7 @@ import { checkContainerIsHealthy, getDockerEventStream, getRunningContainerNames, + getStoppedContainerNames, waitForDockerEvent, } from "../utils/test-helper"; import { GenericContainer } from "./generic-container"; @@ -503,6 +504,45 @@ describe("GenericContainer", { timeout: 180_000 }, () => { expect(await getRunningContainerNames()).not.toContain(container.getName()); }); + it("should stop the container and NOT remove it if withRemove(false) set", async () => { + const container = await new GenericContainer("cristianrgreco/testcontainer:1.1.14") + .withName(`container-${new RandomUuid().nextUuid()}`) + .withRemove(false) + .start(); + + await container.stop(); + + const containerName = container.getName()?.slice(1); // remove first '/' + expect(await getStoppedContainerNames()).toContain(containerName); + + // ryuk will clean up this container after test anyway + // unless env var TESTCONTAINERS_RYUK_DISABLED=true is set + }); + + it("should stop the container and remove it if withRemove(true) set", async () => { + const container = await new GenericContainer("cristianrgreco/testcontainer:1.1.14") + .withName(`container-${new RandomUuid().nextUuid()}`) + .withRemove(true) + .start(); + + await container.stop(); + + const containerName = container.getName()?.slice(1); // remove first '/' + expect(await getStoppedContainerNames()).not.toContain(containerName); + }); + + it("should stop the container and remove it when explicitly overriding withRemove(false)", async () => { + const container = await new GenericContainer("cristianrgreco/testcontainer:1.1.14") + .withName(`container-${new RandomUuid().nextUuid()}`) + .withRemove(false) + .start(); + + await container.stop({ remove: true }); // remove: true here should override withRemove(false) + + const containerName = container.getName()?.slice(1); // remove first '/' + expect(await getStoppedContainerNames()).not.toContain(containerName); + }); + it("should stop the container idempotently", async () => { const container = await new GenericContainer("cristianrgreco/testcontainer:1.1.14") .withName(`container-${new RandomUuid().nextUuid()}`) diff --git a/packages/testcontainers/src/generic-container/generic-container.ts b/packages/testcontainers/src/generic-container/generic-container.ts index 870840eb7..e8cc8c044 100644 --- a/packages/testcontainers/src/generic-container/generic-container.ts +++ b/packages/testcontainers/src/generic-container/generic-container.ts @@ -50,6 +50,7 @@ export class GenericContainer implements TestContainer { protected environment: Record = {}; protected exposedPorts: PortWithOptionalBinding[] = []; protected reuse = false; + protected remove = true; protected networkMode?: string; protected networkAliases: string[] = []; protected pullPolicy: ImagePullPolicy = PullPolicy.defaultPolicy(); @@ -158,7 +159,8 @@ export class GenericContainer implements TestContainer { inspectResult, boundPorts, inspectResult.Name, - this.waitStrategy + this.waitStrategy, + this.remove ); } @@ -222,7 +224,8 @@ export class GenericContainer implements TestContainer { inspectResult, boundPorts, inspectResult.Name, - this.waitStrategy + this.waitStrategy, + this.remove ); if (this.containerStarted) { @@ -433,6 +436,11 @@ export class GenericContainer implements TestContainer { return this; } + public withRemove(remove: boolean): this { + this.remove = remove; + return this; + } + public withPullPolicy(pullPolicy: ImagePullPolicy): this { this.pullPolicy = pullPolicy; return this; diff --git a/packages/testcontainers/src/generic-container/started-generic-container.ts b/packages/testcontainers/src/generic-container/started-generic-container.ts index 51e4e25d5..0828de710 100644 --- a/packages/testcontainers/src/generic-container/started-generic-container.ts +++ b/packages/testcontainers/src/generic-container/started-generic-container.ts @@ -16,7 +16,7 @@ import { StoppedGenericContainer } from "./stopped-generic-container"; export class StartedGenericContainer implements StartedTestContainer { private stoppedContainer?: StoppedTestContainer; - private stopContainerLock = new AsyncLock(); + private readonly stopContainerLock = new AsyncLock(); constructor( private readonly container: Dockerode.Container, @@ -24,7 +24,8 @@ export class StartedGenericContainer implements StartedTestContainer { private inspectResult: ContainerInspectInfo, private boundPorts: BoundPorts, private readonly name: string, - private readonly waitStrategy: WaitStrategy + private readonly waitStrategy: WaitStrategy, + private readonly removeOnStop: boolean = true ) {} protected containerIsStopping?(): Promise; @@ -105,9 +106,12 @@ export class StartedGenericContainer implements StartedTestContainer { await this.containerIsStopping(); } - const resolvedOptions: StopOptions = { remove: true, timeout: 0, removeVolumes: true, ...options }; + // use this.removeOnStop value here (true by default) + // but allow to override it explicitly from options argument + const resolvedOptions: StopOptions = { remove: this.removeOnStop, timeout: 0, removeVolumes: true, ...options }; await client.container.stop(this.container, { timeout: resolvedOptions.timeout }); if (resolvedOptions.remove) { + log.info("StopOptions.remove=true, removing container."); await client.container.remove(this.container, { removeVolumes: resolvedOptions.removeVolumes }); } log.info(`Stopped container`, { containerId: this.container.id }); diff --git a/packages/testcontainers/src/utils/test-helper.ts b/packages/testcontainers/src/utils/test-helper.ts index d99a55358..864734c4f 100644 --- a/packages/testcontainers/src/utils/test-helper.ts +++ b/packages/testcontainers/src/utils/test-helper.ts @@ -36,8 +36,16 @@ export const getDockerEventStream = async (opts: GetEventsOptions = {}): Promise }; export const getRunningContainerNames = async (): Promise => { + return getContainerNames(false); +}; + +export const getStoppedContainerNames = async (): Promise => { + return getContainerNames(true, { status: ["paused", "exited"] }); +}; + +const getContainerNames = async (all: boolean, filters?: string | { [key: string]: string[] }): Promise => { const dockerode = (await getContainerRuntimeClient()).container.dockerode; - const containers = await dockerode.listContainers(); + const containers = await dockerode.listContainers({ all, filters }); return containers .map((container) => container.Names) .reduce((result, containerNames) => [...result, ...containerNames], [])