Skip to content

erikengervall/dockest

Repository files navigation

Dockest

Dockest is an integration testing tool aimed at alleviating the process of evaluating unit tests whilst running multi-container Docker applications.

dockest logo



licence npm downloads licence licence snyk

Table of contents

Introduction

Motivation

The original motivation for Dockest, along with real world examples, can be read in this blog article.

Dockest was born out of frustration and with a vision to make developers’ lives slightly less miserable.

Dockest provides an abstraction for your Docker services’ lifecycles during integration testing, freeing developers from convoluted and flaky shell scripts. Adopting Dockest is super easy regardless if you’ve got existing tests or not and doesn’t necessarily require additional CI pipeline steps.

Why Dockest

The value that Dockest provides over e.g. plain docker-compose is that it figures out the connectivity and responsiveness status of each individual service (either synchronously or asynchronously) and once all services are ready the tests run.

Example use cases

Dockest can be used in a variety of use cases and situations, some of which can be found under packages/examples.

AWS CodeBuild

What is AWS CodeBuild?

AWS CodeBuild is a fully managed continuous integration service that compiles source code, runs tests, and produces software packages that are ready to deploy.

Cool, can I run it locally?

You can now locally test and debug your AWS CodeBuild builds using the new CodeBuild local agent.

Node.js to Node.js

Dockest can also build and run application services as part of your integration tests.

Basic Usage

System requirements

In order to run Dockest, there's a few system requirements:

  • Dockest uses Jest's programmatic CLI and requires Jest v20.0.0 or newer to work
  • Docker
  • Docker Compose ("On desktop systems like Docker Desktop for Mac and Windows, Docker Compose is included as part of those desktop installs.")

Install

yarn add --dev dockest
# npm install --save-dev dockest

Application code

// cache.ts
export const cacheKey = 'arbitraryNumberKey';

export const setCache = (redisClient: Redis, arbitraryNumber: number) => {
  redisClient.set(cacheKey, arbitraryNumber);
};

Unit tests

// cache.spec.ts
import Redis from 'ioredis'; // ... or client of choice
import { cacheKey, setCache } from './cache';

const redisClient = new Redis({
  host: 'localhost',
  port: 6379, // Match with configuration in docker-compose.yml
});

it('should cache an arbitrary number', async () => {
  const arbitraryNumber = 5;

  await setCache(redisClient, arbitraryNumber);

  const cachedValue = await redisClient.get(cacheKey);
  expect(cachedValue).toEqual(arbitraryNumber);
});

Dockest integration tests

Transform unit test into an integration test by creating a docker-compose.yml and dockest.ts file.

Important note for the Compose file

Dockest expects services' ports to be defined using long format and works best with high versions of docker-compose (i.e. 3.7 or higher)

# docker-compose.yml
version: '3.8'

services:
  myRedis:
    image: redis:5.0.3-alpine
    ports:
      - published: 6379
        target: 6379
// dockest.ts
import { Dockest } from 'dockest';

const dockest = new Dockest();

// Specify the services from the Compose file that should be included in the integration test
const dockestServices = [
  {
    serviceName: 'myRedis', // Must match a service in the Compose file
  },
];

dockest.run(dockestServices);

Configure scripts

Configure package.json to run dockest.ts. ts-node is recommended for TypeScript projects.

{
  "scripts": {
    "test": "ts-node ./dockest"
  },
  "devDependencies": {
    "dockest": "...",
    "ts-node": "..."
  }
}

Run

Finally, run the tests:

yarn test

Development

Publishing a new version

Prep

  • Decide on a version. Let's reference it as v1.2.3
    • Append -alpha.0 or -beta.0 to create a tagged release
  • Create release branch git checkout -b "release-v1.2.3"
  • Run yarn lerna version --no-push --no-git-tag-version
  • Merge version updates into main branch
  • Create a new release at https://github.com/erikengervall/dockest/releases/new and let the CI do the rest

Contributing

Setup and testing

This is a monorepo using lerna, meaning all scripts can be run from root.

