In a Dockerfile, what happens to `docker build` when a `RUN` test command exits non-zero?
answer
- The build stops the moment a step fails
- Every step's status is checked before commit
- Shell form only reports the last command
- An unchanged step is never executed again
basics
~20 sA non-zero exit from any RUN command aborts docker build immediately: no layer is committed for that step and no image is tagged. Running the test suite in a RUN step is what turns a failing test into a failing build.
solid answer
~50 sEvery `RUN` step executes its command in a throwaway container on top of the previous layer, and the builder commits the result only if that command exits 0. Any other status stops the build with `ERROR: process "/bin/sh -c ..." did not complete successfully: exit code: 1`, and `docker build` itself exits non-zero, so the calling script or CI step fails too. That is the whole mechanism behind testing during a build: `RUN bundle exec rspec` in a test stage gates the image with no plugin or reporter involved. Two things bite people. Shell form runs the command under `/bin/sh -c`, so only the last command's status counts — `rspec | tee out.log` reports `tee`'s success, and `|| true` swallows the failure outright. And a `RUN` whose cache key is unchanged is not executed at all, so the test step must sit after the `COPY` of the source, or a build can be green because the suite never ran.
code
dockerfile · 6 linesFROM ruby:3.3-bookworm AS test
WORKDIR /app
COPY Gemfile Gemfile.lock ./
RUN bundle install
COPY . .
RUN bundle exec rspecgo deeper
Be ready to say plainly that a non-zero exit from a RUN command stops the build and produces no image. Know that test runners already exit non-zero on failure, so no extra wiring is needed.
Explain the mechanics: each RUN runs in a container, the layer is committed only on exit 0, and shell form means only the last command's status counts. Mention that a cached RUN never executes.
Show the judgment about ordering and cache: where the test step sits relative to the source COPY decides whether it ever runs, and a swallowed exit code turns the gate into decoration. Be able to say what a failed build leaves behind.
Own the tradeoff of making the image build the test gate at all: it couples build time to suite time, hides reports inside the builder, and duplicates work a separate test step already does. Be ready to argue when that coupling is worth it.
### What a RUN step actually does A `RUN` instruction in a Dockerfile starts a container from the current stage's filesystem, executes one command inside it, waits for that process to finish, and commits the resulting filesystem as a new read-only image layer. The commit happens **only** if the process exited with status 0. Any other status aborts the entire build; the builder prints a line of the shape ``` ERROR: process "/bin/sh -c bundle exec rspec" did not complete successfully: exit code: 1 ``` and `docker build` returns non-zero to whatever invoked it. Nothing is tagged, and the failing step's container is discarded. That single rule is the entire reason a test suite can live inside an image build. Test runners already signal failure the Unix way — a non-zero exit — so `RUN <run the suite>` needs no integration of any kind to make a red suite stop the pipeline before an image exists to push. ### Shell form is where exit codes get lost `RUN bundle exec rspec` is *shell form*: the builder actually runs `/bin/sh -c "bundle exec rspec"`. The step's status is the shell's status, which is the status of the **last** command on that line. Three ways that silently disarms the gate: - `RUN bundle exec rspec | tee /tmp/test.log` — the reported status is `tee`'s, essentially always 0. Put a `SHELL ["/bin/sh", "-o", "pipefail", "-c"]` instruction above it so the pipeline reports the first failure instead (note that busybox `sh`, the default shell in Alpine-based images, does not support `pipefail`; those images need `bash` installed or a restructured command). - `RUN bundle exec rspec || true` — deliberately swallows the failure. Sometimes intentional, when you want the report files and will assert the result later, but then something else must assert it. - `RUN bundle exec rspec; echo done` — the `;` discards the status the same way. Exec form, `RUN ["bundle", "exec", "rspec"]`, skips the shell entirely: no globbing, no variable expansion, and the process's own status is the step's status. ### The build cache decides whether the tests run at all A `RUN` step whose cache key is unchanged is not executed — the builder reuses the layer it produced last time. The cache key is built from the instruction text and the state of everything above it, so a test step re-runs only when something before it changed. That is usually exactly what you want, but it makes ordering load-bearing: copy the source in *before* the test step, so a source change invalidates it. Otherwise you get the worst outcome available — a green build whose suite was a cache hit. `--no-cache` disables caching for the whole build; `docker buildx build --no-cache-filter test` disables it for one named stage; varying a `--build-arg` used in that stage also busts it. ### Worked example A chat-message fan-out worker written in Ruby, 1,847 examples in its suite: ```dockerfile FROM ruby:3.3-bookworm AS test WORKDIR /app COPY Gemfile Gemfile.lock ./ RUN bundle install COPY . . RUN bundle exec rspec ``` The `bundle install` layer is cached across builds because the two dependency files rarely change; editing a worker source file invalidates the `COPY . .` layer and therefore re-runs the suite. Three failures out of 1,847 and the build stops on that last step — the build log carries the failure output, and no image exists. ### What it costs you A build that fails on the test step produces no image and no files on the host: everything the suite wrote lives in a layer that was never committed. Your only artefact is the build log, which is why teams that want JUnit XML or coverage out of the run need an explicit export step rather than the exit code alone. There is also a scheduling difference worth knowing: the builder runs independent stages concurrently, so build output from a test step can interleave with other stages' output in the log. Finally, remember which exit code you are looking at. The status that matters here is the one from the test process inside the build. It is not the same as the exit code of a container you later start from the finished image, and it is not the `docker build` process's own status — that is simply 1 when any step failed.
- Why can a RUN test step pass without the tests having run?Because the step was a cache hit. The builder re-executes a `RUN` only when its cache key changed, and the key comes from the instruction text plus everything above it. If the source is copied in after the test step, or nothing above it changed, the previous layer is reused and the suite is skipped. Put the test step after the source `COPY`, and use `--no-cache-filter` on that stage when you need to force it.
- How do you make a piped test command still fail the build?Add `SHELL ["/bin/sh", "-o", "pipefail", "-c"]` before it so the pipeline's status is the first non-zero one rather than the last command's. If the image's shell has no `pipefail` — busybox `sh` in Alpine images — install `bash` and set it as the SHELL, or drop the pipe and let the runner write its own log file.
- What is left on the host when a build fails on the test step?No image and no files. Layers for the steps that succeeded stay in the build cache and speed up the next attempt, but the failing step's container is discarded along with everything it wrote. The build log is the only output, so if you need report files you must export them explicitly rather than relying on the failed build.
saying these in an interview costs you the question
- Thinks a failing RUN step is skipped and the build continues
- Ends the test command with || true to keep builds green
- Pipes test output to tee and still expects failures to surface
- Assumes the suite re-runs on every build regardless of cache
- Believes a failed build still leaves a tagged image behind