Clean tests in Java
Test code is production code. It is read more often than the code it covers, because it is read whenever the code breaks.
Name the behaviour
The name is the failure report.
// Bad
@Test void test1() { ... }
@Test void testWithdraw() { ... }
// Good
@Test void withdrawFailsWhenBalanceIsInsufficient() { ... }
@Test void withdrawLeavesBalanceUnchangedWhenItFails() { ... }
@DisplayName carries a full sentence where the method name gets unwieldy, and @Nested groups cases around one scenario.
One concept per test
// Bad — three unrelated assertions; the first failure hides the rest
@Test void testOrder() {
assertThat(order.total()).isEqualTo(euros(100));
assertThat(order.status()).isEqualTo(PENDING);
assertThat(order.items()).hasSize(3);
}
// Good — one reason to fail each
@Test void totalSumsItemPrices() { ... }
@Test void newOrderStartsPending() { ... }
Several assertions about one concept are fine — assertThat(order).extracting(...), or assertAll when you want every field reported at once. The rule is one reason to fail, not one assertion statement.
Arrange, act, assert
Three visible blocks, separated by a blank line. If arrange runs to twenty lines, the class under test needs too much to exist — that is a design signal, not a test problem.
@Test
void withdrawFailsWhenBalanceIsInsufficient() {
var account = new Account(euros(50)); // arrange
var thrown = catchThrowable(() -> account.withdraw(euros(100))); // act
assertThat(thrown).isInstanceOf(InsufficientFundsException.class) // assert
.hasMessageContaining("50");
assertThat(account.balance()).isEqualTo(euros(50));
}
AssertJ over bare assertions
Fluent assertions produce failure messages that say what was expected and what arrived, including for collections and exceptions:
assertThat(users).extracting(User::email).containsExactly("a@x.com", "b@x.com");
assertThatThrownBy(() -> parse("")).isInstanceOf(ParseException.class);
assertThat(result).usingRecursiveComparison().isEqualTo(expected);
assertEquals(expected, actual) with no message tells you two values differed and nothing else.
Parameterise repeated cases
@ParameterizedTest
@CsvSource({
"0, 0, valid boundary",
"-1, 0, negative rejected",
"1001, 0, above maximum rejected"
})
void clampsToRange(int input, int expected, String description) { ... }
@ValueSource, @CsvSource, @EnumSource, and @MethodSource cover most tables. Adding a case becomes one line.
Test the boundaries
The happy path is the case least likely to break. Cover empty and single-element collections, zero, negative, maximum, off-by-one at both ends, null where accepted, duplicate entries, and the first and last iteration. When you fix a bug, write the test that would have caught it, then test around it — bugs cluster.
Fast, isolated, repeatable
A unit test does no real I/O: no database, no network, no sleeping, no clock. Inject a Clock rather than calling Instant.now(), and a fake repository rather than a real one. Tests must pass in any order and in parallel, which means no shared static state and no dependence on a previous test's writes.
Where a real dependency is the point, use Testcontainers and label it an integration test so the fast suite stays fast.
Mock what you own, at the boundary
Mock the interfaces you defined for external systems. Do not mock value objects, collections, or the type under test, and do not assert on interactions when you can assert on results — verify on every call turns the test into a copy of the implementation, which then breaks on every refactor.
// Prefer a stub returning data
when(userRepository.findById("42")).thenReturn(Optional.of(alice));
// Over asserting the call happened
verify(userRepository).findById("42"); // only when the call itself is the behaviour
No disabled tests without a reason
// Bad
@Disabled
@Test void reconcilesLedger() { ... }
// Good
@Disabled("PLAT-1182: flaky until the fixture clock is injected")
@Test void reconcilesLedger() { ... }
A silently disabled test is a lie about coverage. Fix it, delete it, or say why it sleeps and when it wakes.
Flakiness is a bug
An intermittent failure is a real defect — usually a race, a shared fixture, or a dependence on wall-clock time. Retrying it hides a bug your users will hit. Fix the cause.
Coverage is a map, not a target
Use it to find untested branches. Chasing a percentage produces tests that execute code and assert nothing.