JUnit 5 and Mockito

Lifecycle, parameterised tests, assertions worth reading, mocks versus stubs versus fakes, and the over-mocked test that tests nothing.

5 min read🧪 Testing Java Services

JUnit 5 and Mockito are the two libraries every Java test in this course runs on, and both are small enough to learn completely in an afternoon. What takes longer is learning what not to do with them: the test that re-implements the class in when(...) clauses, the @BeforeEach that hides what a test needs, the assertion whose failure message is expected: <true> but was: <false>. This lesson is the API, then the habits.

The lifecycle, and what runs when

JUnit 5 (the Jupiter API) creates a new instance of the test class for every test method. That is the rule that explains the rest: fields are per test, so state cannot leak between them; @BeforeEach runs before each method on that fresh instance; @BeforeAll must be static because it runs before any instance exists.

java
class OrderServiceTest {
    static Clock clock;                                   // shared: one for the class
    OrderRepository repo;                                 // fresh per test
    OrderService service;
 
    @BeforeAll  static void clock() { clock = Clock.fixed(Instant.parse("2026-09-18T00:00:00Z"), ZoneOffset.UTC); }
    @BeforeEach void setUp()        { repo = new InMemoryOrderRepository(); service = new OrderService(repo, clock); }
 
    @Test
    @DisplayName("an order over 5000 paise gets the bulk discount")
    void bulkDiscount() {
        Order o = service.place(cart(6_000));
        assertThat(o.total()).isEqualTo(Money.paise(5_400));
    }
}

@DisplayName is the sentence a failure prints; write it as the rule being tested, so that a red test reads as a broken requirement rather than a broken method name. @Disabled("reason") needs the reason. @Tag("slow") lets the build run the fast ones first. And @TestInstance(PER_CLASS) exists to change the one-instance-per-test rule, which is the first thing to suspect when tests pass alone and fail together.

Parameterised tests: one rule, many rows

The rule "over 5000 paise gets 10% off" has four interesting inputs — under, at, just over, far over — and four copies of a test are four places for the assertion to drift. One test, four rows:

java
@ParameterizedTest(name = "{0} paise → {1}")
@CsvSource({
    "4999, 4999",
    "5000, 5000",       // "over", not "at least": the boundary is the whole test
    "5001, 4501",
    "100000, 90000",
})
void discount(int amount, int expected) {
    assertThat(new Discount().apply(amount)).isEqualTo(expected);
}

@ValueSource for one argument, @CsvSource for several, @EnumSource for every constant, @MethodSource when the rows need to be built (and then the method returns a Stream<Arguments>). The boundary row is the one that catches the >= bug from the first lesson of this course — which is the point: a parameterised test is where you put the boundaries you thought about.

@Nested groups tests that share a setup into an inner class with its own @BeforeEach, so "when the customer is a student" reads as a heading in the report rather than a prefix on six method names.

Assertions worth reading when they fail

JUnit's own assertEquals(expected, actual) works and is argument-order-sensitive in a way that produces backwards messages. AssertJ's fluent form reads in the order you think, and its failure messages do the diff for you:

java
assertThat(order.lines()).hasSize(2).extracting(Line::sku).containsExactly("A-1", "B-7");
assertThat(order.total()).isEqualTo(Money.paise(5_400));
assertThatThrownBy(() -> service.place(emptyCart()))
        .isInstanceOf(EmptyCartException.class)
        .hasMessageContaining("empty");

Two habits. One behaviour per test, which usually means one or two assertions: a test with nine assertions stops at the first failure and hides the other eight. And assert on the outcome, not the path: assertThat(repo.findById(id)).isPresent() says the order was saved; verify(repo).save(any()) says a method was called, which is the same fact only until someone refactors.

Mockito: mocks, stubs, spies, and which you meant

A stub answers questions: when(rates.forCurrency("INR")).thenReturn(rate). A mock is a stub that also records calls so you can verify them. A spy wraps a real object and records, calling through unless told otherwise. A fake is a working implementation with a shortcut — the InMemoryOrderRepository above — and it is not a Mockito thing at all, which is why it is often the best of the four.

java
@ExtendWith(MockitoExtension.class)
class PaymentServiceTest {
    @Mock PaymentGateway gateway;
    @InjectMocks PaymentService service;
 
    @Test
    void chargesOnce() {
        when(gateway.charge(any(), any())).thenReturn(new Receipt("r-1"));
        service.pay(order);
        verify(gateway, times(1)).charge(eq(order.total()), any());
        verifyNoMoreInteractions(gateway);
    }
}

MockitoExtension makes an unused stub a failure (strict stubs), which is the single most useful setting in the library: a when(...) that no test path reaches is either dead or a test that does not do what its name says. ArgumentCaptor reads what was passed when eq(...) cannot express it. doThrow(...).when(mock).method() is the form for void methods. And @InjectMocks is convenient until it silently leaves a field null because the constructor signature changed; a real constructor call in @BeforeEach is one more line and never surprises you.

Over-mocking: the test that tests nothing

The first lesson's testingTheMock passed against a wrong Discount because the mock was the discount. The general form: every collaborator is mocked, every when restates what the code does, and the assertion checks that the code called the mocks — so the test is a second copy of the implementation, and it passes whenever the two copies agree, including when both are wrong.

The rule that prevents it: mock what you do not own or cannot run — the payment gateway, the clock, the mail server — and use the real thing or a fake for what you do own. A service and the repository it calls, tested together with an in-memory fake, is one test that catches the bug in either; the same pair with a mocked repository is two tests that catch nothing between them. If the setup has more when(...) lines than the test has assertions, the mock is doing the work the test was supposed to do.

Progress is saved on this device and to your account when signed in.