Build & test stages

Compile, test, and fail fast.

Beginner12 min · lesson 9 of 16

A kitchen runs two checks before a plate leaves the pass: is the dish actually cooked, and does it taste right. Your pipeline runs the same two checks on code. A teammate pushes a change to the checkout service, and before any human reads a line of it, two questions have to be answered by a machine: does the code still compile, and do the tests still pass? Those two questions are the build stage and the test stage, the beating heart of every continuous integration (CI) pipeline. CI means every push gets built and tested automatically, with nobody having to remember. The build stage turns source code into something runnable: a compiled binary, a JAR, a package. The test stage runs the automated test suite against it. Get these two stages right and everything downstream, packaging, scanning, deploying, is standing on solid ground.

Jenkinsfile
pipeline {
agent { label 'linux' } // run on a worker agent, never the controller
options { timestamps() } // prefix every log line with a timestamp
stages {
stage('Build') {
// fail fast: a compile error surfaces in seconds, before the slow suite
steps { sh 'mvn -B -DskipTests package' }
}
stage('Test') {
steps { sh 'mvn -B verify' } // runs the unit + integration suite
}
}
post {
always {
// publish results whether the build passed or failed
junit '**/target/surefire-reports/*.xml'
}
}
}
console output — a green build
+ mvn -B verify
[INFO] Scanning for projects...
[INFO] --- surefire:3.2.5:test (default-test) @ checkout ---
[INFO] -------------------------------------------------------
[INFO] T E S T S
[INFO] -------------------------------------------------------
[INFO] Running com.acme.checkout.CartServiceTest
[INFO] Tests run: 12, Failures: 0, Errors: 0, Skipped: 0
[INFO] Running com.acme.checkout.PricingTest
[INFO] Tests run: 8, Failures: 0, Errors: 0, Skipped: 0
[INFO]
[INFO] Results:
[INFO] Tests run: 20, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
[Pipeline] junit
Recording test results
Finished: SUCCESS

One rule decides pass or fail: the exit code

Every command you run hands back a small number when it finishes, the way a delivery driver hands back a signed slip. Zero means it went fine. Anything else means something went wrong. That number is the exit code, and it is the whole contract a sh step lives by. A sh step is the workhorse that runs a shell command on the agent, and it succeeds on exit code 0 and fails on any other code. That is the entire rule. So you never write logic to detect a failed test. You let the test runner do what it already does. mvn -B verify exits non-zero the moment a test fails. (The -B means batch mode, which keeps the log clean and non-interactive; verify is the Maven goal that runs the lifecycle all the way up through compile, test and package.) When a step fails, its stage fails, the pipeline stops, and the build is marked FAILURE. Putting the cheap Build stage ahead of the slow Test stage is the fail-fast principle in one line: a compile error turns the build red in seconds, instead of after a ten-minute suite that was doomed before it started.

Publish the results, do not leave them buried in the log

Scrolling a console log to find the one line that names the broken test is miserable work. The junit step ends it. It reads JUnit XML, a plain-text file format that test runners write to describe what passed and what failed, and turns it into a proper Test Result page: pass and fail counts, a drill-down into any single test, and a trend graph plotting passes and failures across recent builds so a flaky test stands out at a glance. Maven writes that XML for free. The Surefire plugin, which is Maven's unit-test runner, drops one file per test class into target/surefire-reports/. The glob **/target/surefire-reports/*.xml catches all of them, in every module of a multi-module project. Keep junit inside a post block so results get recorded whether the build passed or failed. A failed build is exactly when you most want to see which test broke.

Under the hood, junit parses each XML file, stores what it finds against the build record, and stitches those numbers into the trend. When a first run logs None of the test reports contained any result, the glob almost always missed the files. Maybe the tests never ran because something failed to compile upstream. Maybe the reports landed somewhere else. Maybe the pattern has a typo in it. Check the pattern against the workspace the agent actually used. And note the guardrail: junit fails its own step when it matches zero files, unless you pass allowEmptyResults: true. That is deliberate, so a quietly broken test stage cannot pass itself off as green.

UNSTABLE versus FAILURE, the distinction that trips everyone up

Jenkins shows three everyday build results as coloured balls: SUCCESS (blue or green), FAILURE (red) and UNSTABLE (yellow). The gap between the last two is the most misunderstood thing in CI, and it comes back to that exit code. FAILURE means a step itself errored, because the command exited non-zero: the code would not compile, or Surefire stopped because a test failed. The pipeline halts there. UNSTABLE means every step exited 0 and nothing errored, but a reporting step looked at the results and judged them imperfect. When the XML it reads contains failing tests, the junit step downgrades the build to UNSTABLE rather than failing it. It never sets FAILURE itself. So the same broken test can come out either colour, and the only thing deciding which is whether the test command aborted. By default mvn -B verify aborts on a failing test, which gives you FAILURE. Run mvn -B verify -Dmaven.test.failure.ignore=true and Maven records the failures but exits 0, so the pipeline carries on and junit marks the build UNSTABLE.

