From e923c24449f0ebd8761514df5f0ccd48da408f1a Mon Sep 17 00:00:00 2001 From: James Pulec Date: Mon, 29 Mar 2021 09:49:00 -0700 Subject: [PATCH] Add Support for Building DockerCompose Environments (#28) * Add Support for Building DockerCompose Environments * Add To Docs * Remove unused Import * Add Docker Compose to Examples Folder * Add Docker Compose example Test NPM Script * Add Example and Fixup Port Mapping * Add Docker Compose Example to Travis * Update README with Docker Compose example --- .travis.yml | 1 + DOC.md | 22 ++- README.md | 1 + examples/03-docker-compose/docker-compose.yml | 8 + examples/03-docker-compose/example.spec.js | 41 ++++++ .../jest-testcontainers-config.js | 7 + examples/03-docker-compose/jest.config.js | 5 + examples/README.md | 2 + package.json | 3 +- src/config.spec.ts | 48 ++++++ src/config.ts | 27 +++- src/containers.spec.ts | 138 +++++++++++++++++- src/containers.ts | 56 ++++++- 13 files changed, 348 insertions(+), 11 deletions(-) create mode 100644 examples/03-docker-compose/docker-compose.yml create mode 100644 examples/03-docker-compose/example.spec.js create mode 100644 examples/03-docker-compose/jest-testcontainers-config.js create mode 100644 examples/03-docker-compose/jest.config.js diff --git a/.travis.yml b/.travis.yml index 906abe9..c5f5d14 100644 --- a/.travis.yml +++ b/.travis.yml @@ -4,3 +4,4 @@ script: - npm run build - npm run example:redis - npm run example:redis-typescript + - npm run example:docker-compose diff --git a/DOC.md b/DOC.md index d6f4a27..d960808 100644 --- a/DOC.md +++ b/DOC.md @@ -2,15 +2,29 @@ ## Container Configuration ```js -// you can have multiple containers that gets started with your test -export interface JestTestcontainersConfig { +// You can either build containers using docker compose OR set up multiple images +// using SingleContainerConfig, but not both +export type JestTestcontainersConfig = + | DockerComposeContainersConfig + | MultipleContainerConfig; + +export type DockerComposeConfig = { + // The directory where the compose file is located + composeFilePath: string; + // The actual docker compose file to be built + composeFile: string; + startupTimeout?: number; +} + +// you can have multiple containers that get started with your test +type MultipleContainerConfig = { // each container needs to have a unique key // the IP and PORTS for this container will be registered with this container key // for example, with a *containerKey* of redis, you would end up with // global.__TESTCONTAINERS_REDIS_IP__ and global.__TESTCONTAINERS_REDIS_PORT_XXXX__ // variables - [containerKey: string]: SingleContainerConfig; -} + [key: string]: SingleContainerConfig; +}; export interface SingleContainerConfig { image: string; diff --git a/README.md b/README.md index fe741e4..157276a 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,7 @@ Detailed documentation of the `jest-testcontainers-config.js` can be found at [D ## Examples - [examples/01-basic-redis](examples/01-basic-redis) showcases writing integration tests against Redis. You can use `npm run build && npm run example:redis` command to run this example locally. - [examples/02-typescript-redis](examples/02-typescript-redis) same test cases as in example #1. However using Typescript instead of JavaScript. You can use `npm run build && npm run example:redis-typescript` to run this example locally. +- [examples/03-docker-compose](examples/03-docker-compose) same test cases as examples #1 and #2, however using Docker Compose to build the container. You can use `npm run build && npm run example:docker-compose` to run this example locally. - [Yengas/nodejs-postgresql-testcontainers](https://github.com/Yengas/nodejs-postgresql-testcontainers) showcases writing integration tests against PostgreSQL with schema migration and Typescript. You can check out the project page for more details. ## Watch mode support diff --git a/examples/03-docker-compose/docker-compose.yml b/examples/03-docker-compose/docker-compose.yml new file mode 100644 index 0000000..633f1d5 --- /dev/null +++ b/examples/03-docker-compose/docker-compose.yml @@ -0,0 +1,8 @@ +version: "3.7" + +services: + redis: + image: redis:6 + container_name: redis + ports: + - "6379" diff --git a/examples/03-docker-compose/example.spec.js b/examples/03-docker-compose/example.spec.js new file mode 100644 index 0000000..5448992 --- /dev/null +++ b/examples/03-docker-compose/example.spec.js @@ -0,0 +1,41 @@ +const redis = require('redis'); +const { promisify } = require('util'); + +describe('docker compose example suite', () => { + let redisClient; + + beforeAll(() => { + const connectionUri = `redis://${global.__TESTCONTAINERS_REDIS_IP__}:${global.__TESTCONTAINERS_REDIS_PORT_6379__}`; + redisClient = redis.createClient(connectionUri); + }); + + afterAll(() => { + redisClient.quit(); + }); + + it("should set a container name", () => { + expect(global.__TESTCONTAINERS_REDIS_NAME__).toBeDefined(); + }); + + it('should write correctly', async () => { + // Arrange + const setAsync = promisify(redisClient.set).bind(redisClient); + + // Act + const setResult = await setAsync('test', 73); + + // Assert + expect(setResult).toEqual('OK'); + }); + + it('should read the written value correctly', async () => { + // Arrange + const getAsync = promisify(redisClient.get).bind(redisClient); + + // Act + const getResult = await getAsync('test'); + + // Assert + expect(getResult).toEqual('73'); + }); +}); diff --git a/examples/03-docker-compose/jest-testcontainers-config.js b/examples/03-docker-compose/jest-testcontainers-config.js new file mode 100644 index 0000000..a74146a --- /dev/null +++ b/examples/03-docker-compose/jest-testcontainers-config.js @@ -0,0 +1,7 @@ +module.exports = { + dockerCompose: { + composeFilePath: ".", + composeFile: "docker-compose.yml", + startupTimeout: 10000, + } +}; diff --git a/examples/03-docker-compose/jest.config.js b/examples/03-docker-compose/jest.config.js new file mode 100644 index 0000000..5d82dc0 --- /dev/null +++ b/examples/03-docker-compose/jest.config.js @@ -0,0 +1,5 @@ +module.exports = { + // preset: "@trendyol/jest-testcontainers", // should be like this in your project + preset: "../../jest-preset.js" + // make sure you are not setting *testEnvironment* to something (e.g. node). it should not be present. +}; diff --git a/examples/README.md b/examples/README.md index b027f61..5af13b7 100644 --- a/examples/README.md +++ b/examples/README.md @@ -5,3 +5,5 @@ Example usages of the library. Simple usage of the library with plain Javascript. Starts `redis:latest` and runs set/get queries on it. ## 02-typescript-redis Simple usage of the library with Typescript. Starts `redis:5.0.5` and runs set/get queries on it. +## 03-docker-compose +Simple usage of the library and the built in ability to build Docker containers using Docker Compose. diff --git a/package.json b/package.json index b8cf1b0..e93f70f 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,8 @@ "style:fix": "npm run lint && npm run prettier", "prepublish": "npm run build", "example:redis": "cd ./examples/01-basic-redis && jest", - "example:redis-typescript": "cd ./examples/02-typescript-redis && jest" + "example:redis-typescript": "cd ./examples/02-typescript-redis && jest", + "example:docker-compose": "cd ./examples/03-docker-compose && jest" }, "lint-staged": { "*.ts": [ diff --git a/src/config.spec.ts b/src/config.spec.ts index 0a4ff7b..b4f588d 100644 --- a/src/config.spec.ts +++ b/src/config.spec.ts @@ -65,6 +65,54 @@ describe("config", () => { expect(actualConfig).toEqual(expectedConfig); }); + it("should parse to docker compose options correctly", () => { + // Arrange + const objInput: any = { + dockerCompose: { + composeFilePath: ".", + composeFile: "docker-compose.yml", + startupTimeout: 1000 + } + }; + const expectedConfig: JestTestcontainersConfig = { + dockerCompose: { + composeFilePath: ".", + composeFile: "docker-compose.yml", + startupTimeout: 1000 + } + }; + + // Act + const actualConfig = parseConfig(objInput); + + // Assert + expect(actualConfig).toEqual(expectedConfig); + }); + + it("should throw when trying to combine dockerCompose with other options", () => { + // Arrange + const objInput: any = { + dockerCompose: { + composeFilePath: ".", + composeFile: "docker-compose.yml", + startupTimeout: 1000 + }, + first: { + image: "first", + wait: { + text: "hello", + type: "text" + } + } + }; + + // Act + const expectResult = expect(() => parseConfig(objInput)); + + // Assert + expectResult.toThrow(); + }); + it("empty config should throw", () => { // Arrange const objInput: any = {}; diff --git a/src/config.ts b/src/config.ts index d72238c..9cbef16 100644 --- a/src/config.ts +++ b/src/config.ts @@ -12,9 +12,23 @@ class JestTestcontainersConfigError extends Error { export type EnvironmentVariableMap = { [key: string]: string }; export type WaitConfig = PortsWaitConfig | TextWaitConfig; -export interface JestTestcontainersConfig { +export type DockerComposeConfig = { + composeFilePath: string; + composeFile: string; + startupTimeout?: number; +}; + +type DockerComposeContainersConfig = { + dockerCompose?: DockerComposeConfig; +}; + +type MultipleContainerConfig = { [key: string]: SingleContainerConfig; -} +}; + +export type JestTestcontainersConfig = + | DockerComposeContainersConfig + | MultipleContainerConfig; export interface SingleContainerConfig { image: string; @@ -186,6 +200,15 @@ export function parseConfig(containerConfigs: any) { ); } + if ("dockerCompose" in containerConfigs) { + if (Object.keys(containerConfigs).length !== 1) { + throw new JestTestcontainersConfigError( + "testcontainers config cannot contain other images when using 'dockerCompose' option" + ); + } + return containerConfigs; + } + return Object.keys(containerConfigs).reduce( (acc, key) => ({ ...acc, diff --git a/src/containers.spec.ts b/src/containers.spec.ts index 53f4e6d..19eb997 100644 --- a/src/containers.spec.ts +++ b/src/containers.spec.ts @@ -1,16 +1,22 @@ import { Duration, TemporalUnit } from "node-duration"; -import { Wait } from "testcontainers"; +import { DockerComposeEnvironment, Wait } from "testcontainers"; import { StartedTestContainer, TestContainer } from "testcontainers/dist/test-container"; -import { JestTestcontainersConfig, SingleContainerConfig } from "./config"; +import { + DockerComposeConfig, + JestTestcontainersConfig, + SingleContainerConfig +} from "./config"; import { AllStartedContainersAndMetaInfo, + buildDockerComposeEnvironment, buildTestcontainer, getMetaInfo, startAllContainers, startContainer, + startDockerComposeContainers, StartedContainerAndMetaInfo } from "./containers"; @@ -225,6 +231,47 @@ describe("containers", () => { }); }); + describe("buildDockerComposeEnvironment", () => { + it("should create simple docker compose environment", () => { + // Arrange + const dockerComposeConfig: DockerComposeConfig = { + composeFilePath: ".", + composeFile: "docker-compose.yml" + }; + const nameRegex = new RegExp(/testcontainers-[0-9A-F]{32}/i); + + // Act + const actualEnvironment: any = buildDockerComposeEnvironment( + dockerComposeConfig + ); + + // Assert + expect(actualEnvironment.projectName).toEqual( + expect.stringMatching(nameRegex) + ); + }); + + it("should set startup timeout correctly", () => { + // Arrange + const dockerComposeConfig: DockerComposeConfig = { + composeFilePath: ".", + composeFile: "docker-compose.yml", + startupTimeout: 60000 + }; + const nameRegex = new RegExp(/testcontainers-[0-9A-F]{32}/i); + + // Act + const actualEnvironment: any = buildDockerComposeEnvironment( + dockerComposeConfig + ); + + // Assert + expect(actualEnvironment.startupTimeout).toEqual( + new Duration(60000, TemporalUnit.MILLISECONDS) + ); + }); + }); + describe("getMetaInfo", () => { it("should work with no ports", () => { // Arrange @@ -340,6 +387,58 @@ describe("containers", () => { }); }); + describe("startDockerComposeContainers", () => { + it("should call builder and getter functions", async () => { + // Arrange + const ports = [1]; + const boundPorts = new Map([[1, 2]]); + const startedContainer = ({ + containerName: "container-name", + boundPorts: { + ports: boundPorts + } + } as unknown) as StartedTestContainer; + const containerMeta: StartedContainerAndMetaInfo = { + container: startedContainer, + ip: "localhost", + name: "container-name", + portMappings: boundPorts + }; + const environment: DockerComposeEnvironment = ({ + up: jest.fn(() => + Promise.resolve({ + startedGenericContainers: { + "container-name": startedContainer + } + }) + ) + } as unknown) as DockerComposeEnvironment; + const dockerComposeBuilderFn: any = jest.fn(() => environment); + const expectedMetaResult: AllStartedContainersAndMetaInfo = { + "container-name": containerMeta + }; + const getMetaInfoFn: any = jest.fn(() => containerMeta); + const dockerComposeConfig: DockerComposeConfig = { + composeFilePath: ".", + composeFile: "docker-compose.yml", + startupTimeout: 1000 + }; + + // Act + const actualMetaResult = await startDockerComposeContainers( + dockerComposeConfig, + dockerComposeBuilderFn, + getMetaInfoFn + ); + + // Assert + expect(actualMetaResult).toEqual(expectedMetaResult); + expect(getMetaInfoFn).toHaveBeenCalledWith(startedContainer, ports); + expect(environment.up).toHaveBeenCalledWith(); + expect(dockerComposeBuilderFn).toHaveBeenCalledWith(dockerComposeConfig); + }); + }); + describe("startAllContainers", () => { it("should call starter function", async () => { // Arrange @@ -379,5 +478,40 @@ describe("containers", () => { expect(startContainerFn).toHaveBeenCalledWith(config.rabbit); expect(startContainerFn).toHaveBeenCalledWith(config.redis); }); + + it("should call docker compose starter function", async () => { + // Arrange + const config: JestTestcontainersConfig = { + dockerCompose: { + composeFilePath: ".", + composeFile: "docker-compose.yml" + } + }; + const container = (null as unknown) as StartedTestContainer; + const redisPortMappings = new Map([[1, 2]]); + const infos: AllStartedContainersAndMetaInfo = { + redis: { + name: "redis", + container, + ip: "localhost", + portMappings: redisPortMappings + } + }; + const startContainerFn: any = jest.fn(); + const startDockerComposeContainersFn: any = jest.fn(() => infos); + + // Act + const allStartedContainerAndMetaInfo = await startAllContainers( + config, + startContainerFn, + startDockerComposeContainersFn + ); + + // Assert + expect(allStartedContainerAndMetaInfo).toEqual(infos); + expect(startDockerComposeContainersFn).toHaveBeenCalledWith( + config.dockerCompose + ); + }); }); }); diff --git a/src/containers.ts b/src/containers.ts index dea2d15..bc25b41 100644 --- a/src/containers.ts +++ b/src/containers.ts @@ -1,10 +1,15 @@ import { Duration, TemporalUnit } from "node-duration"; -import { GenericContainer, Wait } from "testcontainers"; +import { + DockerComposeEnvironment, + GenericContainer, + Wait +} from "testcontainers"; import { StartedTestContainer, TestContainer } from "testcontainers/dist/test-container"; import { + DockerComposeConfig, EnvironmentVariableMap, JestTestcontainersConfig, SingleContainerConfig, @@ -87,6 +92,24 @@ export function buildTestcontainer( ); } +export function buildDockerComposeEnvironment( + dockerComposeConfig: DockerComposeConfig +): DockerComposeEnvironment { + const environment = new DockerComposeEnvironment( + dockerComposeConfig.composeFilePath, + dockerComposeConfig.composeFile + ); + if (dockerComposeConfig?.startupTimeout) { + return environment.withStartupTimeout( + new Duration( + dockerComposeConfig.startupTimeout, + TemporalUnit.MILLISECONDS + ) + ); + } + return environment; +} + export interface StartedContainerAndMetaInfo { ip: string; name: string; @@ -125,13 +148,42 @@ export async function startContainer( return infoGetterFn(startedContainer, containerConfig.ports); } +export async function startDockerComposeContainers( + dockerComposeConfig: DockerComposeConfig, + dockerComposeBuilderFn: typeof buildDockerComposeEnvironment = buildDockerComposeEnvironment, + infoGetterFn: typeof getMetaInfo = getMetaInfo +): Promise { + const environment = dockerComposeBuilderFn(dockerComposeConfig); + const startedEnvironment = await environment.up(); + // This is a private property, so the only way to access it is to ignore the + // typescript compilation error. + // @ts-ignore + const containers = startedEnvironment.startedGenericContainers; + return Object.keys(containers).reduce( + (acc, containerName) => ({ + ...acc, + [containerName]: infoGetterFn( + containers[containerName], + Array.from(containers[containerName].boundPorts.ports.keys()) + ) + }), + {} + ); +} + export type AllStartedContainersAndMetaInfo = { [key: string]: StartedContainerAndMetaInfo; }; export async function startAllContainers( config: JestTestcontainersConfig, - startContainerFn: typeof startContainer = startContainer + startContainerFn: typeof startContainer = startContainer, + startDockerComposeContainersFn: typeof startDockerComposeContainers = startDockerComposeContainers ): Promise { + if ("dockerCompose" in config) { + return startDockerComposeContainersFn( + config.dockerCompose as DockerComposeConfig + ); + } const containerKeys = Object.keys(config); const containerConfigs = Object.values(config); const startedContainersMetaInfos = await Promise.all(