# Java Clean Tests

> Enforces test quality in Java with JUnit 5 and AssertJ — one concept per test, boundary coverage, fast isolated tests, parameterised cases, and no disabled tests without a reason. Use when writing or reviewing Java tests, and when the user mentions JUnit, AssertJ, Mockito, Testcontainers, @ParameterizedTest, @Disabled, flaky tests, coverage gaps, or asks "how should I test this".

- Skill: `caslubbers/java-clean-tests` (Agent Skill)
- Install (CLI): `npx skillmds@latest add caslubbers/java-clean-tests`
- Raw SKILL.md: https://api.skillmd.com/api/skills/caslubbers/java-clean-tests/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: CasLubbers (https://skillmd.com/u/caslubbers)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/caslubbers/java-clean-tests

---


# 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.

```java
// 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

```java
// 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.

```java
@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:

```java
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

```java
@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.

```java
// 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

```java
// 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.