yarn prep will executes the necessary scripts to install dependencies for all packages (including root) as well as build whatever needs building.

yarn dev:link will link the library source to each example, making developing a smoother experience.

API Reference

DockestOpts

import { Dockest } from 'dockest';

const { run } = new Dockest(opts);

DockestOpts

DockestOpts is optional, i.e. the dockest constructor can be called without arguments.

DockestOpts structure:

property type default
composeFile string docker-compose.yml
composeOpts object see paragraph on composeOpts
debug boolean false
dumpErrors boolean false
exitHandler function null
jestLib object require('jest')
jestOpts object {}
logLevel object logLevel.INFO, i.e. 3
runInBand boolean true

DockestOpts.composeFile

Compose file(s) with services to use while running tests

DockestOpts.composeOpts

composeOpts structure:

property desription type default
alwaysRecreateDeps Recreate dependent containers. Incompatible with --no-recreate boolean false
build Build images before starting containers boolean false
forceRecreate Recreate containers even if their configuration and image haven't changed boolean false
noBuild Don't build an image, even if it's missing boolean false
noColor Produce monochrome output boolean false
noDeps Don't start linked services boolean false
noRecreate If containers already exist, don't recreate them. Incompatible with --force-recreate and -V boolean false
quietPull Pull without printing progress information boolean false

Forwards options to docker-compose up, Docker's docs.

DockestOpts.debug

Pauses Dockest just before executing Jest. Useful for more rapid development using Jest manually

DockestOpts.dumpErrors

Serializes errors and dumps them in dockest-error.json. Useful for debugging.

DockestOpts.exitHandler

Callback that will run before exit. Received one argument of type { type: string, code?: number, signal?: any, error?: Error, reason?: any, p?: any }

DockestOpts.jestLib

The Jest library itself, typically passed as { lib: require('jest') }. If omitted, Dockest will attempt to require Jest from your application's dependencies. If absent, Dockest will use it's own version.

DockestOpts.jestOpts

Jest's CLI options, an exhaustive list of CLI-options can be found in Jest's documentation

DockestOpts.logLevel

Decides how much logging will occur. Each level represents a number ranging from 0-4

DockestOpts.runInBand [boolean]

Initializes and runs the Runners in sequence. Disabling this could increase performance

Note: Jest runs tests in parallel per default, which is why Dockest defaults runInBand to true. This will cause jest to run sequentially in order to avoid race conditions for I/O operations. This may lead to longer runtimes.

Run

import { Dockest } from 'dockest';

const { run } = new Dockest();

const dockestServices = [
  {
    serviceName: 'service1',
    commands: ['echo "Hello name1 🌊"'],
    dependents: [
      {
        serviceName: 'service2',
      },
    ],
    readinessCheck: () => Promise.resolve(),
  },
];

run(dockestServices);

DockestService

Dockest services are meant to map to services declared in the Compose file(s)

DockestService structure:

property type default
name string property is required
commands (string | function)[] => string[] []
dependents DockestService[] []
readinessCheck function () => Promise.resolve()

DockestService.name

Service name that matches the corresponding service in your Compose file

DockestService.commands

Bash scripts that will run once the service is ready. E.g. database migrations.

Can either be a string, or a function that generates a string. The function is fed the container id of the service.

DockestService.dependents

dependents are Dockest services that are are dependent on the parent service.

For example, the following code

const dockestServices = [
  {
    serviceName: 'service1',
    dependents: [
      {
        serviceName: 'service2',
      },
    ],
  },
];

will ensure that service1 starts up and is fully responsive before even attempting to start service2.

Why not rely on the Docker File service configuration options depends_on?

Docker's docs explains this very neatly:

version: '3.8'
services:
  web:
    build: .
    depends_on:
      - db
      - redis
  redis:
    image: redis
  db:
    image: postgres

depends_on does not wait for db and redis to be “ready” before starting web - only until they have been started.

DockestService.readinessCheck

