Testing and Build
Which test to write
Parameterising earns its keep when cases differ only in data: same setup, same call, same assertion, different numbers. It stops earning its keep once cases differ in what they assert. See lesson 31.
| Situation | Construct | Reason |
|---|---|---|
| One behaviour, one input, one assertion | @Test | nothing varies |
| Same setup and assertion, different data | @ParameterizedTest | each declared case is its own test, so one bad case never hides another the way a for loop inside a @Test does |
| Cases differ in what they assert, not just what they feed in | separate @Test methods, one per behaviour | parameterising would hide a conditional inside the body that decides which assertion applies to which row |
| Case list only known at run time | @TestFactory returning Stream<DynamicTest> | a @ParameterizedTest source is fixed at discovery time; a factory is ordinary code |
| Input genuinely varies per run, or hunting flakiness | @RepeatedTest | repeating a deterministic assertion against fixed inputs proves nothing a single run did not |
| Several scenarios share one fixture | @Nested | groups tests around shared context without repeating it per method |
JUnit versions and artifacts
The part of the ecosystem most likely to have drifted. See lesson 29.
| Layer | Artifact | Purpose |
|---|---|---|
| Platform | junit-platform-commons, -engine, -launcher | discovers and runs tests; knows nothing about @Test |
| Engine | junit-jupiter-engine (Jupiter), junit-vintage-engine (old JUnit 4, for mid-migration) | plugs into the platform, understands one way of writing tests |
| API | junit-jupiter-api, junit-jupiter-params | the annotations and assertions you write against |
| Aggregate | junit-jupiter | pulls in API, params and engine; the right default for a project that both writes and runs its own tests |
One version number. Under JUnit 5, Platform was 1.x while Jupiter was 5.x. JUnit 6 collapsed this: Platform, Jupiter and Vintage all release as 6.1.3, together, every time. Resolving the graph for a project declaring only junit-jupiter shows every module, Platform included, at 6.1.3. Package names did not change (org.junit.jupiter.api), which is what makes the jump easy to miss.
- Java 17+ at runtime; can still test code compiled for older releases.
- Removed in 6.0.0:
junit-platform-runner,junit-platform-jfr(JFR moved intojunit-platform-launcher). - Nullability uses JSpecify;
org.jspecify:jspecifyappears transitively on the test classpath. @CsvSource/@CsvFileSourceswitched to FastCSV in 6.0.0; a regression made#unchangeable, fixed in 6.1 with acommentCharacterattribute.@Nestedclasses run in deterministic order;@TestMethodOrderon the enclosing class is inherited by nested classes.
The engine you never declared. Declare only junit-jupiter-api and hand the classpath to the Platform's own Launcher: it throws PreconditionViolationException: Cannot create Launcher without at least one TestEngine. Run the identical POM under mvn test and the build passes anyway: Surefire notices junit-jupiter-api and silently resolves and injects a matching junit-jupiter-engine, even though the POM never asked for one. Maven is therefore not where you will ever see that exception. Declare the aggregate anyway (lesson 29).
Lifecycle annotations
See lesson 29. The default is a fresh test instance per method.
| Annotation | When it runs | Must be static? |
|---|---|---|
@BeforeAll | once, before the first test | yes, under the default lifecycle; not under PER_CLASS |
@BeforeEach | before every test, on that test's own instance | no |
@AfterEach | after every test, pass or fail | no |
@AfterAll | once, after the last test | yes, under the default lifecycle; not under PER_CLASS |
@TestInstance(Lifecycle.PER_CLASS) backs the whole class with one instance instead of one per method. It buys instance-method @BeforeAll/@AfterAll and a non-static @MethodSource factory. It costs independence: a field left dirty by one test is visible to the next. Reach for it deliberately, for one expensive shared fixture, not as a habit.
Choosing an assertion
The assertion decides what the next reader sees on failure, not just whether the bug is caught. See lesson 30; compare equals versus == in equality, hashing and ordering.
| Checking | Assertion | On failure |
|---|---|---|
| Equal by value | assertEquals(expected, actual) | both operands printed via toString, e.g. expected: <Point[x=1, y=2]> but was: <Point[x=1, y=3]> |
| Equal by identity | assertSame | both operands' identity hash and content |
| A boolean condition, last resort | assertTrue/assertFalse | only expected: <true> but was: <false>; the operands are already collapsed and gone |
| An exception is expected | assertThrows(Type.class, exec) | "nothing was thrown", or the wrong type with the real exception attached as Caused by:; returns the caught exception for further asserts |
| Code must not throw (rare) | assertDoesNotThrow | a Failure naming the exception, versus an Error if left uncaught; its real use is the ThrowingSupplier overload, which also returns the value |
| Several checks on one object | assertAll(execs...) | MultipleFailuresError listing every failure, not just the first; catches any Throwable, not only assertion errors |
float/double | assertEquals(expected, actual, delta) | exact comparison shows full precision, e.g. <0.30000000000000004>; fix with a delta |
| Arrays | assertArrayEquals | "array contents differ at index [n]" |
Any Iterable | assertIterableEquals | "iterable contents differ at index [n]" |
| Text lines, allowing regex/fast-forward | assertLinesMatch | "expected line #n doesn't match actual line #n" |
| Time budget, thread-confined code | assertTimeout | runs on the calling thread; safe for ThreadLocal-bound code |
| Time budget that must not be overrun | assertTimeoutPreemptively | runs on a separate thread; breaks thread-confined code, see concurrency |
| A branch that should be unreachable | fail(message) | unconditional failure with your message |
Failure versus Error, together because the split recurs everywhere. A Failure is an assertion that ran and disagreed. An Error is anything else: an uncaught exception in the body, or (lesson 31) a parameterised argument that failed to convert before the body ran. Tests run: N, Failures: X, Errors: Y separates the two.
Parameterised test sources
Every source value starts as a String, implicitly converted to the parameter's type; a failed conversion is an Error, not a Failure. See lesson 31.
| Source | Supplies | Gotcha |
|---|---|---|
@ValueSource | one array of a primitive or String, one parameter | no null/empty case, no multiple parameters |
@NullSource/@EmptySource/@NullAndEmptySource | one case each for null/"", stacking with @ValueSource | @NullAndEmptySource is shorthand for both |
@EnumSource | one case per enum constant, or a filtered subset | case set comes from the enum's own declared constants |
@CsvSource | rows become cases, columns become parameters | commentCharacter (# default) applies only to the textBlock form; a # row there is dropped silently, case count the only evidence; the same leading # inside the array form is not a comment |
@MethodSource | a static factory returning a Stream (Stream<Arguments> or domain objects) | a local factory must be static unless @TestInstance(PER_CLASS); an external factory must always be static |
Display names matter as much as the source: the default is positional (method(int, int)[3]); a name template with {index}/{0}/{1} substitutes the raw pre-conversion value, quotes included.
Test doubles
Meszaros's five names, precisely, so "mock" stops being a catch-all. See lesson 32.
| Kind | One-line test | Reach for the real thing (or a fake) instead when |
|---|---|---|
| Dummy | passed only to satisfy a signature, never used | it starts being used for an answer; then it is a stub |
| Stub | returns canned answers, nothing more | the collaborator's behaviour across several calls matters; a fake fits better |
| Spy | a stub that also records what happened | the recorded interaction is not actually part of the contract; then it is ceremony |
| Mock | a stub with expectations the test checks via verify | the point is a return value or resulting state; use assert first |
| Fake | a real, simplified working implementation (an in-memory map) | almost never; it survives a behaviour-preserving refactor by answering actual behaviour, not a pinned call sequence |
The default-return trap. An unstubbed mock returns null, 0, or false, and does nothing for void. When that default matches what the test expected, the test passes without the mock ever answering the real question, indistinguishable from a broken implementation hardcoding the same value. Adding verify(mock).theMethod(...) exposes the gap: it fails with "zero interactions with this mock" against the broken version.
verify versus asserting on state. Asserting checks what the code produced, the promise to its caller. Verifying checks which calls the current implementation happened to make. Reach for assert first: a mock test pinning call order with InOrder fails on a refactor that changes order but not outcome; a fake-backed test asserting only final state survives it. Do not mock a type you do not own; wrap it, or use a real stand-in the library ships, such as Clock.fixed(...).
Dependency scopes
A scope answers two questions: compile classpath, and runtime classpath. See lesson 33.
| Scope | Compile | Runtime | Transitive to a consumer? |
|---|---|---|---|
compile (default) | yes | yes | yes |
provided | yes | no | no |
runtime | no | yes | yes |
test | yes, test sources only | yes, test execution only | no |
system | yes | no | no; resolved from <systemPath>, effectively deprecated |
import | n/a | n/a | valid only on a <type>pom</type> entry in dependencyManagement |
provided and test scoped dependencies of a library do not propagate: verified, a test-scoped and a provided-scoped dependency of lib-a are both absent from consumer-b's tree even though consumer-b depends on lib-a at the default scope. test scope is literal: importing a test-scoped type from src/main/java fails mvn compile itself, before mvn test is reached.
Reading the dependency graph
Mediation: nearest wins, not newest. Maven picks the version nearest the root; if two candidates sit at equal depth, the one declared first in the POM wins, with no reference to recency. See lesson 33: a newer Guava, pulled in one hop below a direct dependency, was omitted for conflict purely because another direct dependency at the same depth was declared earlier.
| Tool | Shows |
|---|---|
mvn dependency:tree | the resolved graph as it stands |
mvn dependency:tree -Dverbose | also every version considered and discarded, marked "omitted for conflict with <version that won>" |
mvn dependency:analyze | used, undeclared: a class you import that resolves only transitively; declared, unused: a dependency nothing in your code references |
Three ways to force a version: declare the coordinate directly in your own <dependencies> (depth zero beats anything transitive); manage it in <dependencyManagement> with no matching <dependencies> entry, so it still arrives only transitively but at the managed version; or import a BOM (a <type>pom</type> artifact whose only content is dependencyManagement) with <scope>import</scope>, to manage many coordinates at once.
One tooling trap: the version Maven resolves for maven-dependency-plugin by default, still 3.7.0, cannot read class files from a recent release, and the number it complains about tracks the release you compiled for: Unsupported class file major version 69 for JDK 25, and 70 for JDK 26 when rechecked a release later. Pin 3.11.0 or later, which reads both.
Lifecycle phases and what runs
Naming a phase runs every phase up to and including it, in order, executing whatever is bound along the way. See lesson 34. Maven ships three lifecycles (clean, default, site); default builds, tests and ships.
| Phase | What runs under jar packaging |
|---|---|
validate | nothing bound by default |
compile | maven-compiler-plugin's compile goal: compiles src/main/java |
test-compile | the compiler plugin compiles src/test/java |
test | maven-surefire-plugin's test goal: a failing test stops the build here, before package |
package | maven-jar-plugin's jar goal: collects target/classes into a jar; nothing compiled or run |
verify | maven-failsafe-plugin's verify goal, if configured: checks Failsafe's recorded results, fails the build if any failed |
install | copies the artifact into the local repository |
deploy | publishes it outward |
| Command | What it actually runs |
|---|---|
mvn compile | validate through compile |
mvn package | validate through package; a failing test means package is never reached |
mvn clean test | clean's clean phase, then default through test, in one invocation |
mvn integration-test | default through integration-test; Failsafe records *IT results but does not fail the build here |
mvn verify | default through verify; post-integration-test tears down what the integration tests started, then Failsafe fails the build if any recorded failure exists |
mvn install | default through install |
./mvnw ... | any of the above, through the exact Maven version pinned in .mvn/wrapper/maven-wrapper.properties, regardless of what is installed system-wide |
The Surefire/Failsafe asymmetry silently passes a broken build. Surefire fails the build the instant a unit test fails, in test. Failsafe does not fail at integration-test, because post-integration-test must tear down whatever the tests started whether they passed or not; only verify reports the failure. A pipeline stopping at mvn integration-test has silently disabled its own integration tests.
Inspecting instead of guessing. mvn help:effective-pom shows a plugin's real, project-specific version and binding. mvn help:describe -Dcmd=test reports Maven's generic default plugin version for the packaging, not what the project pins: verified, describe reported Surefire 3.5.4 for a project whose POM, build log and effective POM all agreed on 3.5.6. Trust effective-pom or the log, not describe.
-DskipTests versus -Dmaven.test.skip=true. Both skip running tests; -DskipTests still runs test-compile, catching a test file that no longer compiles, while -Dmaven.test.skip=true skips test-compile entirely.
Packaging strategies
An unconfigured jar-packaged build produces a manifest with no Main-Class; java -jar fails with "no main manifest attribute". Setting mainClass fixes that only for a project with no dependencies beyond the JDK. See lesson 35.
| Strategy | Produces | Needs at run time | Cost |
|---|---|---|---|
Plain jar, Main-Class set | the project's own classes plus a manifest | dependency classes on the classpath by some other means | a green build can be dead on arrival: NoClassDefFoundError, because Surefire tested against the full resolved classpath while package only ever collects the project's own classes |
Manifest Class-Path + copy-dependencies | a thin jar plus a lib/ directory of the actual dependency jars | lib/, alongside the jar, resolved relative to the jar's own location | lib/ must travel with the jar; copy the jar alone and it is back on the cliff |
Uber jar (maven-shade-plugin) | one self-contained jar, dependencies unpacked and repacked inside | nothing else | measured on one dependency: plain jar 3.19 KB versus shaded 699.0 KB, about 220 to 1, growing with each dependency; flattens each library's META-INF/licence provenance; needs a ServicesResourceTransformer so same-path META-INF/services entries concatenate rather than one overwriting another, and relocation for colliding transitive versions |
jlink/jpackage (named, not taught) | a custom runtime image, or a platform installer | no separately installed JDK on the target | both assume a modular application, a bigger step than this stage takes |
Orthogonal facts: an automatic module's derived name and version come from the jar's file name when it hits a module path, and change if the file is renamed; Automatic-Module-Name fixes the name half. Identical source does not build byte-identical output by default, purely from zip entry timestamps; project.build.outputTimestamp made two builds hash identically, verified with shasum/cmp.
Symptom to cause
Populated only from failures the lessons actually reproduced.
| Symptom | What it actually means |
|---|---|
NoClassDefFoundError at java -jar, after a green build with passing tests | the plain jar packages only the project's own classes; Surefire ran with the full resolved classpath, package never copies dependency jars in (lesson 35) |
Cannot create Launcher without at least one TestEngine | only reachable by calling the Platform Launcher directly; under Maven, Surefire silently resolves and injects an engine even for a bare junit-jupiter-api classpath, so the same gap passes mvn test (lesson 29) |
| A test passes while asserting nothing real happened | an unstubbed mock returned its type's default and the assertion happened to match it; a broken implementation that never consults the collaborator produces the identical pass (lesson 32) |
BUILD SUCCESS at mvn integration-test despite a failing *IT | Failsafe defers failing the build to verify, after post-integration-test teardown; check verify, never integration-test alone (lesson 34) |
| An unexpected transitive dependency version | nearest-wins mediation picked the shallowest candidate; an equal-depth tie went to whichever was declared earlier in the POM, never the newest one (lesson 33) |
| A mock-based test breaks on a refactor that changed no observable behaviour | the test verified an interaction rather than asserting on resulting state; the interaction was never part of the contract (lesson 32) |
@BeforeAll method ... must be static | the default per-method lifecycle builds a fresh instance per test, so no single instance exists yet to own a non-static @BeforeAll (lesson 29) |
A @MethodSource factory errors before the test body runs | a local factory must be static unless @TestInstance(PER_CLASS); an external factory must always be static (lesson 31) |
| A parameterised case is reported as an Error, not a Failure | JUnit could not convert the raw String to the declared parameter type before the body ran, distinct from an assertion mismatch (Failure) or an uncaught exception (also an Error) (lessons 30, 31) |
A @CsvSource case count is smaller than the declared rows | the default # comment character dropped a row silently, in the textBlock form only; the array form never treats a leading # as a comment (lesson 31) |
Handover checklist
The stage's done-when criterion as a list. See lesson 35 for the full procedure.
- [ ]
mvnw,mvnw.cmdand.mvn/wrapper/maven-wrapper.propertiesare committed, so a clone needs no pre-installed Maven (lesson 34). - [ ] Every plugin and dependency in
pom.xmlis pinned to an exact version, not a range or unpinned "latest" (lesson 33). - [ ] The README states the two commands that matter: build and test, and run what came out (lesson 35).
- [ ] From a clean copy, with nothing but a JDK on the path,
./mvnw clean verifyreportsBUILD SUCCESS(lesson 35). - [ ] The produced artifact runs with
java -jar, from outside the build's own working directory (lesson 35). - [ ] If the artifact has runtime dependencies, one of the three packaging strategies was chosen deliberately, not left as an unrunnable plain jar (lesson 35).
Version table
Stable at the time of writing, distinct from a plugin's "latest" tag, which several of these publish as a beta or milestone. Pin the version actually tested. See lesson 33.
| Artifact | Stable, at time of writing | Latest published |
|---|---|---|
org.junit:junit-bom | 6.1.3 | 6.1.3 |
maven-compiler-plugin | 3.15.0 | 4.0.0-beta-5 |
maven-surefire-plugin | 3.5.6 | 3.6.0-M1 |
maven-failsafe-plugin | 3.5.6 | 3.6.0-M1 |
maven-jar-plugin | 3.5.1 | 4.0.0-beta-1 |
maven-shade-plugin | 3.6.2 | 3.6.2 |
maven-enforcer-plugin | 3.6.3 | 3.6.3 |
maven-wrapper-plugin | 3.3.4 | 3.3.4 |
org.mockito:mockito-core | 5.23.0 | 5.23.0 |
org.assertj:assertj-core | 3.27.7 | 4.0.0-M1 |
Baseline: JDK 25, the current long-term-support release; Maven 3.9 or later (Maven 4 is only a release candidate at time of writing); maven-dependency-plugin 3.11.0 or later, since the 3.7.0 that Maven still resolves by default cannot read class files from either JDK 25 or JDK 26.
Sources
- JUnit User Guide: the primary source for the JUnit versions and lifecycle-annotations sections
- JUnit API: exact assertion overloads and annotation semantics
- JUnit 6.1.3 release notes: the version collapse, removed artifacts, and the
commentCharacterfix - Mockito API:
mock,when,verify,ArgumentCaptor, the inline mock maker's agent notice - Maven guides index: the entry point for every Maven guide used here
- Introduction to the Build Lifecycle: phases, goals, bindings and the three lifecycles
- Maven POM Reference: coordinates, scopes,
dependencyManagement,exclusions,optional - Introduction to the Dependency Mechanism: transitivity and mediation
- Maven Surefire Plugin: the unit test runner and its phase binding
- Maven Failsafe Plugin: the integration test runner and
verifybehaviour - Maven Wrapper Plugin:
wrapper:wrapperand the files it generates - Guide to Uber JAR (Shade Plugin): transformers and relocation
- Configuring for Reproducible Builds:
project.build.outputTimestamp - Enforcer Plugin rule catalogue:
banDynamicVersions,requireReleaseDeps dependency:analyze: what "used undeclared" and "declared unused" check- The jar Command, Oracle: the manifest,
Main-Class,Class-Path, module descriptors - Java SE 25 API: the link shape used for JDK class documentation