Skip to the content.
2023/10/31

Why?

Docker’s multi-stage builds are a powerful tool for producing smaller images, faster. We can also use multi-stage Docker builds to test our images.

Sometimes, we aren’t building an application, but an image to be the basis of downstream applications - for example, a development environment. In this case, we can’t simply copy a single application executable and test it, our system under test is the image itself.

# The image we build
FROM debian AS foundation
# Our downstream users will read /config_files
COPY . /config_files
# Downstream app using our image
FROM foundation AS app
COPY . /app
RUN /app/downstream_app /config_files

How?

How do we test that our foundation image maintains its contract to downstream users?

We could write a test that inspects our image and validates our contracts. That could be awkward, since our existing testing framework, if we have one, is probably geared toward testing application or library code, not Docker images.

We can multi-stage builds to run our tests during the build phase, failing the build if our tests fail.

# The image we want to release after testing
FROM debian AS foundation_candidate
COPY . /config_files

# Our test
FROM foundation_candidate AS test
COPY test.sh /test.sh
# Write /tmp/results.txt only if test passes
RUN /bin/sh test.sh && echo "pass" > /tmp/result.txt

# The stage we will actually release
FROM foundation_candidate AS foundation
# This will cause the `test` stage to build. If that stage fails to write /tmp/result.txt,
# this `COPY` will fail, and our Docker build will fail.
COPY --from=test /tmp/result.txt /dev/null

We can build this image by targeting the final build stage.

A failing build:

% echo "exit 1" > test.sh
% docker build -t foundation --target foundation .
 => ERROR [test 2/2] RUN /bin/sh test.sh && echo "pass"                                                                                                                                                         0.1s
------
 > [test 2/2] RUN /bin/sh test.sh && echo "pass":
------
Dockerfile:9
--------------------
   7 |     COPY test.sh /test.sh
   8 |     # Write /tmp/results.txt only if test passes
   9 | >>> RUN /bin/sh test.sh && echo "pass"
  10 |
  11 |     # The stage we will actually release
--------------------
ERROR: failed to solve: process "/bin/sh -c /bin/sh test.sh && echo \"pass\"" did not complete successfully: exit code: 1

A passing build!

% echo "exit 0" > test.sh
% docker build -t foundation --target foundation .

The final image doesn’t contain the tests, but it does contain a 5 byte layer from copying the test results to /dev/null.

% docker history foundation
IMAGE          CREATED              CREATED BY                                      SIZE      COMMENT
2ca32e95dd61   About a minute ago   COPY /tmp/result.txt /dev/null # buildkit       5B        buildkit.dockerfile.v0
<missing>      About a minute ago   COPY . /config_files # buildkit                 512B      buildkit.dockerfile.v0
<missing>      2 weeks ago          /bin/sh -c #(nop)  CMD ["bash"]                 0B
<missing>      2 weeks ago          /bin/sh -c #(nop) ADD file:bf4264671bd91eb30…   139MB

Can we improve it?

Slightly! Let’s print out an error message when tests fail.

# Write /tmp/results.txt only if test passes
RUN (/bin/sh test.sh && echo "pass" > /tmp/result.txt) || (echo "Tests Failed" ; exit 42)

Now fail a test.

% echo "exit 1" > test.sh
% docker build -t foundation --target foundation .
=> ERROR [test 2/2] RUN (/bin/sh test.sh && echo "pass" > /tmp/result.txt) || (echo "Tests Failed" ; exit 42)                                                                                                  0.1s
------
 > [test 2/2] RUN (/bin/sh test.sh && echo "pass" > /tmp/result.txt) || (echo "Tests Failed" ; exit 42):
0.089 Tests Failed
------
Dockerfile:9
--------------------
   7 |     COPY test.sh /test.sh
   8 |     # Write /tmp/results.txt only if test passes
   9 | >>> RUN (/bin/sh test.sh && echo "pass" > /tmp/result.txt) || (echo "Tests Failed" ; exit 42)
  10 |
  11 |     # The stage we will actually release
--------------------
ERROR: failed to solve: process "/bin/sh -c (/bin/sh test.sh && echo \"pass\" > /tmp/result.txt) || (echo \"Tests Failed\" ; exit 42)" did not complete successfully: exit code: 42

Is that better? Well, it does say Tests Failed.

Parallel tests

Docker runs the non-dependent stages of a multi-stage build in parallel. Once the stages our tests depend on have built, the tests can run in parallel.

# The image we want to release after testing
FROM debian AS foundation_candidate
COPY . /config_files

# Our tests
FROM foundation_candidate AS test1
COPY test1.sh /test1.sh
# Write /tmp/results1.txt only if test passes
RUN /bin/sh test1.sh && echo "pass" > /tmp/result1.txt

FROM foundation_candidate AS test2
COPY test2.sh /test2.sh
# Write /tmp/results2.txt only if test passes
RUN /bin/sh test2.sh && echo "pass" > /tmp/result2.txt

# The stage we will actually release
FROM foundation_candidate AS foundation
# This will cause the `test1` and `test2` stages to build. 
# If either of those stages fail to write /tmp/resultN.txt,
# the relevant `COPY` will fail, and our Docker build will fail.
COPY --from=test1 /tmp/result1.txt /dev/null
COPY --from=test2 /tmp/result2.txt /dev/null

Run it as before, notice that test2 doesn’t wait for test1 to finish.

% echo "sleep 2 && exit 0" > test1.sh
% echo "exit 0" > test2.sh
% docker build -t foundation --target foundation .

Alternatives

We could stick with a dedicated CI testing phase using its own Dockerfile to import and test the one we’re building.

We can use features of our CI system to pass our artifact between phases.

More people can read Dockerfiles than can understand our CI system, and those people probably know how to docker build our Dockerfile on their dev machine, but they probably don’t know how to execute our CI pipeline there.

Advantages

Disadvantages

The Bottom Line

CI pipelines typically have a testing phase. If our build artifact doesn’t fit well into our testing paradigm, we may end up writing a complex test just to “fit in” with a testing framework not designed for our artifact type. Or we may end up with no tests at all.

In those cases, we can leverage Docker’s powerful built-in features to deliver tested artifacts to our customers.