Java Test Design
Purpose
A failing test has one job: tell you what broke without being read. Most tests fail that
job — the name repeats the method name, the message says expected: true but was: false,
and the arrangement is thirty lines of setup shared with tests that need none of it.
The second job is surviving. A test bound to how the code is structured must be rewritten by
every refactoring, and a suite that must be rewritten by every refactoring is a suite that
gets deleted the first time a deadline arrives.
Workflow
Inspect the project's JDK/toolchain, resolved Jupiter/assertion versions, lifecycle/parallel
configuration and existing test command first. Reference snippets were authored for JDK 25,
use Java 17+ syntax and Jupiter 5 APIs, and do not authorize upgrades or new dependencies. When a
failure cannot be reproduced, report the observation and diagnostic next step, not a guessed cause.
- Name it after the condition and the expected behaviour, so a reader can predict the
assertion from the name alone:
renewalOneDayAfterTheWindowIsNotDue, not
testIsDueWithin2. Method names or digits are fine when they help describe the contract.
- Give it one reason to fail. Multiple assertions are fine when they describe one
outcome; two unrelated outcomes are two tests, because the first failure hides the second.
- Make the arrangement disappear. A builder with sensible defaults, where the test names
only the field it depends on, keeps the relevant input visible:
aSubscription().renewingOn(MARCH_9).build().
- Choose the assertion for its failure message.
assertThat(list).containsExactly(a, b)
prints both lists on failure; assertTrue(list.equals(...)) prints false.
- Remove every input you do not control — the system clock, iteration order, default
locale and zone, randomness, the filesystem. See
references/determinism.md.
- For a consequential new regression test, check a representative fault with a temporary
local mutation or the known failing revision. Restore the mutation and rerun the test;
inspect both fault detection and diagnostic clarity. Do not leave broken production code.
Rules
- Keep scenario selection explicit: parameterise data-only cases instead of branching to choose
unrelated assertions. Loops, generated cases and property assertions are valid when their oracle
is independent, readable and identifies the failing input.
- One behaviour per test;
assertAll only for several facets of the same outcome, so that
all of them are reported rather than just the first.
- Shared mutable fixture state is the cause of "passes alone, fails together". Construct in
the test or in
@BeforeEach; never mutate a static field. @TestInstance(PER_CLASS) keeps
one instance for the whole class — its fields are then shared state between tests.
- Never
Thread.sleep to wait for something. Either the thing is synchronous and the sleep
is noise, or it is not and the sleep is a race (concurrency-testing owns the alternatives).
- Never assert against a value the test computes with the same expression the code uses. That
asserts the expression equals itself and passes when both are wrong. Write the expected
value as a literal for example-based tests, or use an independent oracle/property.
- Assert on the resulting value whenever the outcome is observable as one. Verifying that a
collaborator was called is a claim about implementation, and is only justified when the
call is the outcome (java-test-doubles).
- Assert the exception type always, and its message only when the message is part of the
contract callers rely on.
assertThatThrownBy(...).isInstanceOf(...) reads better than
try/fail/catch and cannot silently pass when nothing is thrown.
- Parameterise only cases that differ in data alone. If the expected result needs a
conditional to compute, they were different tests wearing one name.
- A flaky test is a defect report about the test, code or environment. Preserve the regression
signal: do not delete, weaken or disable it just to pass. Bounded repetition can diagnose a
flake if every outcome is retained; retry-until-green is not a fix.
- Assertion helpers/custom assertions are useful for recurring domain contracts when names,
actual/expected values and caller context make failures clear. Avoid helpers that hide which
behaviour is asserted or duplicate production logic.
Report the behaviour covered, relevant boundary/failure cases, exact command and executed test
count, and any untested hypothesis. A green command with zero matching tests is not validation.
References
- JUnit patterns —
references/junit5-patterns.md. Partial examples
(Java 17+ syntax, Jupiter 5 APIs): test data builder, @ParameterizedTest with @CsvSource and implicit
java.time conversion, @Nested for context, exception assertions, and the lifecycle
choices that create shared state. Read when reaching for a Jupiter feature.
- Removing non-determinism —
references/determinism.md. The controllable inputs a test
accidentally depends on — clock, zone, locale, charset, iteration order, randomness,
filesystem, ports — each with the substitution, plus the "passes alone, fails together"
checklist. Read when a test is flaky or order-dependent.
1---2name: java-test-design3description: Writing a Java test that survives refactoring and says why it failed: naming the behaviour rather than the method, one reason to fail, test data builders over shared mutable setup, choosing the assertion that produces a readable failure, parameterised and nested tests, and removing every input the test does not control — clock, ordering, locale, randomness. Use when a test name does not say what broke, when a failure message has to be decoded by reading the test, when setup is shared across unrelated tests, when a test sleeps, when tests pass alone and fail together, when a flaky test is about to be retried or disabled, or when the same assertions are being copied across cases. Does not cover which level to test at (java-testing-strategy), stubs and mocks (java-test-doubles), the red-green-refactor loop (tdd), or threading (concurrency-testing).4---56# Java Test Design78## Purpose910A failing test has one job: tell you what broke without being read. Most tests fail that11job — the name repeats the method name, the message says `expected: true but was: false`,12and the arrangement is thirty lines of setup shared with tests that need none of it.1314The second job is surviving. A test bound to how the code is structured must be rewritten by15every refactoring, and a suite that must be rewritten by every refactoring is a suite that16gets deleted the first time a deadline arrives.1718## Workflow1920Inspect the project's JDK/toolchain, resolved Jupiter/assertion versions, lifecycle/parallel21configuration and existing test command first. Reference snippets were authored for JDK 25,22use Java 17+ syntax and Jupiter 5 APIs, and do not authorize upgrades or new dependencies. When a23failure cannot be reproduced, report the observation and diagnostic next step, not a guessed cause.24251. **Name it after the condition and the expected behaviour**, so a reader can predict the26 assertion from the name alone: `renewalOneDayAfterTheWindowIsNotDue`, not27 `testIsDueWithin2`. Method names or digits are fine when they help describe the contract.282. **Give it one reason to fail.** Multiple assertions are fine when they describe one29 outcome; two unrelated outcomes are two tests, because the first failure hides the second.303. **Make the arrangement disappear.** A builder with sensible defaults, where the test names31 only the field it depends on, keeps the relevant input visible:32 `aSubscription().renewingOn(MARCH_9).build()`.334. **Choose the assertion for its failure message.** `assertThat(list).containsExactly(a, b)`34 prints both lists on failure; `assertTrue(list.equals(...))` prints `false`.355. **Remove every input you do not control** — the system clock, iteration order, default36 locale and zone, randomness, the filesystem. See `references/determinism.md`.376. **For a consequential new regression test, check a representative fault** with a temporary38 local mutation or the known failing revision. Restore the mutation and rerun the test;39 inspect both fault detection and diagnostic clarity. Do not leave broken production code.4041## Rules4243- Keep scenario selection explicit: parameterise data-only cases instead of branching to choose44 unrelated assertions. Loops, generated cases and property assertions are valid when their oracle45 is independent, readable and identifies the failing input.46- One behaviour per test; `assertAll` only for several facets of the _same_ outcome, so that47 all of them are reported rather than just the first.48- Shared mutable fixture state is the cause of "passes alone, fails together". Construct in49 the test or in `@BeforeEach`; never mutate a static field. `@TestInstance(PER_CLASS)` keeps50 one instance for the whole class — its fields are then shared state between tests.51- Never `Thread.sleep` to wait for something. Either the thing is synchronous and the sleep52 is noise, or it is not and the sleep is a race (concurrency-testing owns the alternatives).53- Never assert against a value the test computes with the same expression the code uses. That54 asserts the expression equals itself and passes when both are wrong. Write the expected55 value as a literal for example-based tests, or use an independent oracle/property.56- Assert on the resulting value whenever the outcome is observable as one. Verifying that a57 collaborator was called is a claim about implementation, and is only justified when the58 call _is_ the outcome (java-test-doubles).59- Assert the exception type always, and its message only when the message is part of the60 contract callers rely on. `assertThatThrownBy(...).isInstanceOf(...)` reads better than61 `try/fail/catch` and cannot silently pass when nothing is thrown.62- Parameterise only cases that differ in data alone. If the expected result needs a63 conditional to compute, they were different tests wearing one name.64- A flaky test is a defect report about the test, code or environment. Preserve the regression65 signal: do not delete, weaken or disable it just to pass. Bounded repetition can diagnose a66 flake if every outcome is retained; retry-until-green is not a fix.67- Assertion helpers/custom assertions are useful for recurring domain contracts when names,68 actual/expected values and caller context make failures clear. Avoid helpers that hide which69 behaviour is asserted or duplicate production logic.7071Report the behaviour covered, relevant boundary/failure cases, exact command and executed test72count, and any untested hypothesis. A green command with zero matching tests is not validation.7374## References7576- **JUnit patterns** — `references/junit5-patterns.md`. Partial examples77 (Java 17+ syntax, Jupiter 5 APIs): test data builder, `@ParameterizedTest` with `@CsvSource` and implicit78 `java.time` conversion, `@Nested` for context, exception assertions, and the lifecycle79 choices that create shared state. Read when reaching for a Jupiter feature.80- **Removing non-determinism** — `references/determinism.md`. The controllable inputs a test81 accidentally depends on — clock, zone, locale, charset, iteration order, randomness,82 filesystem, ports — each with the substitution, plus the "passes alone, fails together"83 checklist. Read when a test is flaky or order-dependent.