The Docker test stage pattern

When writing Dockerfiles for development and deployment there is a tension between keeping them well tested and lean. I would ideally want to run the tests on in the same environment as the production app. But I don’t want to install all tests and their dependencies into the production container.

I can get close to this goal with a pattern that uses multiple Docker stages.

Pre-requisites: multi-stage builds.

The general simple form

I want to have a two Dockerfile with two “flavors”, one for the production container, and one for the one with the tests. The second is based on the first one, but it has additional tools to run the the tests. I can implement this with a single Dockerfile and three stages:

The "Docker test stage" pattern
1
2
3
4
5
6
7
8
9
10
11
12
13
ARG BASE=<base-image>
FROM ${BASE} AS build

# install everything needed for the production container

# -------------------------------------------------------------------------
FROM build AS test

# install everything needed for the tests

# -------------------------------------------------------------------------
FROM build AS prod
COPY --from=test /tmp/dumm[y] /tmp/dummy
  • The build stage is shared between prod and test, and it contains the app and its dependencies.
  • The test stage is on top of build and also contains the tools neeeded for testing. It can also run the tests, to make every build implicitly tested.
  • The prod stage is also top of build, but it also makes a “fake” reference to test, so that Docker runs test before prod. (The COPY instruction will not actually copy anything if the /tmp/dummy path doesn’t exist.)

The last stage is the default target, docker build builds prod by default.

For R projects

DESCRITPION + pak

For an R project, that use DESCRIPTION to define dependencies and pak to install them, this could look:

The "Docker test stage" pattern for R projects
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
ARG BASE=ghcr.io/r-lib/rig/ubuntu-22.04-release
FROM ${BASE} AS build

COPY . .
RUN R -q -e 'pak::pkg_install("deps::.", lib = .Library); pak::pak_cleanup(force = TRUE)' && \
apt-get clean
rm -rf /tmp/*

# -------------------------------------------------------------------------
FROM build AS test

RUN R -q -e 'pak::pkg_install("deps::.", dependencies = TRUE)'
RUN R -q -e 'testthat::test_local()'

# -------------------------------------------------------------------------
FROM build AS prod
COPY --from=test /tmp/dumm[y] /tmp/dummy

renv

If I am using renv to define my R package dependencies, then I create a test profile which includes the dependencies for running the tests as well, and then I can use this Dockerfile:

The "Docker test stage" pattern for R projects with renv
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
ARG BASE=ghcr.io/r-lib/rig/ubuntu-22.04-release
FROM ${BASE} AS build

# install system dependencies for prod packages here

COPY . .
RUN R -q -e 'renv::activate(profile = "default")'

# -------------------------------------------------------------------------
FROM build AS test

# install system dependencies for test packages here

RUN R -q -e 'renv::activate(profile = "test")'
RUN R -q -e 'testthat::test_local()'

# -------------------------------------------------------------------------
FROM build AS prod
COPY --from=test /tmp/dumm[y] /tmp/dummy

Adding a dev stage

Often I also want another stage, that has extra packages for development: devtools, usethis, etc. The dev stage should also have the dependencies for the tests, but I don’t actually want to run the tests when building the dev stage. The general Dockerfile could look like this:

The "Docker test stage" pattern with a dev stage
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
ARG BASE=<base-image>
FROM ${BASE} AS build

# install everything needed for the production container

# -------------------------------------------------------------------------
FROM build AS test-deps

# install everything needed for the tests

# -------------------------------------------------------------------------
FROM test-deps AS test

# run the tests

# -------------------------------------------------------------------------
FROM build AS prod
COPY --from=test /tmp/dumm[y] /tmp/dummy

# -------------------------------------------------------------------------
FROM test-deps AS dev

# install extra tools I want for development

This Dockerfile builds the dev stage by default. I have to request the prod stage explicitly:

1
docker build --target prod .

For an R project with pak it could look like this:

The "Docker test stage" pattern with a dev stage for an R project
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
ARG BASE=ghcr.io/r-lib/rig/ubuntu-22.04-release
FROM ${BASE} AS build

COPY . .
RUN R -q -e 'pak::pkg_install("deps::.", lib = .Library); pak::pak_cleanup(force = TRUE)' && \
apt-get clean
rm -rf /tmp/*

# -------------------------------------------------------------------------
FROM build AS test-deps
RUN R -q -e 'pak::pkg_install("deps::.", dependencies = TRUE)'

# -------------------------------------------------------------------------
FROM test-deps AS test
RUN R -q -e 'testthat::test_local()'

# -------------------------------------------------------------------------
FROM build AS prod
COPY --from=test /tmp/dumm[y] /tmp/dummy

# -------------------------------------------------------------------------
FROM test-deps AS dev
RUN R -q -e 'pak::pkg_install(c("devtools", "userthis"))

Leaving out the test files

Sometimes I include test fixtures in the project, inside tests/, and I want to leave tests/ out from the prod containers, while I need to include them in the test containers. COPY cannot exclude paths currently, but BuildKit can now use Dockerfile syntax extensions, and the docker/dockerfile:1.7.0-labs extension supports excluding paths. Newer Docker installations use BuildKit by default.

The updated Dockerfile that leaves out tests/ from the prod containers could look like this:

Leave out test fixtures from the prod container
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# syntax=docker/dockerfile:1.7-labs
ARG BASE=ghcr.io/r-lib/rig/ubuntu-22.04-release
FROM ${BASE} AS build

COPY --exclude=tests . .
RUN R -q -e 'pak::pkg_install("deps::.", lib = .Library); pak::pak_cleanup(force = TRUE)' && \
apt-get clean
rm -rf /tmp/*

# -------------------------------------------------------------------------
FROM build AS test

COPY tests tests
RUN R -q -e 'pak::pkg_install("deps::.", dependencies = TRUE)'
RUN R -q -e 'testthat::test_local()'

# -------------------------------------------------------------------------
FROM build AS prod
COPY --from=test /tmp/dumm[y] /tmp/dummy

The first line defines the extended Dockerfile syntax. Then I can use COPY --exclude=... later to exclude tests from the prode image, and COPY tests only in the test stage separately.

Comments

What do you think?