skip to content

How do you get test and coverage reports out of a test stage in a `docker build`?

level: seniorimportance: nice to knowfreq 30%

answer

  1. The host sees nothing a build step wrote
  2. The build's last step decides what comes out
  3. Export a stage's filesystem, or run a container
  4. Exporters only run after a successful build

basics

~20 s

A build writes nothing to the host, so export the files: docker buildx build --target report --output type=local,dest=./reports writes that stage's filesystem into ./reports. Or build the test stage into an image and run it with a bind mount.

solid answer

~50 s

Files a `RUN` step writes live in an image layer, not on the host, so you need an exporter or a container. The build route is `docker buildx build --target report --output type=local,dest=./reports .`, where `report` is a tiny stage — usually `FROM scratch` — holding only the JUnit XML and coverage files, because `--output type=local` writes the target stage's **whole** filesystem to the destination. The catch is ordering: exporters run only after the build graph succeeds, so if the suite fails you get a failed build and an empty directory. To keep reports on failure, let the test step record its status instead of failing (`...; echo $? > /out/status`) and have the pipeline assert that file afterwards. The container route sidesteps this: `docker build --target test -t x .` then `docker run --rm -v "$PWD/reports:/out" x <run the suite>` — the bind mount keeps the files whatever the exit code.

code

dockerfile · 11 lines
dockerfile
FROM ruby:3.3-bookworm AS test
WORKDIR /app
COPY Gemfile Gemfile.lock ./
RUN bundle install
COPY . .
RUN mkdir -p /out && \
    ( bundle exec rspec --format RspecJunitFormatter --out /out/junit.xml ; \
      echo $? > /out/status )

FROM scratch AS report
COPY --from=test /out /

go deeper

for a junior

Know that anything a build step writes stays inside the image, and that getting a file onto the host means either exporting it or running a container with a bind mount.

for a middle

Explain the exporter: --output type=local writes the target stage's filesystem to a directory, which is why the export target is a scratch stage holding only the reports.

for a senior

Demonstrate the failure-path thinking: exporters run only after a successful build, so a naive setup loses reports precisely when the suite is red. Show how you keep both the artefacts and the gate.

for a principal

Own the decision of where the suite runs at all — inside the build graph, with its cache and parallelism but awkward artefact extraction, or as a container run whose exit code and files behave like every other step in the pipeline.

### Why the files are not on your disk Everything a `RUN` step writes goes into that step's image layer. The build context flows one way — host into the builder — and nothing flows back by default. `docker build` with no other flags hands the finished image to the engine; a JUnit XML file the suite wrote at `/app/tmp/junit.xml` is inside a layer of an image you may not even have tagged. Getting it onto the runner is a deliberate act, and there are two shapes for it. ### Route one: a BuildKit exporter BuildKit ends every build by running an *exporter* that decides what to do with the result. `--output type=docker` (or `type=image`) produces an image; `type=local,dest=<dir>` writes the result's filesystem to a host directory; `type=tar,dest=<file>` writes it as a tarball. `-o ./reports` is shorthand for the local form. The important word is *filesystem*. `--output type=local` copies the target stage's entire root filesystem into the destination, so pointing it at a full test stage dumps a whole Ruby base image into your workspace. The idiomatic answer is a dedicated export stage that starts from `scratch` and contains only the artefacts: ```dockerfile FROM ruby:3.3-bookworm AS test WORKDIR /app COPY Gemfile Gemfile.lock ./ RUN bundle install COPY . . RUN mkdir -p /out && \ ( bundle exec rspec --format RspecJunitFormatter --out /out/junit.xml ; \ echo $? > /out/status ) RUN cp -r coverage /out/coverage || true FROM scratch AS report COPY --from=test /out / ``` Built with: ```bash docker buildx build --target report --output type=local,dest=./reports . test "$(cat ./reports/status)" = 0 || { echo 'suite failed'; exit 1; } ``` Two details decide whether this works in anger. First, exporters run **after** the build graph completes; a build that fails exports nothing, so if the test step propagates its failure you lose exactly the reports you wanted for the failure. That is why the example records the status into a file and lets the step succeed, then makes the pipeline — not the builder — fail on the recorded value. Second, on a multi-platform build the local exporter writes one subdirectory per platform, so a report path that worked for a single-platform build suddenly has an extra level in it. ### Route two: run a container The alternative treats the test stage as an ordinary image: ```bash docker build --target test -t chatfanout-test . mkdir -p reports docker run --rm -v "$PWD/reports:/out" chatfanout-test \ bundle exec rspec --format RspecJunitFormatter --out /out/junit.xml ``` Here the suite writes straight through a bind mount onto the host, so the files exist whether the run passed or failed, and `docker run`'s own exit code is the gate — no status file needed. It is simpler to reason about and it is what most pipelines end up doing. Its costs: the suite runs in a container started from the image rather than inside the build, so it does not benefit from the build's parallelism or remote cache; and the process inside writes as its container user, which is root unless you set one, so a rootful engine leaves root-owned files in the workspace. Add `--user "$(id -u):$(id -g)"` when that matters. A third variant exists for a container that has already exited: `docker cp <container>:/app/tmp/junit.xml ./` works on a stopped container, which is handy when you forgot the bind mount and do not want to re-run 1,847 examples to get the file back. It does not work against an image — only a container, running or stopped. ### Choosing If the suite is already a build step and you want its output as a build artefact, use the exporter and accept the status-file dance. If you want the natural thing — a command whose exit code is the verdict and whose files land on disk either way — build the test stage to an image and run it. Whichever you pick, decide up front what happens on failure: the most common defect here is a pipeline that publishes beautiful reports on green runs and nothing at all on the red ones you actually needed to read.

  • What exactly does --output type=local export?
    The target stage's entire root filesystem, copied into the destination directory. That is why the export target is normally a `FROM scratch` stage containing only the report files — aiming it at the test stage itself would write the whole base image into your workspace. On a multi-platform build the exporter writes one subdirectory per platform under the destination.
  • Your pipeline now keeps reports on failure but the build is always green. What is missing?
    The assertion. Once the test step swallows its own status to let the export happen, nothing fails the pipeline any more. Something afterwards has to read the recorded status — a shell step that checks the exported status file, or a second build of a verify stage that asserts it — or the gate is gone and only the reports remain.
  • Why do the report files end up owned by root on the runner?
    Because a container writing through a bind mount writes as the user inside the container, and that is root unless the image sets `USER` or you pass `--user`. On a rootful engine those UIDs land unchanged on the host. Pass `--user "$(id -u):$(id -g)"` on the `docker run`, or have the pipeline fix ownership before later steps read the files.

saying these in an interview costs you the question

  • Expects a RUN step to write files onto the host
  • Thinks a failed build still exports its artefacts
  • Confuses --output with pushing the image to a registry
  • Tries to docker cp out of an image instead of a container
  • Points --output type=local at a full test stage

context