Topic 1.2
Build & Test Stages Done Well
In one line
A genuinely useful test stage does more than run one command — it separates fast tests from slow ones, fails fast, and reports results in a form a human (or a required status check) can actually act on.
Key ideas
- 01
FAIL FAST is a genuinely important principle for a test stage's internal ordering: run the FASTEST checks first (a linter, a quick unit test suite) before slower ones (integration tests, a full build) — if something's obviously wrong, you find out in seconds rather than waiting minutes for a slow step to even begin.
- 02
Separating UNIT tests (fast, no real dependencies, testing one piece of logic in isolation) from INTEGRATION tests (slower, often needing a real database or external service, Docker's own course's multi-container setups frequently back these) as genuinely distinct jobs or steps lets a pipeline report which KIND of failure occurred, and lets fast unit tests give feedback long before slower integration tests even finish.
- 03
TEST REPORTS in a structured format (JUnit XML is a near-universal standard, produced natively by both Maven's Surefire plugin and Gradle's own test task) let a CI platform parse and display INDIVIDUAL test results directly in its own UI — which specific test failed, and why — rather than requiring someone to scroll through raw console log output hunting for the actual failure.
- 04
A REQUIRED STATUS CHECK (Git's own course, Phase 4.4, previewed this concept) is a branch protection rule making a pipeline's success a genuine PRECONDITION for merging a pull request at all — configuring this transforms 'the tests should pass' from a hoped-for team norm into a structurally enforced rule nobody can accidentally bypass.
- 05
A well-designed test stage FAILS THE WHOLE JOB the instant any check fails (the default behavior for most CI commands, since a non-zero exit code — Linux's own course, Phase 3.1 — propagates upward) rather than silently continuing and reporting a false 'success' — this is exactly why a flaky test that's allowed to simply pass on retry, without genuine investigation, is a real, insidious risk to a pipeline's own trustworthiness over time.
- 06
CODE COVERAGE tools (JaCoCo for Java, commonly wired directly into a Maven/Gradle build) measure what percentage of your code is actually exercised by tests — genuinely useful as a directional signal and a common quality gate, though a high coverage NUMBER alone doesn't guarantee the tests are actually meaningful; it only confirms the code was executed, not that its behavior was genuinely, correctly verified.
Code & diagrams
Cheapest, fastest checks first — a linting failure is caught in seconds, not after a slow integration suite finishes.
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with: { java-version: "21", distribution: "temurin" }
# Fastest first — fails in seconds if something's obviously wrong
- name: Lint
run: mvn checkstyle:check
# Fast unit tests next
- name: Unit tests
run: mvn test -Dtest="**/*UnitTest"
# Slower integration tests last
- name: Integration tests
run: mvn verify -Dtest="**/*IntegrationTest"
# Publish structured test results for the platform's own UI
- name: Publish test results
uses: dorny/test-reporter@v1
if: always()
with:
name: Test Results
path: "target/surefire-reports/*.xml"
reporter: java-junitExplain it without notes
Why does putting a fast linter step before a slow integration test suite genuinely save real time, even though the total number of checks is unchanged?
Why is it genuinely risky for a team to get into the habit of simply re-running a pipeline when a test fails, without investigating why?
Practice
Reorder an existing pipeline's steps (or design a new one) so the fastest, cheapest checks genuinely run first, and confirm a deliberately introduced fast-check failure (like a lint error) is reported within seconds, without waiting for slower steps.
If your build tool supports it, configure structured JUnit XML test report publishing and confirm your CI platform's UI shows individual test results, not just raw console output.
Trade-offs
- ↔
Splitting unit and integration tests into separate, distinctly-run steps gives clearer, faster feedback, but it does require genuine discipline in how tests are ORGANIZED and NAMED in the first place (so a build tool can correctly select 'just the unit tests') — a codebase where this distinction was never established requires real, sometimes tedious reorganization work before this kind of fast/slow separation becomes possible at all.
Done when you can
I understand the fail-fast principle and can order a pipeline's steps from fastest to slowest.
I can explain why separating unit tests from integration tests as distinct steps is genuinely useful.
I know why treating a flaky test as noise to retry past, rather than a signal to investigate, is a real risk.