console output — a failing test (FAILURE)
+ mvn -B verify
[INFO] Running com.acme.checkout.PricingTest
[ERROR] Tests run: 8, Failures: 1, Errors: 0, Skipped: 0
[ERROR] PricingTest.appliesBulkDiscount:47 expected:<90.00> but was:<100.00>
[INFO]
[INFO] Results:
[ERROR] Tests run: 20, Failures: 1, Errors: 0, Skipped: 0
[INFO] BUILD FAILURE
[Pipeline] } // Test stage aborts here (mvn exited 1)
[Pipeline] junit // still runs — it lives in post { always }
Recording test results
Finished: FAILURE
Jenkinsfile — grade results instead of aborting
stage('Test') {
// record failures but keep going, so junit grades the result:
steps { sh 'mvn -B verify -Dmaven.test.failure.ignore=true' }
}
// (junit still runs in post { always } and downgrades to UNSTABLE)
console output — same failure, now UNSTABLE
[INFO] Tests run: 20, Failures: 1, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS // mvn exited 0 despite the failing test
[Pipeline] junit
Recording test results // junit reads the XML, finds 1 failing test
Finished: UNSTABLE // junit downgraded the build — mvn never failed
One failing unit test, three possible colours
A unit test fails during the Test stage
same broken test; the exit code decides the outcome
test command exits non-zero (the default)
FAILURE (red)
sh step fails, stage aborts, pipeline stops, post { failure } runs
exits 0 via -Dmaven.test.failure.ignore=true
UNSTABLE (yellow)
pipeline carries on; junit reads the XML and downgrades the build
no junit step at all
SUCCESS (green), and a lie
nothing grades the failures; the build looks fine while tests are broken
Pick the colour on purpose, and always run junit so a broken test can never end up a silent green.

So which do you want? For unit tests, take the default and let it be FAILURE. A broken unit test means the code is wrong, and a red build that blocks the merge is the signal you want. Fail fast, fix it, move on. UNSTABLE earns its keep for softer signals: a flaky end-to-end suite, a coverage threshold, or lint warnings you want visible on the build page without blocking every merge while the team gets them under control. The anti-pattern is reaching for -Dmaven.test.failure.ignore=true only to push a greenish build past a deadline. Nobody watches the yellow ball, and broken tests pile up quietly for weeks. Whichever you pick, keep junit in post { always } so you get the report either way, red or yellow.

Where the build actually runs, and why security cares

Every sh step here executes on an agent: a worker machine, often a container that gets thrown away when the build ends, that the controller hands work to. The controller is the brain of the setup. It schedules builds, stores the configuration and the credential store, and serves the web UI, and it should run zero builds of its own. Treat that split as a security boundary rather than a scaling convenience. mvn -B verify downloads dependencies and runs test code, and when the change arrived as a pull request, that is effectively arbitrary code nobody on your team wrote, executing on the agent. Keeping builds off the controller means a hostile test cannot reach the controller's secrets or its config. If a test genuinely needs a secret, hand it over with withCredentials so Jenkins masks the value in the log, and never print a secret from inside a test, because junit publishes test output and a leaked token would sit in the report for anyone who can open the build. The junit and Surefire plugins are ones you actually use, so keep them installed and current. Plugins nobody uses are attack surface wearing a feature's icon.

A test suite that can never fail the build is set dressing
The classic own-goal is a test step that can only ever exit 0: a trailing || true, a runner set to a mode that swallows failures, or maven.test.failure.ignore=true left switched on with no junit step to grade what came back. The pipeline glows green while the tests are broken, and a green light that never means anything is worse than having no CI at all. The other common miss is forgetting that junit lives in post { always }, so on a red build the report comes back empty and you are back to scrolling logs. Check the whole chain yourself: break a test on purpose and confirm it produces a red FAILURE or a yellow UNSTABLE. Never a silent green.
Quick check
01Your Test stage runs sh 'mvn -B verify -Dmaven.test.failure.ignore=true' and publishes results with junit in a post { always } block. One unit test fails. What colour does the build end up?
Incorrect — Not here. That flag tells Maven to record the failure and still exit 0, so the sh step succeeds and nothing produces a FAILURE.
Correct — With failure.ignore=true Maven exits 0, so the pipeline carries on. The junit step then reads the XML, finds a failing test, and marks the build UNSTABLE.
Incorrect — The sh step does exit 0, but junit still grades the published results and downgrades any build containing a failing test to UNSTABLE.
Incorrect — ABORTED is for builds someone cancelled or that hit a timeout. It has nothing to do with test outcomes.
02The lesson treats running builds on an agent, rather than on the controller, as a security boundary rather than a scaling convenience. Why does that matter for security?
Incorrect — No. The controller can run steps perfectly well. The point is that it should not, so its secrets stay out of reach.
Incorrect — No. Speed has nothing to do with it. The boundary exists to isolate untrusted code.
Incorrect — Backwards. The controller is what holds the credential store, and keeping builds off it is what protects those secrets.
Correct — The build downloads dependencies and executes untrusted test code, so it belongs on a throwaway agent, well away from the controller that holds credentials and config.
03On a pipeline's first run, the junit step logs 'None of the test reports contained any result' and turns the build red at that step. What is the most likely cause?
Correct — Failing on zero matches is a deliberate guardrail so a quietly broken test stage cannot pass for green. Check the pattern against the workspace the agent really used.
Incorrect — No. Passing tests still write JUnit XML files. An empty match means the files are not where the glob looked.
Incorrect — No. There is no first-build rule for junit. The message is about the glob matching no files.
Incorrect — No. The message points at a glob or path problem, not a missing plugin. Surefire writes the XML whenever the tests actually run.

Right now this pipeline throws away everything it produced: the packaged JAR, the coverage report, the test XML. Attach those to the build instead and you can pull back exactly what build 412 made, weeks later, without rebuilding a thing. The post block can carry more weight too, notifying the right people and cleaning up the workspace on every outcome, green, yellow or red. That is the next lesson, Artifacts & post actions.

Try this

Work through “Where the build actually runs, and why security cares” yourself on a sandbox you can throw away, following the commands above in order. Then break one step deliberately and re-run, so you have seen the failure before it finds you.

Takeaway

The trap worth remembering here: a test suite that can never fail the build is set dressing. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.

Related