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
@RunWithrunner per class and composed awkwardly via@Rule; Jupiter's@ExtendWithcomposes any number of extensions hooking into well-defined lifecycle points. - Parameterized tests are first-class:
@ParameterizedTestwith@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). @Nestedinner classes group behaviours and share setup — good for given-context blocks.@DisplayNamefor human-readable reporting;@Tagfor suite slicing (fast,slow).assertThrows/assertAllin the core API; dynamic tests via@TestFactory;@Timeout,@Disabled, conditional execution (@EnabledOnOs,@EnabledIfEnvironmentVariable).- Extension points:
BeforeEachCallback,ParameterResolver,TestExecutionExceptionHandleretc. — this is exactly howMockitoExtension,SpringExtension,@QuarkusTestand@MicronautTestplug in. One model, everywhere.
Mockito discipline
@ExtendWith(MockitoExtension.class)+@Mock/@InjectMocks, or plainMockito.mock(...)— the extension adds automatic validation and cleanup.- Stub with
when(x.f(...)).thenReturn(...); prefer state verification via the result oververify(...)interaction checks; reserveverifyfor genuine outgoing commands (side effects) where the interaction is the behaviour. ArgumentCaptorinspects what was passed to a collaborator when the argument itself is the interesting output — clearer than complex argument matchers.- Strictness:
MockitoExtensiondefaults toSTRICT_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
- Testing in Java — strategies & tooling map — parent map of the strategy portfolio.
- Mutation testing — PIT measures whether these unit tests actually assert anything.
- Property-based testing — generated inputs where example-based unit tests run out.
- TDD — the red/green/refactor discipline these tools serve.