rgoussu@goussu: ~/library/java/testing
~/library/java/testing cat unit-testing.md

Unit testing — JUnit 5, Mockito, AssertJ

# The Java unit-testing stack — JUnit 5's Jupiter model, Mockito's disciplined use, AssertJ assertions, and test-double taxonomy.

Conceptsaved 2026-08-09 #java#testing#junit#mockito#tooling

Overview

Unit tests exercise one behaviour in isolation and run in milliseconds; they are the fast inner loop of TDD and the bulk of most suites. The de-facto Java stack is JUnit 5 (the Jupiter programming model) for structure and lifecycle, Mockito for test doubles where a collaborator genuinely cannot be real, and AssertJ for readable, fluent assertions. Plain units need no framework at all — and since Spring, Quarkus and Micronaut all run their tests on Jupiter, the same skills and tooling carry straight into framework tests.

Key points

  • JUnit 5 = Platform + Jupiter + Vintage: the Platform launches test engines (build/IDE integration), Jupiter is the modern API and extension model, Vintage runs legacy JUnit 4.
  • Extensions replace runners/rules: JUnit 4 allowed one @RunWith runner per class and composed awkwardly via @Rule; Jupiter's @ExtendWith composes any number of extensions hooking into well-defined lifecycle points.
  • Parameterized tests are first-class: @ParameterizedTest with @ValueSource, @CsvSource, @MethodSource, @EnumSource — one test method, many cases, each reported individually.
  • Mock roles, not types: mock ports/collaborators you own at an architectural boundary; don't mock value objects, data holders, JDK types, or types you don't own (wrap them).
  • Over-mocking is the classic failure mode: a test that mirrors the implementation's call sequence verifies wiring, not behaviour, and shatters on refactor.
  • AssertJ over Hamcrest/JUnit asserts: discoverable fluent API, rich collection/ exception/optional support, and failure messages that show actual vs expected usefully.
  • Structure: given/when/then (or arrange/act/assert) per test; one behaviour per test; name tests after the behaviour (rejectsExpiredToken), not the method under test.
  • No framework for plain units: domain logic should construct its objects with new; needing a DI container in a unit test is a design smell.

Details

JUnit 5 essentials

  • Lifecycle: @BeforeEach/@AfterEach, @BeforeAll/@AfterAll; a fresh test instance per method by default (@TestInstance(PER_CLASS) to opt out).
  • @Nested inner classes group behaviours and share setup — good for given-context blocks.
  • @DisplayName for human-readable reporting; @Tag for suite slicing (fast, slow).
  • assertThrows/assertAll in the core API; dynamic tests via @TestFactory; @Timeout, @Disabled, conditional execution (@EnabledOnOs, @EnabledIfEnvironmentVariable).
  • Extension points: BeforeEachCallback, ParameterResolver, TestExecutionExceptionHandler etc. — this is exactly how MockitoExtension, SpringExtension, @QuarkusTest and @MicronautTest plug in. One model, everywhere.

Mockito discipline

  • @ExtendWith(MockitoExtension.class) + @Mock/@InjectMocks, or plain Mockito.mock(...) — the extension adds automatic validation and cleanup.
  • Stub with when(x.f(...)).thenReturn(...); prefer state verification via the result over verify(...) interaction checks; reserve verify for genuine outgoing commands (side effects) where the interaction is the behaviour.
  • ArgumentCaptor inspects what was passed to a collaborator when the argument itself is the interesting output — clearer than complex argument matchers.
  • Strictness: MockitoExtension defaults to STRICT_STUBS — unused or mismatched stubs fail the test. Keep it; lenient mocks hide dead setup and drifting tests.
  • Mockito can mock final classes and statics (mockStatic) — the capability existing does not make it a good idea; statics that need mocking usually want to be instances.

Fakes vs mocks vs stubs

Double What it is Use when
Stub Canned answers, no verification You only need input data from a collaborator
Mock Records interactions for verification The outgoing call is the observable behaviour
Fake Real, simplified implementation (in-memory repo) Many tests need a working collaborator; behaviour matters
Spy Real object, partially stubbed Legacy seams — treat as a smell

Fakes scale best: one well-tested in-memory implementation serves the whole suite and keeps tests black-box. Mock-heavy suites couple to interaction detail.

Examples

@ExtendWith(MockitoExtension.class)
class InvoiceServiceTest {

    @Mock PaymentGateway gateway;
    @InjectMocks InvoiceService service;

    @Test
    void chargesGatewayOncePerInvoice() {
        service.settle(new Invoice("inv-1", Money.eur(100)));

        var captor = ArgumentCaptor.forClass(Charge.class);
        verify(gateway).charge(captor.capture());
        assertThat(captor.getValue().amount()).isEqualTo(Money.eur(100));
    }

    @ParameterizedTest
    @CsvSource({"0, ZERO_AMOUNT", "-5, NEGATIVE_AMOUNT"})
    void rejectsNonPositiveAmounts(long cents, String reason) {
        assertThatThrownBy(() -> service.settle(invoiceOf(cents)))
            .isInstanceOf(InvalidInvoiceException.class)
            .hasMessageContaining(reason);
    }
}

Related