The Dockest Service's readinessCheck function helps determining a service's readiness (or "responsiveness") by, for example, querying a database using select 1. The readinessCheck function receive the corresponding Compose service configuration from the Compose file as first argument and the containerId as the second.

The readinessCheck takes a single argument in form of an object.

const dockestServices = [
  {
    serviceName: 'service1',
    readinessCheck: async ({
      containerId,
      defaultReadinessChecks: { postgres, redis, web },
      dockerComposeFileService: { ports },
      logger,
    }) => {
      // implement your readinessCheck...
    },
  },
];

readinessCheck structure:

property description
containerId The Docker container's id.
defaultReadinessChecks Dockest exposes a few default readinessChecks that developers can use. These are plug-and-play async functions that will attempt to establish responsiveness towards a service.
dockerComposeFileService This is an object representation of your service's information from the Compose file.
logger An instance, specific to this particular Dockest Service (internally known as Runner), of the internal Dockest logger. Using this logger will prettify and contextualize logs with e.g. the serviceName.

defaultReadinessChecks

defaultReadinessChecks.postgres

The default readiness check for PostgreSQL is based on this image which expects certain environment variables.

# docker-compose.yml
version: '3.8'

services:
  postgres: # (1)
    image: postgres:9.6-alpine
    ports:
      - published: 5432
        target: 5432
    environment: # (2)
      POSTGRES_DB: baby
      POSTGRES_USER: dont
      POSTGRES_PASSWORD: hurtme
// dockest.ts
import { Dockest } from 'dockest';

const { run } = new Dockest();

run([
  {
    serviceName: 'postgres', // must match (1)
    readinessCheck: async ({
      defaultReadinessChecks: { postgres },
      dockerComposeFileService: {
        environment: { POSTGRES_DB, POSTGRES_USER }, // must match (2)
      },
    }) => postgres({ POSTGRES_DB, POSTGRES_USER }),
  },
]);

defaultReadinessChecks.redis

The default readiness check for Redis is based on this image which is plug-and-play.

# docker-compose.yml
version: '3.8'

services:
  redis: # (1)
    image: redis:5.0.3-alpine
    ports:
      - published: 6379
        target: 6379
// dockest.ts
import { Dockest } from 'dockest';

const { run } = new Dockest();

run([
  {
    serviceName: 'redis', // must match (1)
    readinessCheck: ({ defaultReadinessChecks: { redis } }) => redis(),
  },
]);

defaultReadinessChecks.web [WIP]

Requires wget. The image would most likely be a self-built web service.

The exact use case should be fleshed out.

// dockest.ts
import { Dockest } from 'dockest';

const { run } = new Dockest();

run([
  {
    serviceName: 'web', // must match (1)
    readinessCheck: async ({ defaultReadinessChecks: { web } }) => web(),
  },
]);

Utils

logLevel object

Helper constant for DockestOpts

import { logLevel } from 'dockest';

console.log(logLevel);

// {
//   NOTHING: 0,
//   ERROR: 1,
//   WARN: 2,
//   INFO: 3,
//   DEBUG: 4
// }

sleep function

Sleeps for X milliseconds.

import { sleep } from 'dockest';

const program = async () => {
  await sleep(1337);
};

program();

sleepWithLog function

Sleeps for X seconds, printing a message each second with the progress.

import { sleepWithLog } from 'dockest';

const program = async () => {
  await sleepWithLog(13, 'sleeping is cool');
};

program();

execa function

Exposes the internal wrapper of the execa library.

import { execa } from 'dockest';

const opts = {
  logPrefix,
  logStdout,
  execaOpts,
  runner,
};

const program = async () => {
  await execa(`echo "hello :wave:"`, opts);
};

program();

opts structure:

property description type default
logPrefix Prefixes logs string '[Shell]'
logStdout Prints stdout from the child process boolean false
execaOpts Options passed to the execa function object {}
runner Internal representation of a DockestService. Ignore this Runner -

Versioned Documentation

Acknowledgements

Thanks to Juan Lulkin for the logo ❤️

Thanks to Laurin Quast for great ideas and contributions 💙

License

MIT