The last two lessons left BiblioTech with forty-one tests and a reproducible Maven project. They also left a very clear limit.

Everything tested so far was easy to test: FineCalculator, which only needs a fixed Clock; Loan's rules, which are plain Java; AtomicWrite, with a temporary directory. As soon as a real collaborator appeared, you had to write by hand an in-memory implementation of the repository and another one that recorded calls to the notice service. Twenty lines of inner classes per test, with two simple collaborators.

LoanManager has five. LoanRepository is a Spring Data interface with twenty inherited methods: implementing it by hand is simply unfeasible. And the MetadataClient from 09-06 needs a real HTTP API at the other end.

There are also questions that assertions on the result do not answer. Was the notice sent to the right employee, with the right text? Was the repository called once or forty times? What happens when the database throws an exception, a scenario that is extremely hard to provoke with a real implementation and that in production will happen on some random Tuesday?

Mockito answers all of that. It is the standard test-double library in Java: it generates fake implementations of interfaces and classes at run time —with the bytecode generation you studied in 10-03—, it lets you program their behaviour and verify how they were used.

But this lesson is not just about an API. It is about the judgement needed to use it: what to mock and what not to, when to verify interactions and when to check only the result, and why over-mocking produces exactly those brittle tests that 11-04 listed among the antipatterns. Because a badly written Mockito test can be worse than no test at all.

By the end you will be able to tell the five kinds of double apart; you will master the Mockito 5 API; you will verify interactions with judgement; you will capture arguments; you will know what must never be mocked; and you will know about integration tests with @SpringBootTest and @DataJpaTest.

Contents

  1. Why JUnit alone is not enough
  2. The five kinds of test double
  3. The in-memory fake: the underrated alternative
  4. Mockito 5: dependency and set-up
  5. Creating mocks: annotations and factory methods
  6. @InjectMocks and its caveats
  7. Defining behaviour: when(...).thenReturn(...)
  8. thenThrow: testing the error paths
  9. thenAnswer: dynamic responses
  10. doReturn, doThrow, doNothing and when they are mandatory
  11. A mock's default values
  12. Verifying interactions: verify
  13. times, never, atLeast, only
  14. inOrder and verifyNoMoreInteractions
  15. The criterion: verify commands, not queries
  16. Matchers: any, eq, argThat
  17. The all-or-nothing rule
  18. ArgumentCaptor
  19. Spies with @Spy
  20. mockStatic and why it is a warning sign
  21. What must NOT be mocked
  22. The risk of over-mocking
  23. State-based versus interaction-based tests
  24. The same case, solved both ways
  25. When the design removes the need for a mock
  26. Combining Mockito with AssertJ
  27. BiblioTech: LoanManager with mocked repositories
  28. BiblioTech: NoticeService verified with ArgumentCaptor
  29. BiblioTech: MetadataClient tested without the network
  30. Integration tests: @SpringBootTest and @MockitoBean
  31. @DataJpaTest with H2
  32. Testcontainers, introduced
  33. Coverage: an honest reading
  34. Common Mistakes and Tips
  35. Exercises

  1. Why JUnit alone is not enough

JUnit runs tests and checks results. What it does not do is solve the problem of collaborators.

LoanManager depends on five things:

public LoanManager(MaterialRepository materials,
                   EmployeeRepository employees,
                   LoanRepository loans,
                   NoticeService notices,
                   BiblioTechProperties properties,
                   Clock clock) { ... }

To test it you need all six. And three of them are problematic:

Collaborator Problem
MaterialRepository Database: slow to start, data has to be prepared and cleaned up
LoanRepository Spring Data interface with twenty inherited methods
NoticeService In production it sends real e-mails
MetadataClient (09-06) Calls an external API: slow, non-deterministic, may be down

The four reasons a collaborator gets in the way of a test:

  1. Slow: database, network, disk.
  2. Non-deterministic: the clock, randomness, API responses that change.
  3. Hard to put into a specific state: how do you make the database fail on exactly the third INSERT?
  4. With real effects: sending e-mails, charging money, deleting things.

A test double is a stand-in that takes the real collaborator's place and does what the test needs. The term comes from cinema: the stunt double.

  1. The five kinds of test double

The classic taxonomy, with precise definitions because in practice they get confused constantly:

Kind Definition What for Example in BiblioTech
Dummy Passed just to fill a parameter; never used Satisfying a signature A NoticeService in a test that notifies nobody
Stub Returns canned answers; verifies nothing Controlling what goes into the system under test Repository that always returns "Effective Java"
Spy Real or almost, and additionally records how it was called Verifying interactions without replacing everything The RecordingNotices you wrote in 11-04
Mock A double with expectations about how it must be used Verifying interactions Verifying that a notice was sent exactly once
Fake A real but simplified implementation Replacing whole infrastructure Repository backed by a HashMap

Seen another way, in terms of what each one contributes:

graph TD
    A["What do I need from the collaborator?"] --> B{"Is it used at all?"}
    B -->|No| C["DUMMY<br/>any object will do"]
    B -->|Yes| D{"Do I need it to return<br/>specific data?"}
    D -->|Yes, and I do not check how it was used| E["STUB<br/>when().thenReturn()"]
    D -->|Yes, and I also check how it was used| F["MOCK<br/>when() + verify()"]
    D -->|No, I only check that it was called| G["MOCK or SPY<br/>verify()"]
    A --> H{"Do I need real and<br/>complete behaviour?"}
    H -->|Yes| I["FAKE<br/>in-memory implementation"]

One important clarification about the vocabulary: in Mockito, everything is called a mock. Mockito.mock(X.class) creates an object you can use as a dummy, as a stub or as a mock depending on what you do with it. The distinction is conceptual, not an API matter, but knowing it helps you reason about what you are doing — and about whether you should be verifying at all.

  1. The in-memory fake: the underrated alternative

Before getting into Mockito, it is worth defending the option many people forget.

A fake is a real but simplified implementation. You wrote one in 11-04 without naming it:

class InMemoryRepository implements LoanRepository {
    private final Map<Long, Loan> data = new HashMap<>();
    private long nextId = 1;

    @Override public Loan save(Loan l) {
        if (l.getId() == null) l.assignId(nextId++);
        data.put(l.getId(), l);
        return l;
    }
    @Override public Optional<Loan> findById(Long id) {
        return Optional.ofNullable(data.get(id));
    }
    @Override public List<Loan> findByStatus(LoanStatus status) {
        return data.values().stream().filter(l -> l.getStatus() == status).toList();
    }
}

Compared with a mock:

Aspect Mock (Mockito) In-memory fake
Cost of creation One line 30-80 lines, once
Behaviour Only what you program Real and coherent
State between calls None, unless you simulate it Yes: you save and then read
Coupling to the implementation High: it knows which methods are called Low
Brittleness when refactoring High Low
Verifying interactions Yes No, unless you add it
Reusable across tests Reconfigured every time Written once

The case where the fake clearly wins:

// With a MOCK: every answer has to be programmed, and there is no coherence
when(repository.save(any())).thenReturn(loan);
when(repository.findById(1L)).thenReturn(Optional.of(loan));
when(repository.countByEmployee(marta)).thenReturn(1L);
// If the code saves and then counts, the mock does NOT reflect what was saved.
// You have to maintain by hand a coherence the fake gives you for free.

// With a FAKE: you save and you count, and the right thing comes out
repository.save(loan);
assertThat(repository.countByEmployee(marta)).isEqualTo(1);

Professional recommendation: for an application's main repository, a well-built in-memory fake is usually a better investment than twenty mocks configured across twenty different tests. It is written once, reused always and does not break when you refactor. For one-off collaborators, or when you need to provoke failures, use Mockito.

In practice both are used, and knowing how to choose is part of the craft.

  1. Mockito 5: dependency and set-up

You already have it. spring-boot-starter-test (11-04, 11-05) brings mockito-core and mockito-junit-jupiter.

Without Spring:

<dependency>
    <groupId>org.mockito</groupId>
    <artifactId>mockito-core</artifactId>
    <version>5.12.0</version>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.mockito</groupId>
    <artifactId>mockito-junit-jupiter</artifactId>
    <version>5.12.0</version>
    <scope>test</scope>
</dependency>

The basic structure of a test with Mockito:

package com.nexussoftware.bibliotech.service;

import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;

import org.junit.jupiter.api.*;
import org.mockito.*;
import org.mockito.junit.jupiter.MockitoExtension;

@ExtendWith(MockitoExtension.class)   // enables @Mock, @Spy, @Captor, @InjectMocks
class LoanManagerTest {

    @Mock private MaterialRepository materials;
    @Mock private EmployeeRepository employees;
    @Mock private LoanRepository loans;
    @Mock private NoticeService notices;

    private LoanManager manager;

    @BeforeEach
    void setUp() {
        // Explicit construction: better than @InjectMocks (section 6)
        manager = new LoanManager(materials, employees, loans, notices,
                                  testProperties(), FIXED_CLOCK);
    }
}

@ExtendWith(MockitoExtension.class) is JUnit 5's extension mechanism (11-04). It does three things: it creates the mocks before each test, it resets them between tests, and it validates their use at the end —warning about programmed behaviour that was never used, which usually points to a badly written test or to dead code—.

How it works underneath, and here module 10 comes back: Mockito uses ByteBuddy to generate at run time a subclass of the class or an implementation of the interface, with every method overridden to record the call and return whatever was programmed. It is exactly the bytecode generation mechanism of section 6 in 11-01, and it is the reason for two limitations you will run into: a final class or a final method cannot be mocked by the classic mechanism (Mockito 5 allows it with an alternative engine, but not by default).

And that is the same ByteBuddy that showed up in dependency:tree in 11-05, pulled in by Hibernate for its lazy-loading proxies. Three different tools, the same mechanism.

  1. Creating mocks: annotations and factory methods

Two equivalent ways:

// Way 1: annotation (recommended, more readable)
@ExtendWith(MockitoExtension.class)
class AnnotationBasedTest {
    @Mock private MaterialRepository materials;
}

// Way 2: factory method (useful for mocks local to a single test)
class FactoryBasedTest {
    @Test
    void example() {
        MaterialRepository materials = mock(MaterialRepository.class);
        // ...
    }
}

Useful options when creating a mock:

// With a name: error messages identify it
MaterialRepository materials = mock(MaterialRepository.class, "materialRepository");

// Different default answers
NoticeService notices = mock(NoticeService.class, Answers.RETURNS_DEEP_STUBS);

// Throws if an unprogrammed method is called: forces you to be explicit
MaterialRepository strict = mock(MaterialRepository.class, Answers.RETURNS_SMART_NULLS);

About RETURNS_DEEP_STUBS: it allows when(a.getB().getC()).thenReturn(x) without programming the intermediate calls. It is almost always a warning sign: it means your code chains calls across several objects, which violates the Law of Demeter. Avoid it and fix the design.

  1. @InjectMocks and its caveats

Mockito can build the object under test and inject the mocks into it:

@ExtendWith(MockitoExtension.class)
class LoanManagerTest {

    @Mock private MaterialRepository materials;
    @Mock private NoticeService notices;

    @InjectMocks private LoanManager manager;   // built automatically
}

Convenient, but with real drawbacks worth knowing about:

  1. It fails silently. If Mockito cannot find a mock for a parameter, it injects null without warning. The test then fails with a NullPointerException whose cause is not obvious.
  2. It does not accept non-mockable values. BiblioTech's Clock.fixed and BiblioTechProperties are not mocks: they are real objects. @InjectMocks does not supply them.
  3. It hides the constructor. One of the benefits of constructor injection (11-02) is that a constructor with nine parameters screams "this class does too much". @InjectMocks hides exactly that signal.

Recommendation: build the object explicitly in @BeforeEach.

@BeforeEach
void setUp() {
    manager = new LoanManager(materials, employees, loans, notices,
                              testProperties(), FIXED_CLOCK);
}

It is one line more, it is explicit, it lets you mix mocks with real objects and it fails at compile time if the constructor changes. Many experienced teams have abandoned @InjectMocks for these reasons.

  1. Defining behaviour: when(...).thenReturn(...)

The central operation. It is known as stubbing.

@Test
void lendingReturnsALoanDueInFifteenDays() {

    // ARRANGE: program what the collaborators return
    Material book = new Book("978-0000000001", "Effective Java", 3, "J. Bloch");
    Employee marta = new Employee("[email protected]", "Marta Ruiz");

    when(materials.findByIsbn("978-0000000001")).thenReturn(Optional.of(book));
    when(employees.findByEmail("[email protected]")).thenReturn(Optional.of(marta));
    when(loans.countByEmployeeAndStatus(marta, LoanStatus.ACTIVE)).thenReturn(0L);
    when(loans.save(any(Loan.class))).thenAnswer(inv -> inv.getArgument(0));

    // ACT
    Loan loan = manager.lend("978-0000000001", "[email protected]");

    // ASSERT
    assertThat(loan.getDueDate())
            .isEqualTo(LocalDate.of(2026, 3, 20).plusDays(15));
}

Variants of thenReturn:

// Different values on successive calls
when(loans.count())
        .thenReturn(0L)      // 1st call
        .thenReturn(1L)      // 2nd
        .thenReturn(2L);     // 3rd and onwards

// Equivalent, shorter
when(loans.count()).thenReturn(0L, 1L, 2L);

One detail about when: it is not magic, it is a trick. when(materials.findByIsbn("...")) really calls the mock's method, which internally records the invocation and returns null. when retrieves that last recorded invocation and attaches the behaviour to it. Knowing this explains why when does not work with void methods (there is nothing to pass to when) and why you have to use the do... form with spies (section 10).

  1. thenThrow: testing the error paths

Here is one of the strongest reasons to use Mockito. Testing what happens when the database fails is nearly impossible with a real implementation; with a mock it is one line.

@Test
void ifTheRepositoryFailsOnSaveNoNoticeIsSent() {

    when(materials.findByIsbn(anyString())).thenReturn(Optional.of(aBook()));
    when(employees.findByEmail(anyString())).thenReturn(Optional.of(marta()));
    when(loans.countByEmployeeAndStatus(any(), any())).thenReturn(0L);

    // The repository fails on save
    when(loans.save(any()))
            .thenThrow(new DataAccessResourceFailureException("Connection lost"));

    assertThatThrownBy(() -> manager.lend("978-0000000001", "[email protected]"))
            .isInstanceOf(DataAccessResourceFailureException.class);

    // The important bit: NOBODY was notified about a loan that was not saved
    verifyNoInteractions(notices);
}

That test verifies a real business property: what has not been persisted is not notified. Without Mockito, provoking it would mean disconnecting the database halfway through a test.

Other failure scenarios you can now test:

// Concurrency exception (the optimistic locking of 11-03)
when(materials.findByIsbn(anyString()))
        .thenThrow(new OptimisticLockingFailureException("Stale version"));

// Network timeout (the MetadataClient of 09-06)
when(httpClient.send(any(), any()))
        .thenThrow(new HttpTimeoutException("The API is not responding"));

// Failure the first time, success the second: testing the retry
when(loans.save(any()))
        .thenThrow(new OptimisticLockingFailureException("conflict"))
        .thenReturn(savedLoan);

That last pattern is exactly what you need to test the retry loop you wrote in 11-03. Without Mockito, there is no reasonable way to do it.

  1. thenAnswer: dynamic responses

When the answer depends on the arguments:

// The most common case: save returns whatever was passed to it
when(loans.save(any(Loan.class))).thenAnswer(inv -> inv.getArgument(0));

// Simulating the id assignment the database would do
when(loans.save(any(Loan.class))).thenAnswer(invocation -> {
    Loan l = invocation.getArgument(0);
    if (l.getId() == null) {
        l.assignId(nextId.getAndIncrement());
    }
    return l;
});

// An answer that depends on the argument
when(materials.findByIsbn(anyString())).thenAnswer(invocation -> {
    String isbn = invocation.getArgument(0);
    return testCatalog.containsKey(isbn)
            ? Optional.of(testCatalog.get(isbn))
            : Optional.empty();
});

A warning: when your Answer starts to contain logic, you are writing a fake inside a mock. At that point, write a real fake (section 3): it will be more readable and reusable.

  1. doReturn, doThrow, doNothing and when they are mandatory

There is a second syntax that reverses the order:

// when syntax: the usual one
when(materials.findByIsbn("x")).thenReturn(Optional.empty());

// do syntax: equivalent, but the method is named at the end
doReturn(Optional.empty()).when(materials).findByIsbn("x");

They are not always interchangeable. There are three cases where do... is mandatory:

Case 1: void methods

// DOES NOT COMPILE: notify() returns void, there is nothing to pass to when()
when(notices.notify(marta, "message")).thenThrow(new RuntimeException());

// CORRECT
doThrow(new NoticeServiceException("SMTP down"))
        .when(notices).notify(any(Employee.class), anyString());

// And to make a void method do nothing (it is the default behaviour,
// but stating it explicitly sometimes clarifies the intent)
doNothing().when(notices).notify(any(), anyString());

Case 2: spies

List<String> list = new ArrayList<>();
List<String> spy = spy(list);

// WRONG: when() REALLY CALLS get(0) on an empty list
// -> IndexOutOfBoundsException before anything is programmed
when(spy.get(0)).thenReturn("Effective Java");

// RIGHT: do... does NOT invoke the real method
doReturn("Effective Java").when(spy).get(0);

This is the number-one trap with spies, and it follows from section 7: when(x.method()) executes x.method(). On a pure mock that is harmless because there is no real implementation; on a spy, it runs the real code with all its consequences.

Case 3: reprogramming already-defined behaviour

when(materials.findByIsbn(anyString())).thenReturn(Optional.of(book));
// Later on, change the behaviour:
doReturn(Optional.empty()).when(materials).findByIsbn("978-9999999999");

Summary:

Situation Syntax
Ordinary method on a mock when(...).thenReturn(...) — more readable
void method doThrow / doNothing
Spy (@Spy) doReturn(...).when(spy).method()
Reprogramming doReturn

Rule of thumb: use when by default; switch to do... when when does not compile or when you are working with a spy.

  1. A mock's default values

A freshly created mock responds to everything, with no programming needed:

Return type Default return value
int, long, double 0
boolean false
Object and any class null
String null
List, Set, Map Empty collection (not null)
Optional Optional.empty() (since Mockito 2)
Stream Empty Stream
void Does nothing

These defaults are very convenient. Optional returning Optional.empty() and collections returning empty avoids most NullPointerExceptions in tests where you do not care about that collaborator.

A useful practice: program only what the test really needs. If LoanManager calls five repository methods but your test only depends on two, program two. Less configuration, a more readable test and a less brittle one.

And this is where MockitoExtension's strict validation comes in:

org.mockito.exceptions.misusing.UnnecessaryStubbingException:
Unnecessary stubbings detected.
Clean & maintainable test code requires zero unnecessary code.
  1. -> at LoanManagerTest.setUp(LoanManagerTest.java:44)

That exception is not a nuisance: it is telling you that you programmed behaviour that was never used. Either the test does not do what you think, or there is dead code. It is worth investigating instead of silencing it with @MockitoSettings(strictness = Strictness.LENIENT).

  1. Verifying interactions: verify

Up to here, doubles served to control what goes in. verify serves to check what comes out: which calls the object under test made to its collaborators.

@Test
void returningLateRecordsTheFineAndNotifiesTheEmployee() {

    Loan overdue = loanOverdueBy(10);
    when(loans.findById(1L)).thenReturn(Optional.of(overdue));

    manager.returnItem(1L);

    // The updated loan was saved
    verify(loans).save(overdue);

    // The employee was notified
    verify(notices).notify(eq(overdue.getEmployee()), contains("fine"));
}

verify(mock).method(args) means: "check that method was called with those arguments exactly once".

It is essential when the effect you want to check is not in the return value. Sending a notice, publishing an event, deleting a file: those are effects, and they can only be verified this way.

  1. times, never, atLeast, only

verify(notices, times(1)).notify(any(), anyString());        // exactly 1 (the default)
verify(notices, times(3)).notify(any(), anyString());        // exactly 3
verify(notices, never()).notify(any(), anyString());         // NEVER
verify(notices, atLeastOnce()).notify(any(), anyString());   // 1 or more
verify(notices, atLeast(2)).notify(any(), anyString());      // 2 or more
verify(notices, atMost(5)).notify(any(), anyString());       // 5 or fewer
verify(notices, only()).notify(any(), anyString());          // this call AND NO OTHER

verifyNoInteractions(notices);                   // the mock was not used at all
verifyNoMoreInteractions(loans);                 // there were no calls beyond the verified ones

never() is probably the most valuable of them all, because it checks that something did not happen:

@Test
void aLoanReturnedOnTimeGeneratesNoNotice() {
    when(loans.findById(1L)).thenReturn(Optional.of(loanOnTime()));

    manager.returnItem(1L);

    verify(notices, never()).notify(any(), anyString());
}

Checking absences is impossible with assertions on the result, and it is where verify adds unique value.

  1. inOrder and verifyNoMoreInteractions

When the order matters:

@Test
void stockIsDecrementedBeforeTheLoanIsSaved() {

    InOrder order = inOrder(materials, loans, notices);

    manager.lend("978-0000000001", "[email protected]");

    order.verify(materials).findByIsbn("978-0000000001");
    order.verify(loans).save(any(Loan.class));
    order.verify(notices).notify(any(), anyString());
}

A warning: verifying order is one of the fastest ways to create a brittle test. Only do it when the order is a genuine business requirement —for example, "the notice must be sent after the save is confirmed, never before"—, not because the current code happens to do it that way.

verifyNoMoreInteractions checks that there were no additional calls:

manager.find("978-0000000001");

verify(materials).findByIsbn("978-0000000001");
verifyNoMoreInteractions(materials);   // nothing else was called

Use it sparingly: any new and legitimate call —adding a metric, a trace— breaks the test without the behaviour having changed.

  1. The criterion: verify commands, not queries

This is the most important section of the lesson, and the one that separates good tests from brittle ones.

The distinction comes from command-query separation:

Query Command
What it does Returns data, without effects Produces an effect, usually returning nothing
Examples findByIsbn, countByEmployee, calculate save, notify, delete, publish
How to test it With a stub: program what it returns With verify: check that it was called
Verify it? NO YES

Rule: stub the queries, verify the commands.

Why queries are not verified:

// WRONG: verifying a query
verify(materials).findByIsbn("978-0000000001");
verify(loans).countByEmployeeAndStatus(marta, LoanStatus.ACTIVE);

Those two lines say "the code calls these methods". But that is implementation, not behaviour. If tomorrow you optimise LoanManager to do a single combined query instead of two, the result will be identical and the test will break. That is exactly the brittle-test antipattern from 11-04.

It is also redundant: if the code had not called findByIsbn, it would not have the material and the result would be different. The assertion on the result already checks it, indirectly and without coupling.

Why commands are verified:

// RIGHT: verifying a command
verify(notices).notify(marta, "Your loan of Effective Java is due in 2 days");

Sending a notice is an observable effect the system must produce. It does not appear in any return value. The only way to check it is to verify the interaction, and it is a genuine business requirement: "when two days are left, the employee is notified".

Applied to BiblioTech:

Collaborator Method Kind What to do
MaterialRepository findByIsbn Query Stub
LoanRepository countByEmployeeAndStatus Query Stub
LoanRepository save Command Verify
NoticeService notify Command Verify
FineCalculator calculate Query Stub (or use the real one)
AuditLog write Command Verify

If you apply only this rule, your Mockito tests will be far better than average.

  1. Matchers: any, eq, argThat

When you do not want —or cannot— pin down the exact argument:

Matcher Matches
any() Anything, including null
any(Loan.class) Any non-null Loan
anyString(), anyInt(), anyLong() Any non-null value of that type
anyList(), anyMap(), anyCollection() Any collection
eq(value) That exact value (using equals)
isNull(), isNotNull() Nullity
contains("text") A String containing that text
startsWith, endsWith, matches("regex") String matches
argThat(predicate) Whatever you define
when(materials.findByIsbn(anyString())).thenReturn(Optional.of(book));

verify(notices).notify(any(Employee.class), contains("due"));

// argThat: arbitrary conditions
verify(loans).save(argThat(l ->
        l.getStatus() == LoanStatus.ACTIVE
        && l.getDueDate().equals(LocalDate.of(2026, 4, 4))));

An important tip about any(): it is convenient and it weakens the test. verify(notices).notify(any(), any()) checks that somebody was notified about something. If the bug is that the wrong employee is notified, the test will not catch it. Be as specific as you can; use any() only for what is genuinely irrelevant.

  1. The all-or-nothing rule

A mistake everyone makes the first time:

// DOES NOT WORK
verify(notices).notify(marta, anyString());
org.mockito.exceptions.misusing.InvalidUseOfMatchersException:
Invalid use of argument matchers!
2 matchers expected, 1 recorded.
This exception may occur if matchers are combined with raw values:
    //incorrect:
    someMethod(anyObject(), "raw String");
When using matchers, all arguments have to be provided by matchers.

Rule: if you use a matcher for one argument, ALL arguments must be matchers.

The fix is to wrap the literal values in eq():

// CORRECT
verify(notices).notify(eq(marta), anyString());

// Also correct: no matchers at all
verify(notices).notify(marta, "Your loan is due in 2 days");

// Also correct: all matchers
verify(notices).notify(any(Employee.class), anyString());

Why it happens: matchers are not values, they are side effects on an internal stack. When you write anyString(), Mockito pushes a matcher and returns null. When processing the invocation, it compares the number of pushed matchers with the number of method arguments. If they do not match, it cannot tell which corresponds to which, and it fails.

A practical and disconcerting consequence: the error may show up on the next line, or even in the next test, because the stack is left contaminated. If you see an InvalidUseOfMatchersException somewhere you use no matchers, look at the previous test.

  1. ArgumentCaptor

verify with matchers answers "was it called with something like this?". ArgumentCaptor answers "what exactly was it called with?".

@ExtendWith(MockitoExtension.class)
class DueDateNoticeServiceTest {

    @Mock private LoanRepository loans;
    @Mock private NoticeService notices;

    @Captor private ArgumentCaptor<String> messageCaptor;
    @Captor private ArgumentCaptor<Employee> employeeCaptor;

    @Test
    void theNoticeMessageIncludesTheTitleAndTheDaysRemaining() {

        Loan loan = loanDueOn(TODAY.plusDays(2), marta(), "Effective Java");
        when(loans.findActive()).thenReturn(List.of(loan));

        service.sendTodaysNotices();

        // Capture the REAL arguments of the call
        verify(notices).notify(employeeCaptor.capture(), messageCaptor.capture());

        assertThat(employeeCaptor.getValue().getEmail())
                .isEqualTo("[email protected]");
        assertThat(messageCaptor.getValue())
                .contains("Effective Java")
                .contains("2 days")
                .doesNotContain("null");        // the classic concatenation bug
    }
}

That doesNotContain("null") deserves a comment: it is a small check that catches one of the most frequent and most user-visible bugs — an e-mail saying "Your loan of null is due soon".

Capturing several calls:

@Test
void everyEmployeeWithALoanDueSoonIsNotified() {

    when(loans.findActive()).thenReturn(List.of(
            loanDueOn(TODAY.plusDays(2), marta(), "Effective Java"),
            loanDueOn(TODAY.plusDays(2), diego(), "Design Patterns"),
            loanDueOn(TODAY.plusDays(9), nuria(), "Refactoring")));   // not yet

    service.sendTodaysNotices();

    verify(notices, times(2)).notify(employeeCaptor.capture(), messageCaptor.capture());

    assertThat(employeeCaptor.getAllValues())
            .extracting(Employee::getEmail)
            .containsExactly("[email protected]",
                             "[email protected]");

    assertThat(messageCaptor.getAllValues())
            .allSatisfy(message -> assertThat(message).contains("2 days"));
}

Note: getValue() returns the last capture; getAllValues() returns all of them in order.

ArgumentCaptor versus argThat:

ArgumentCaptor argThat
When it is checked After verify During verify
Error message Clear: it tells you what the value was Poor: "there was no matching call"
Complex assertions Yes, with the full AssertJ API Limited to a predicate
Verbosity Higher Lower

For non-trivial checks, prefer ArgumentCaptor, precisely because of the error messages. With argThat, when it fails you only know that no call satisfied the predicate; with the captor, you see exactly what arrived.

  1. Spies with @Spy

A spy wraps a real object: by default it runs the real code, and you can replace specific methods.

@ExtendWith(MockitoExtension.class)
class FineCalculatorTest {

    @Spy private FineCalculator calculator =
            new FineCalculator(testProperties(), FIXED_CLOCK);

    @Test
    void example() {
        // REAL behaviour by default
        assertThat(calculator.calculate(loanOverdueBy(5))).isEqualByComparingTo("2.50");

        // Replace ONLY one method (note: do... is mandatory, section 10)
        doReturn(new BigDecimal("999.99")).when(calculator).calculate(any());
        assertThat(calculator.calculate(loanOverdueBy(5))).isEqualByComparingTo("999.99");
    }
}

Legitimate use cases:

  1. Legacy code that cannot be refactored and where a method has to be isolated.
  2. Verifying calls on a real object without replacing its behaviour.
  3. Replacing a single expensive method of an otherwise cheap class.

And the warning, which is a serious one:

Needing a spy on your own class almost always indicates a design problem.

If you have to replace a method of the class you are testing, that class does two things: the one you test and the one you replace. The correct solution is to extract the second one into a collaborator and inject it. Then it is an ordinary mock, and the design has improved.

Two technical traps with spies, besides the when one (section 10):

// TRAP: the spy is a COPY, not the original object
List<String> original = new ArrayList<>();
List<String> spy = spy(original);

spy.add("Effective Java");
assertThat(spy).hasSize(1);         // ok
assertThat(original).isEmpty();     // the original did NOT change!
// TRAP: internal calls do NOT go through the spy
// It is exactly the proxy problem from 11-02 §26.
// If methodA() calls this.methodB(), replacing methodB does not affect methodA.

That second one is the same internal-call trap as @Transactional. The mechanism is identical: a proxy only intercepts what goes through it.

  1. mockStatic and why it is a warning sign

Since Mockito 3.4, static methods can be mocked:

@Test
void staticExample() {
    try (MockedStatic<LocalDate> date = mockStatic(LocalDate.class)) {
        date.when(LocalDate::now).thenReturn(LocalDate.of(2026, 3, 20));

        // inside this block, LocalDate.now() returns the fixed date
        assertThat(LocalDate.now()).isEqualTo(LocalDate.of(2026, 3, 20));
    }
    // outside, normal behaviour
}

The try-with-resources (06-06) is mandatory: the mocking is per thread and must be undone, or it will contaminate the following tests.

And now the important part:

Needing mockStatic is almost always a sign that there is a design problem.

If you have to mock LocalDate.now(), it means your code calls it directly instead of using an injected Clock. The solution is not the static mock: it is to inject the Clock, exactly what you did in 10-05 and cashed in on in 11-04.

An honest comparison of the same problem:

// Option A: mockStatic. It works, and it drags problems along.
try (MockedStatic<LocalDate> d = mockStatic(LocalDate.class)) {
    d.when(LocalDate::now).thenReturn(LocalDate.of(2026, 3, 20));
    assertThat(calculator.calculate(loan)).isEqualByComparingTo("2.50");
}

// Option B: injected Clock. The design removes the need for the mock.
var calculator = new FineCalculator(properties, Clock.fixed(...));
assertThat(calculator.calculate(loan)).isEqualByComparingTo("2.50");
mockStatic Injected Clock
Change in production None One extra parameter
Test readability Worse Better
Performance Slow (it instruments the class) None
Risk of contaminating other tests Yes, if you forget to close it No
Works in parallel With care Yes
Improves the design No Yes

When mockStatic is acceptable: legacy code you cannot change, or a third-party static utility with no injectable alternative. In new code, fix the design.

  1. What must NOT be mocked

A short list that avoids a lot of pain:

Do not mock Why What to do instead
Types you do not control You are mocking your belief about how they behave, not how they behave. If you get it wrong, the test passes and the code fails Wrap them in an interface of your own and mock that
Value classes (String, LocalDate, BigDecimal, your records) They are cheap to create and their behaviour is the one you want Use real instances
The framework Mocking the EntityManager tests your idea of JPA, not JPA An integration test with H2
JDK collections A mocked List is more brittle and slower than an ArrayList Use the real one
The class under test If you have to mock part of it, it does too much Extract a collaborator
Data objects with no logic There is nothing to simulate Build them

The first one deserves elaboration, because it is the subtlest. Imagine you mock Java 11's HttpClient directly:

@Mock private HttpClient http;

when(http.send(any(), any())).thenReturn(fakeResponse);

That test passes. But it rests on your assumption that send throws IOException in a given case, that the response has that exact format, that status codes arrive in a certain way. If you are wrong about any of those assumptions, the test will be green while the code fails in production. That is the worst possible outcome: unjustified confidence.

The correct alternative: wrap the external dependency in an interface of your own that expresses what your domain needs, and mock that. That is what section 29 does with MetadataClient.

  1. The risk of over-mocking

Look at this test, which seems thorough:

@Test
void lend() {
    when(materials.findByIsbn("978-0000000001")).thenReturn(Optional.of(book));
    when(employees.findByEmail("[email protected]")).thenReturn(Optional.of(marta));
    when(loans.countByEmployeeAndStatus(marta, ACTIVE)).thenReturn(0L);
    when(properties.loan()).thenReturn(new Loan(15, 3));
    when(calculator.calculate(any())).thenReturn(BigDecimal.ZERO);
    when(loans.save(any())).thenReturn(savedLoan);

    manager.lend("978-0000000001", "[email protected]");

    verify(materials).findByIsbn("978-0000000001");
    verify(employees).findByEmail("[email protected]");
    verify(loans).countByEmployeeAndStatus(marta, ACTIVE);
    verify(loans).save(any());
    verify(notices).notify(any(), anyString());
    verifyNoMoreInteractions(materials, employees, loans, notices);
}

It is a textbook example of a brittle test. Its problems:

  1. It describes the implementation, not the behaviour. It is a transcription of the method body.
  2. It breaks when you refactor. Combining two queries into one, caching the employee, changing the order: all of it breaks the test, without the behaviour changing.
  3. It does not check the result. Not one assertion on the returned loan. It could return null and the test would pass.
  4. It mocks things it should not. properties is an immutable record and calculator is a pure function: both are cheap to use for real.
  5. It is unreadable. Twelve lines of configuration to test one case.
  6. It gives false confidence. It is green, and it guarantees almost nothing.

And the deeper problem: if the method is wrong, the test stays green. It could compute the due date wrongly, set the wrong status or assign the wrong material. None of that is checked.

The same test, done well:

@Test
void lendingCreatesAnActiveLoanDueInFifteenDays() {

    when(materials.findByIsbn("978-0000000001")).thenReturn(Optional.of(book));
    when(employees.findByEmail("[email protected]")).thenReturn(Optional.of(marta));
    when(loans.save(any(Loan.class))).thenAnswer(inv -> inv.getArgument(0));

    Loan loan = manager.lend("978-0000000001", "[email protected]");

    // BEHAVIOUR: what was produced
    assertThat(loan)
            .satisfies(l -> {
                assertThat(l.getMaterial()).isEqualTo(book);
                assertThat(l.getEmployee()).isEqualTo(marta);
                assertThat(l.getStatus()).isEqualTo(LoanStatus.ACTIVE);
                assertThat(l.getDueDate()).isEqualTo(LocalDate.of(2026, 4, 4));
            });

    // Observable EFFECTS (commands)
    verify(loans).save(loan);
    verify(notices).notify(eq(marta), contains("Effective Java"));
}

Shorter, more readable, it checks what matters and it survives any refactoring that does not change the behaviour.

  1. State-based versus interaction-based tests

Two philosophies, and it is worth being clear about them:

State-based Interaction-based
What it checks The result and the final state The calls to the collaborators
Tool Assertions (AssertJ) Mockito's verify
Coupling to the implementation Low High
Resistance to refactoring High Low
Error messages Clear: "expected X, was Y" Worse: "this call did not happen"
When it is the only option Effects with no return value

Recommendation: prefer state-based tests. Use interaction-based ones only when the effect is not observable any other way.

Cases where interaction is the only option:

  • Sending an e-mail or a notification.
  • Publishing an event or a message on a queue.
  • Recording to an external audit system.
  • Checking that something did not happen (never()).
  • Checking that something was called exactly once (idempotency, avoiding duplicates).

  1. The same case, solved both ways

A BiblioTech requirement: "When a loan is returned late, the corresponding fine is recorded."

Interaction-based version

@Test
void returningLateRecordsTheFine_interaction() {

    Loan overdue = loanOverdueBy(10);
    when(loans.findById(1L)).thenReturn(Optional.of(overdue));
    when(calculator.calculate(overdue)).thenReturn(new BigDecimal("5.00"));

    manager.returnItem(1L);

    verify(calculator).calculate(overdue);
    verify(fines).register(eq(overdue), eq(new BigDecimal("5.00")));
}

Problems: it depends on FineCalculator being used and on the recording happening with those two arguments. If tomorrow the calculation moves into the Loan entity —a very reasonable refactoring—, the behaviour is identical and the test breaks. And it does not check whether the recorded fine is correct: it checks that the value the mock itself returned was passed along, which is circular.

State-based version

@Test
void returningLateRecordsTheFine_state() {

    // REAL collaborators for whatever is cheap and deterministic
    var calculator = new FineCalculator(testProperties(), FIXED_CLOCK);
    var inMemoryFines = new InMemoryFineRegistry();                // fake
    var manager = new LoanManager(loans, calculator, inMemoryFines, notices, FIXED_CLOCK);

    Loan overdue = loanOverdueBy(10);
    when(loans.findById(1L)).thenReturn(Optional.of(overdue));

    manager.returnItem(1L);

    // The resulting STATE is what gets checked
    assertThat(inMemoryFines.forEmployee(overdue.getEmployee()))
            .singleElement()
            .satisfies(fine -> {
                assertThat(fine.amount()).isEqualByComparingTo("5.00");
                assertThat(fine.loan()).isEqualTo(overdue);
            });

    assertThat(overdue.getStatus()).isEqualTo(LoanStatus.RETURNED);
}

Advantages: it checks that the fine really is €5.00, computed by the real code. It survives the calculation moving elsewhere. And it also checks the loan's status, which the other one ignored.

The recommendation

Situation Approach
There is a return value State: assertions on it
There is an observable final state State: with a fake if necessary
The collaborator is cheap and deterministic (calculator, record, Clock) Use it for real, do not mock it
The effect is external (e-mail, event, queue) Interaction: verify
You have to check that something did not happen Interaction: never()
The collaborator is slow, non-deterministic or failing Mock it to keep control

In practice, a good test mixes both: mock what gets in the way, use the real thing for what is cheap, check the state and verify only the external effects.

  1. When the design removes the need for a mock

The most valuable lesson in the whole testing module, and you already know it.

The clock case:

// Without Clock: a static method has to be mocked
try (MockedStatic<LocalDate> d = mockStatic(LocalDate.class)) {
    d.when(LocalDate::now).thenReturn(LocalDate.of(2026, 3, 20));
    // ... a slow, brittle test with a risk of contamination
}

// With an injected Clock: NO mock is needed at all
var calculator = new FineCalculator(properties, Clock.fixed(...));

The design removed the need for the double. It is not a testing trick: it is that an injected Clock is better design, and testability is a consequence.

This pattern repeats itself:

Problem Solution with a mock Design solution
Time mockStatic(LocalDate.class) Injected Clock
Randomness mockStatic(Math.class) Injected Random with a seed
Identifiers mockStatic(UUID.class) An injected IdGenerator
Configuration Mocking BusinessRules (impossible: it is static) Injected @ConfigurationProperties (11-02)
A calculation inside a service Mocking the service itself with @Spy Extracting it into a pure function
The database Mocking the repository A reusable in-memory fake

The general heuristic:

Before writing a mock, ask yourself whether the problem is that the design needs to change. If a collaborator is hard to simulate, it is often because it should not be a collaborator.

Mockito is an excellent tool. And like every powerful tool, it can be used to work around a problem instead of solving it.

  1. Combining Mockito with AssertJ

They complement each other perfectly: Mockito controls and captures, AssertJ checks.

@Test
void theNoticeContainsAllTheLoanDetails() {

    when(loans.findActive()).thenReturn(List.of(martasLoan(), diegosLoan()));

    service.sendTodaysNotices();

    ArgumentCaptor<Notice> captor = ArgumentCaptor.forClass(Notice.class);
    verify(notices, times(2)).send(captor.capture());

    // AssertJ on what was captured: expressive and with good error messages
    assertThat(captor.getAllValues())
            .hasSize(2)
            .extracting(Notice::recipient, Notice::subject)
            .containsExactly(
                    tuple("[email protected]",  "Your loan is due in 2 days"),
                    tuple("[email protected]","Your loan is due in 2 days"));

    assertThat(captor.getAllValues())
            .allSatisfy(notice -> {
                assertThat(notice.body()).doesNotContain("null");
                assertThat(notice.recipient()).endsWith("@nexussoftware.com");
            });
}

AssertJ also offers assertThatThrownBy combined with thenThrow:

when(loans.save(any())).thenThrow(new DataAccessResourceFailureException("No connection"));

assertThatThrownBy(() -> manager.lend("978-0000000001", "[email protected]"))
        .isInstanceOf(BiblioTechException.class)                  // the exception is translated
        .hasCauseInstanceOf(DataAccessResourceFailureException.class)
        .hasMessageContaining("978-0000000001");

That test verifies module 6's layered error-handling strategy: the technical exception is wrapped in a domain one, preserving the cause.

  1. BiblioTech: LoanManager with mocked repositories

The complete test, applying all the judgement from this lesson:

package com.nexussoftware.bibliotech.service;

import static org.assertj.core.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;

import java.math.BigDecimal;
import java.time.*;
import java.util.*;
import org.junit.jupiter.api.*;
import org.mockito.*;
import org.mockito.junit.jupiter.MockitoExtension;

@ExtendWith(MockitoExtension.class)
@DisplayName("LoanManager")
class LoanManagerTest {

    private static final ZoneId MADRID = ZoneId.of("Europe/Madrid");
    private static final LocalDate TODAY = LocalDate.of(2026, 3, 20);
    private static final Clock CLOCK = Clock.fixed(TODAY.atStartOfDay(MADRID).toInstant(), MADRID);

    @Mock private MaterialRepository materials;
    @Mock private EmployeeRepository employees;
    @Mock private LoanRepository loans;
    @Mock private NoticeService notices;

    private LoanManager manager;
    private Material book;
    private Employee marta;

    @BeforeEach
    void setUp() {
        // REAL properties and calculator: cheap, deterministic and effect-free
        var properties = new BiblioTechProperties(
                "BiblioTech",
                new BiblioTechProperties.Loan(15, 3),
                new BiblioTechProperties.Fine(new BigDecimal("0.50"), new BigDecimal("20.00")),
                new BiblioTechProperties.Reservation(3));
        var calculator = new FineCalculator(properties, CLOCK);

        manager = new LoanManager(materials, employees, loans, calculator,
                                  notices, properties, CLOCK);

        book = new Book("978-0000000001", "Effective Java", 3, "J. Bloch");
        marta = new Employee("[email protected]", "Marta Ruiz");
    }

    @Nested
    @DisplayName("when lending")
    class Lend {

        @Test
        @DisplayName("creates an active loan due in 15 days")
        void createsTheRightLoan() {
            when(materials.findByIsbn("978-0000000001")).thenReturn(Optional.of(book));
            when(employees.findByEmail(marta.getEmail())).thenReturn(Optional.of(marta));
            when(loans.save(any(Loan.class))).thenAnswer(inv -> inv.getArgument(0));

            Loan loan = manager.lend("978-0000000001", marta.getEmail());

            assertThat(loan.getMaterial()).isEqualTo(book);
            assertThat(loan.getEmployee()).isEqualTo(marta);
            assertThat(loan.getStatus()).isEqualTo(LoanStatus.ACTIVE);
            assertThat(loan.getLoanDate()).isEqualTo(TODAY);
            assertThat(loan.getDueDate()).isEqualTo(TODAY.plusDays(15));
        }

        @Test
        @DisplayName("decrements the material's available copies")
        void decrementsCopies() {
            when(materials.findByIsbn("978-0000000001")).thenReturn(Optional.of(book));
            when(employees.findByEmail(marta.getEmail())).thenReturn(Optional.of(marta));
            when(loans.save(any(Loan.class))).thenAnswer(inv -> inv.getArgument(0));

            manager.lend("978-0000000001", marta.getEmail());

            assertThat(book.getAvailableCopies()).isEqualTo(2);          // real state
        }

        @Test
        @DisplayName("fails if the material does not exist, and saves nothing")
        void materialNotFound() {
            when(materials.findByIsbn("978-9999999999")).thenReturn(Optional.empty());

            assertThatThrownBy(() -> manager.lend("978-9999999999", marta.getEmail()))
                    .isInstanceOf(MaterialNotFoundException.class)
                    .hasMessageContaining("978-9999999999");

            verify(loans, never()).save(any());
            verifyNoInteractions(notices);
        }

        @Test
        @DisplayName("fails if the material has no available copies")
        void noCopiesLeft() {
            Material soldOut = new Book("978-0000000003", "Refactoring", 0, "M. Fowler");
            when(materials.findByIsbn("978-0000000003")).thenReturn(Optional.of(soldOut));
            when(employees.findByEmail(marta.getEmail())).thenReturn(Optional.of(marta));

            assertThatThrownBy(() -> manager.lend("978-0000000003", marta.getEmail()))
                    .isInstanceOf(NoCopiesAvailableException.class);

            verify(loans, never()).save(any());
        }

        @Test
        @DisplayName("fails if the employee has reached the limit of 3 loans")
        void limitReached() {
            when(materials.findByIsbn("978-0000000001")).thenReturn(Optional.of(book));
            when(employees.findByEmail(marta.getEmail())).thenReturn(Optional.of(marta));
            when(loans.countByEmployeeAndStatus(marta, LoanStatus.ACTIVE))
                    .thenReturn(3L);

            assertThatThrownBy(() -> manager.lend("978-0000000001", marta.getEmail()))
                    .isInstanceOf(LoanLimitExceededException.class)
                    .hasMessageContaining(marta.getEmail());

            verify(loans, never()).save(any());
            assertThat(book.getAvailableCopies()).isEqualTo(3);          // no effects
        }
    }

    @Nested
    @DisplayName("faced with infrastructure failures")
    class Failures {

        @Test
        @DisplayName("if the save fails, nobody is notified")
        void theSaveFails() {
            when(materials.findByIsbn("978-0000000001")).thenReturn(Optional.of(book));
            when(employees.findByEmail(marta.getEmail())).thenReturn(Optional.of(marta));
            when(loans.save(any()))
                    .thenThrow(new DataAccessResourceFailureException("Connection lost"));

            assertThatThrownBy(() -> manager.lend("978-0000000001", marta.getEmail()))
                    .isInstanceOf(DataAccessResourceFailureException.class);

            verifyNoInteractions(notices);   // requirement: unpersisted things are not notified
        }

        @Test
        @DisplayName("retries after a concurrency conflict and ends up succeeding")
        void retriesAfterAConflict() {
            when(materials.findByIsbn("978-0000000001")).thenReturn(Optional.of(book));
            when(employees.findByEmail(marta.getEmail())).thenReturn(Optional.of(marta));
            when(loans.save(any()))
                    .thenThrow(new OptimisticLockingFailureException("stale version"))
                    .thenAnswer(inv -> inv.getArgument(0));   // second time, success

            Result<Loan> result =
                    manager.lendWithRetry("978-0000000001", marta.getEmail());

            assertThat(result.isSuccess()).isTrue();
            verify(loans, times(2)).save(any());   // it was attempted twice
        }
    }
}

Decisions worth underlining:

  • FineCalculator and BiblioTechProperties are real. They are cheap, deterministic and effect-free: mocking them would only add noise and weaken the test.
  • findByIsbn and findByEmail are never verified. They are queries. The fact that they were called is already implicit in the result being correct.
  • save and notify are verified. They are commands with an effect.
  • never() and verifyNoInteractions in the failure cases. Checking absences is where verify adds unique value.
  • The retry test is only possible with thenThrow().thenAnswer(). It is the optimistic-locking scenario from 11-03, impossible to provoke any other way.

  1. BiblioTech: NoticeService verified with ArgumentCaptor

@ExtendWith(MockitoExtension.class)
@DisplayName("Due-date notices")
class DueDateNoticeServiceTest {

    private static final ZoneId MADRID = ZoneId.of("Europe/Madrid");
    private static final LocalDate TODAY = LocalDate.of(2026, 3, 20);
    private static final Clock CLOCK = Clock.fixed(TODAY.atStartOfDay(MADRID).toInstant(), MADRID);

    @Mock private LoanRepository loans;
    @Mock private NoticeService notices;

    @Captor private ArgumentCaptor<Employee> employeeCaptor;
    @Captor private ArgumentCaptor<String> messageCaptor;

    private DueDateNoticeService service;

    @BeforeEach
    void setUp() {
        service = new DueDateNoticeService(loans, notices, propertiesWithDaysAhead(2), CLOCK);
    }

    @Test
    @DisplayName("the message includes the material's title and the days remaining")
    void messageContent() {
        when(loans.findActive()).thenReturn(List.of(
                loan("Effective Java", marta(), TODAY.plusDays(2))));

        service.sendTodaysNotices();

        verify(notices).notify(employeeCaptor.capture(), messageCaptor.capture());

        assertThat(employeeCaptor.getValue().getEmail())
                .isEqualTo("[email protected]");

        assertThat(messageCaptor.getValue())
                .contains("Effective Java")
                .contains("2 days")
                .doesNotContain("null")          // classic concatenation bug
                .doesNotContain("@{");           // unsubstituted template
    }

    @Test
    @DisplayName("notifies only the employees whose loan is due in exactly 2 days")
    void noticeWindow() {
        when(loans.findActive()).thenReturn(List.of(
                loan("Effective Java",  marta(), TODAY.plusDays(2)),   // YES
                loan("Design Patterns", diego(), TODAY.plusDays(2)),   // YES
                loan("Refactoring",     nuria(), TODAY.plusDays(1)),   // no
                loan("Effective Java",  nuria(), TODAY.plusDays(3)),   // no
                loan("Refactoring",     diego(), TODAY.minusDays(5))));// no

        service.sendTodaysNotices();

        verify(notices, times(2)).notify(employeeCaptor.capture(), anyString());

        assertThat(employeeCaptor.getAllValues())
                .extracting(Employee::getEmail)
                .containsExactly("[email protected]",
                                 "[email protected]");
    }

    @Test
    @DisplayName("a broken channel does not stop the remaining notices")
    void oneFailureDoesNotStopTheRest() {
        when(loans.findActive()).thenReturn(List.of(
                loan("Effective Java",  marta(), TODAY.plusDays(2)),
                loan("Design Patterns", diego(), TODAY.plusDays(2)),
                loan("Refactoring",     nuria(), TODAY.plusDays(2))));

        // The second notice fails
        doNothing()
                .doThrow(new NoticeServiceException("SMTP is not responding"))
                .doNothing()
                .when(notices).notify(any(), anyString());

        int sent = service.sendTodaysNotices();

        assertThat(sent).isEqualTo(2);                            // 3 attempts, 2 successful
        verify(notices, times(3)).notify(any(), anyString());     // all 3 were attempted
    }
}

That last test is an excellent example of what only Mockito allows: chaining doNothing().doThrow().doNothing() to simulate the second of three calls failing. It verifies module 6's resilience strategy —a partial failure must not abort the whole process— and provoking it any other way would be unfeasible.

  1. BiblioTech: MetadataClient tested without the network

The MetadataClient from 09-06 calls an external API with HttpClient. Testing it as is means depending on the network.

First, the right design (remember section 21: do not mock HttpClient directly):

package com.nexussoftware.bibliotech.network;

// OUR OWN interface, expressing what the domain needs
public interface MetadataGateway {
    Optional<BookMetadata> findByIsbn(String isbn);
}

// The real implementation, which does use HttpClient
@Component
public class HttpMetadataGateway implements MetadataGateway {

    private final HttpClient http;
    private final String baseUrl;

    public HttpMetadataGateway(HttpClient http,
                               @Value("${bibliotech.metadata.url}") String baseUrl) {
        this.http = http;
        this.baseUrl = baseUrl;
    }

    @Override
    public Optional<BookMetadata> findByIsbn(String isbn) { /* real HTTP */ }
}

And the service that uses it:

@Service
public class CatalogEnricher {

    private final MetadataGateway gateway;        // the INTERFACE, not HttpClient
    private final MaterialRepository materials;

    public CatalogEnricher(MetadataGateway gateway, MaterialRepository materials) {
        this.gateway = gateway;
        this.materials = materials;
    }

    public int enrichCatalog() {
        int enriched = 0;
        for (Material material : materials.findByAuthorIsNull()) {
            try {
                Optional<BookMetadata> metadata = gateway.findByIsbn(material.getIsbn());
                if (metadata.isPresent()) {
                    material.completeWith(metadata.get());
                    materials.save(material);
                    enriched++;
                }
            } catch (GatewayUnavailableException e) {
                log.warn("Could not enrich {}: {}", material.getIsbn(), e.getMessage());
                // carries on with the next one
            }
        }
        return enriched;
    }
}

Now the test, without a single network connection:

@ExtendWith(MockitoExtension.class)
@DisplayName("Catalogue enrichment")
class CatalogEnricherTest {

    @Mock private MetadataGateway gateway;
    @Mock private MaterialRepository materials;

    private CatalogEnricher enricher;

    @BeforeEach
    void setUp() { enricher = new CatalogEnricher(gateway, materials); }

    @Test
    @DisplayName("fills in the author of the materials that have it empty")
    void fillsInTheData() {
        Material withoutAuthor = new Book("978-0000000001", "Effective Java", 3, null);
        when(materials.findByAuthorIsNull()).thenReturn(List.of(withoutAuthor));
        when(gateway.findByIsbn("978-0000000001"))
                .thenReturn(Optional.of(new BookMetadata("Joshua Bloch", 2018, "Addison-Wesley")));

        int enriched = enricher.enrichCatalog();

        assertThat(enriched).isEqualTo(1);
        assertThat(withoutAuthor.getAuthor()).isEqualTo("Joshua Bloch");
        verify(materials).save(withoutAuthor);
    }

    @Test
    @DisplayName("saves nothing if the API does not know the ISBN")
    void unknownIsbn() {
        Material withoutAuthor = new Book("978-0000000099", "Odd title", 1, null);
        when(materials.findByAuthorIsNull()).thenReturn(List.of(withoutAuthor));
        when(gateway.findByIsbn(anyString())).thenReturn(Optional.empty());

        assertThat(enricher.enrichCatalog()).isZero();
        verify(materials, never()).save(any());
    }

    @Test
    @DisplayName("if the API is down, it carries on with the rest of the catalogue")
    void apiPartiallyDown() {
        Material one = new Book("978-0000000001", "Effective Java", 3, null);
        Material two = new Book("978-0000000002", "Design Patterns", 2, null);
        when(materials.findByAuthorIsNull()).thenReturn(List.of(one, two));

        when(gateway.findByIsbn("978-0000000001"))
                .thenThrow(new GatewayUnavailableException("504 Gateway Timeout"));
        when(gateway.findByIsbn("978-0000000002"))
                .thenReturn(Optional.of(new BookMetadata("GoF", 1994, "Addison-Wesley")));

        assertThat(enricher.enrichCatalog()).isEqualTo(1);
        assertThat(two.getAuthor()).isEqualTo("GoF");
        verify(materials, times(1)).save(two);
    }

    @Test
    @DisplayName("does not call the API if there are no incomplete materials")
    void completeCatalogue() {
        when(materials.findByAuthorIsNull()).thenReturn(List.of());

        assertThat(enricher.enrichCatalog()).isZero();
        verifyNoInteractions(gateway);       // not a single needless request
    }
}

Four tests, milliseconds, no network, and covering scenarios —the API down, an unknown ISBN, a partial failure— that with the real API would be unpredictable or impossible.

And to test HttpMetadataGateway itself, which is the class that speaks HTTP, the right tool is not Mockito but WireMock: a fake HTTP server that answers whatever you program. That is an integration test and it belongs in 12-05.

  1. Integration tests: @SpringBootTest and @MockitoBean

Unit tests do not cover everything. What is missing is checking that the pieces fit together: that Spring injects the right things, that the configuration is read, that transactions work.

@SpringBootTest
@ActiveProfiles("test")
class LoanManagerIT {              // IT suffix: failsafe runs it (11-05)

    @Autowired private LoanManager manager;
    @Autowired private MaterialRepository materials;

    // Replaces the real bean in the Spring context with a mock
    @MockitoBean private NoticeService notices;

    @Test
    void lendingPersistsAndNotifies() {
        materials.save(new Book("978-0000000001", "Effective Java", 3, "J. Bloch"));

        Loan loan = manager.lend("978-0000000001", "[email protected]");

        assertThat(loan.getId()).isNotNull();          // it really was persisted
        verify(notices).notify(any(), contains("Effective Java"));  // no real e-mail was sent
    }
}

About the annotation's name: @MockitoBean is the one from Spring Boot 3.4 onwards; in earlier versions it was @MockBean, now deprecated. They do the same thing: replace a bean in the context with a mock.

The key difference from @Mock:

@Mock @MockitoBean
Scope One object in the test One bean in the Spring context
Needs a context No Yes
Speed Milliseconds Seconds
Use Unit tests Integration tests

A little-known performance warning: each different combination of @MockitoBean creates a new Spring context. Spring caches contexts across test classes, but if every class mocks different beans, many contexts get started and the suite becomes extremely slow. It is one of the most frequent causes of "our tests take fifteen minutes".

  1. @DataJpaTest with H2

To test the persistence layer without starting the whole application:

@DataJpaTest                       // JPA only: repositories, EntityManager, embedded DB
@Tag("integration")
class LoanRepositoryIT {

    @Autowired private LoanRepository repository;
    @Autowired private TestEntityManager em;

    @Test
    @DisplayName("finds the overdue, unreturned loans with their relations loaded")
    void findsOverdueOnes() {
        Material book = em.persist(new Book("978-0000000001", "Effective Java", 3, "J. Bloch"));
        Employee marta = em.persist(new Employee("[email protected]", "Marta Ruiz"));

        em.persist(new Loan(book, marta, LocalDate.of(2026, 3, 1), LocalDate.of(2026, 3, 10)));
        em.persist(new Loan(book, marta, LocalDate.of(2026, 3, 1), LocalDate.of(2026, 4, 10)));
        em.flush();
        em.clear();                // empties the context: forces a real read from the DB

        List<Loan> overdue = repository.overdue(LocalDate.of(2026, 3, 20));

        assertThat(overdue).hasSize(1);
        // The JOIN FETCH from 11-03 works: no LazyInitializationException
        assertThat(overdue.get(0).getMaterial().getTitle()).isEqualTo("Effective Java");
    }
}

That em.clear() is a detail many people forget and it makes all the difference: without it, the entities stay in the first-level persistence context (11-03) and the query returns them from the cache without touching the database. The test would pass even if the mapping were wrong.

And this test verifies something a unit test cannot: that the JPQL is syntactically valid, that the attribute names exist, that the JOIN FETCH avoids the N+1 and that the mapping generates the right schema. All of that is only checked against a real database.

Features of @DataJpaTest:

Feature Behaviour
Context Only the JPA layer (repositories, entities, DataSource)
Database Embedded (H2) by default
Transaction One per test, automatically rolled back at the end
TestEntityManager A simplified version of the EntityManager for preparing data
Services and controllers Not loaded

The automatic rollback is what makes the tests independent: each one starts from a clean database without you having to clean it.

  1. Testcontainers, introduced

H2 is convenient for learning and for the fast cycle, but it is not the production database. Its differences from PostgreSQL or MySQL are real: different SQL dialects, sequence behaviour, types, functions, default isolation levels. A native query that works on H2 may fail on PostgreSQL.

Testcontainers is today's standard answer: it starts the real database in a Docker container, during the test.

@SpringBootTest
@Testcontainers
class LoanRepositoryPostgresIT {

    @Container
    @ServiceConnection    // Spring Boot 3.1+: configures the DataSource on its own
    static PostgreSQLContainer<?> postgres =
            new PostgreSQLContainer<>("postgres:16-alpine");

    @Autowired private LoanRepository repository;

    @Test
    void worksAgainstRealPostgres() {
        // Runs against real PostgreSQL 16, started and destroyed by the test
    }
}
In-memory H2 Testcontainers
Speed Milliseconds Seconds (the start-up is shared)
Fidelity Approximate Identical to production
Requirements None Docker
When Fast cycle, learning Continuous integration, native queries

It also works for message queues, Redis, Elasticsearch or any service with a Docker image. It is covered in 12-05.

  1. Coverage: an honest reading

Coverage measures what percentage of the code the tests execute. With Mockito it is easy to inflate it without testing anything:

@Test
void highCoverage() {
    when(materials.findByIsbn(anyString())).thenReturn(Optional.of(book));
    when(employees.findByEmail(anyString())).thenReturn(Optional.of(marta));
    when(loans.save(any())).thenReturn(loan);

    manager.lend("978-0000000001", "[email protected]");
    // Not one assertion. Coverage of the method: 100%. Value: zero.
}

An executed line is not a tested line.

What coverage does tell you:

  • 0% on a business method is valuable information: nobody has tested it.
  • An uncovered branch points to a path that is never exercised, often the error path.

What it does not tell you: anything about whether the assertions check the right thing, whether the edge cases are covered or whether the behaviour is the expected one.

And there is a well-known perverse effect: a high coverage target produces bad tests. If the team has to reach 90%, tests of getters and setters will appear that push the number up without contributing anything, and nobody will write the hard edge-case test because it costs more.

It is covered in 12-05, with JaCoCo configured and sensible criteria.

  1. Common Mistakes and Tips

Mistake: mixing matchers and literal values. verify(notices).notify(marta, anyString()) throws InvalidUseOfMatchersException. Either all matchers or none: eq(marta).

Mistake: using when with a void method or with a spy. It does not compile with void, and with a spy it runs the real method. Use doThrow / doReturn.

Mistake: verifying queries. verify(materials).findByIsbn(...) tests implementation and produces brittle tests. Verify commands.

Mistake: mocking types you do not control. You are mocking your belief about how they behave. Wrap them in an interface of your own.

Mistake: mocking value classes. String, LocalDate, BigDecimal and your records get constructed: they are cheap and their behaviour is the one you want.

Mistake: using verifyNoMoreInteractions as a matter of course. Any new and legitimate call breaks the test.

Mistake: @InjectMocks with non-mockable dependencies. It injects null without warning and the failure shows up later as a NullPointerException. Build explicitly.

Mistake: not closing mockStatic. It contaminates the following tests. Always try-with-resources — and before that, ask yourself whether the design should change.

Mistake: tests with no assertions at all. With Mockito it is easy to write a test that only configures and executes. High coverage, zero value.

Mistake: silencing UnnecessaryStubbingException. It is pointing at programmed behaviour nobody uses: either the test does not do what you think, or there is dead code.

Mistake: using @SpringBootTest with many different @MockitoBeans. Every combination creates a new context and the suite becomes extremely slow.

Tip: apply the command/query rule. It is the only thing you need to remember to write Mockito tests far better than average.

Tip: use real objects for whatever is cheap. A configuration record, a pure calculator or a Clock.fixed are not mocked: they are used. The test is stronger and more readable.

Tip: consider an in-memory fake for the main repository. It is written once, it gives state coherence for free and it does not break when you refactor.

Tip: ArgumentCaptor for non-trivial checks. Its error messages are far better than argThat's.

Tip: make the most of thenThrow for the error paths. It is Mockito's most valuable capability and the one that covers the code that in production runs on the worst day.

Tip: before writing a complicated mock, ask yourself whether the design should change. The Clock from 10-05 removed the need for a static mock. That is the best possible solution.

Tip: use the smallest test annotation that does the job. Without Spring if possible; @DataJpaTest if you need the database; @SpringBootTest only when the full context is genuinely required.

  1. Exercises

Exercise 1: choosing the right double

For each BiblioTech scenario, state what kind of double you would use (dummy, stub, spy, mock, fake, or none) and why. Also write the code fragment for the relevant part.

  1. Testing that FineCalculator caps the fine at €20 when the delay is 200 days.
  2. Testing that lending the last copy sends a notice to the librarian in charge.
  3. Testing that LoanManager saves nothing if the employee has reached the limit.
  4. Testing BiblioTechStatistics, which makes eight different repository queries and aggregates the results with streams.
  5. Testing that CatalogEnricher carries on processing when the external API returns a 503 error.
  6. Testing that OperationLog writes an audit entry for every loan.
  7. Testing that ReservationProcessor expires reservations older than 3 days.

Exercise 2: fixing a brittle test

This test is in the Nexus Software project. It passes, but it is bad. Identify at least six problems, explain why each one is a problem, and rewrite it.

@ExtendWith(MockitoExtension.class)
class ReservationProcessorTest {

    @Mock private ReservationRepository reservations;
    @Mock private LoanManager manager;
    @Mock private BiblioTechProperties properties;
    @Mock private BiblioTechProperties.Reservation reservationProperties;
    @Mock private Clock clock;
    @Mock private Reservation reservation;
    @Mock private Material material;

    @InjectMocks private ReservationProcessor processor;

    @Test
    void test() {
        when(properties.reservation()).thenReturn(reservationProperties);
        when(reservationProperties.expiryDays()).thenReturn(3);
        when(clock.instant()).thenReturn(Instant.parse("2026-03-20T10:00:00Z"));
        when(clock.getZone()).thenReturn(ZoneId.of("Europe/Madrid"));
        when(reservations.pending()).thenReturn(List.of(reservation));
        when(reservation.getRequestDate()).thenReturn(LocalDate.of(2026, 3, 10));
        when(reservation.getMaterial()).thenReturn(material);
        when(material.getIsbn()).thenReturn("978-0000000001");
        when(manager.isAvailable(anyString())).thenReturn(false);

        processor.processPending();

        verify(properties).reservation();
        verify(reservationProperties).expiryDays();
        verify(reservations).pending();
        verify(reservation).getRequestDate();
        verify(reservations).expire(reservation);
        verifyNoMoreInteractions(reservations, manager, properties);
    }
}

Exercise 3: testing the complete return flow

Implement the tests for this method, the most complex one in BiblioTech:

@Service
public class ReturnManager {

    private final LoanRepository loans;
    private final FineCalculator calculator;
    private final FineRegistry fines;
    private final ReservationProcessor reservations;
    private final NoticeService notices;
    private final Clock clock;

    @Transactional
    public ReturnResult returnItem(Long loanId) {

        Loan loan = loans.findById(loanId)
                .orElseThrow(() -> new LoanNotFoundException(loanId));

        if (loan.getReturnDate().isPresent()) {
            throw new LoanAlreadyReturnedException(loanId);
        }

        LocalDate today = LocalDate.now(clock);
        BigDecimal fine = calculator.calculate(loan);

        loan.returnItem(today);
        loan.getMaterial().returnOneCopy();
        loans.save(loan);

        if (fine.compareTo(BigDecimal.ZERO) > 0) {
            fines.register(loan, fine);
            notices.notify(loan.getEmployee(),
                    "A fine of " + fine + " EUR has been recorded for the late return of "
                    + loan.getMaterial().getTitle());
        }

        Optional<Reservation> next = reservations.nextFor(loan.getMaterial());
        next.ifPresent(r -> {
            r.markNotified(clock);
            notices.notify(r.getEmployee(),
                    "The material you reserved is now available: "
                    + loan.getMaterial().getTitle());
        });

        return new ReturnResult(loan, fine, next.isPresent());
    }
}

Write a test class that covers, applying the lesson's criterion:

  1. On-time return: no fine, no fine notice.
  2. Return 10 days late: a €5.00 fine recorded and notified, with the message content verified using ArgumentCaptor.
  3. Return of a material with a pending reservation: the next employee is also notified (two notices in total, checked for order and content).
  4. Non-existent loan: exception and no effects.
  5. Already-returned loan: exception and no effects.
  6. The material recovers an available copy.

Justify in each case what you mock, what you use for real and what you verify.

Solutions

Solution 1

# Scenario Double Why
1 The fine cap None FineCalculator only needs BiblioTechProperties (a record) and a Clock.fixed. Both real. It is the ideal case: the design made the double unnecessary
2 Notice to the librarian Mock of NoticeService Sending a notice is a command with an external effect. The only way to check it is verify
3 Loan limit Stub of the repository + mock to verify the absence countByEmployeeAndStatus is a query (stub); verify(loans, never()).save(any()) verifies that there was no command
4 BiblioTechStatistics In-memory fake Eight different queries would be eight whens per test, and the results must be coherent with each other (a loan appearing in one query must appear in another). A fake gives that coherence for free
5 API returning 503 Mock with thenThrow It is Mockito's exclusive capability: provoking a failure impossible to reproduce any other way
6 Audit per loan Mock + ArgumentCaptor write is a command; and you have to check the content of the entry, not just that it was called
7 Expiring reservations Fake or stub + Clock.fixed The key is the fixed clock (not a mock of Clock, but Clock.fixed); the repository can be a fake that also lets you check the final state

Relevant fragments:

// 1. No doubles: everything real
var calculator = new FineCalculator(testProperties(), Clock.fixed(...));
assertThat(calculator.calculate(loanOverdueBy(200))).isEqualByComparingTo("20.00");
// 2. A mock to verify the command
verify(notices).notify(eq(librarianInCharge), contains("last copy"));
// 3. A stub for the query, verify(never) for the absence of a command
when(loans.countByEmployeeAndStatus(marta, ACTIVE)).thenReturn(3L);
assertThatThrownBy(() -> manager.lend(...)).isInstanceOf(LoanLimitExceededException.class);
verify(loans, never()).save(any());
// 4. A fake: coherence between queries, for free
var repository = new InMemoryLoanRepository();
repository.save(loanOf(marta, "978-0000000001"));
repository.save(loanOf(marta, "978-0000000002"));
repository.save(loanOf(diego, "978-0000000001"));

var statistics = new BiblioTechStatistics(repository);
assertThat(statistics.mostLentMaterial()).contains("978-0000000001");
assertThat(statistics.loansPerEmployee()).containsEntry("Marta Ruiz", 2L);
// 5. thenThrow: the scenario that is impossible to reproduce
when(gateway.findByIsbn("978-0000000001"))
        .thenThrow(new GatewayUnavailableException("503 Service Unavailable"));
// 6. A captor for the audit content
verify(auditLog).write(entryCaptor.capture());
assertThat(entryCaptor.getValue())
        .extracting(AuditEntry::operation, AuditEntry::isbn)
        .containsExactly("LOAN", "978-0000000001");
// 7. A REAL fixed clock, not a mock of Clock
var processor = new ReservationProcessor(repository, manager, properties,
                                         Clock.fixed(Instant.parse("2026-03-20T10:00:00Z"), MADRID));

Solution 2

The problems:

# Problem Why it is bad
1 @Mock on Clock Clock is a value class with Clock.fixed. Mocking it forces you to program instant() and getZone(), it is more brittle and less readable. And if the code calls another clock method, it returns null
2 @Mock on BiblioTechProperties and its nested record They are immutable records: they are built with new in one line. Mocking them produces four useless lines of configuration
3 @Mock on Reservation and Material They are domain entities. Mocking them means the test does not exercise their real logic (validations, state transitions) and produces the "mock of a mock of a mock"
4 Verifying queries (properties.reservation(), expiryDays(), pending(), getRequestDate()) It is pure implementation. Any refactoring —caching the configuration value, for instance— breaks the test without the behaviour changing
5 verifyNoMoreInteractions on three mocks Adding a trace, a metric or an extra check breaks the test
6 The name test It says nothing. When it fails in continuous integration, nobody knows what broke
7 No assertion on the result or the state It only verifies calls. If expire received the wrong reservation... well, that would show up; but if the reservation's status did not change, the test would still be green
8 @InjectMocks With so many mocks it works, but if tomorrow a non-mockable parameter is added to the constructor, it injects null without warning

Rewritten version:

@ExtendWith(MockitoExtension.class)
@DisplayName("ReservationProcessor")
class ReservationProcessorTest {

    private static final ZoneId MADRID = ZoneId.of("Europe/Madrid");
    private static final LocalDate TODAY = LocalDate.of(2026, 3, 20);
    private static final Clock CLOCK = Clock.fixed(TODAY.atStartOfDay(MADRID).toInstant(), MADRID);

    @Mock private ReservationRepository reservations;   // external collaborator: mock
    @Mock private LoanManager manager;                  // collaborator with effects: mock

    private ReservationProcessor processor;
    private Material book;
    private Employee marta;

    @BeforeEach
    void setUp() {
        // REAL objects: configuration records, fixed clock and domain entities
        var properties = new BiblioTechProperties(
                "BiblioTech",
                new BiblioTechProperties.Loan(15, 3),
                new BiblioTechProperties.Fine(new BigDecimal("0.50"), new BigDecimal("20.00")),
                new BiblioTechProperties.Reservation(3));    // 3-day expiry

        // Explicit construction, not @InjectMocks
        processor = new ReservationProcessor(reservations, manager, properties, CLOCK);

        book = new Book("978-0000000001", "Effective Java", 0, "J. Bloch");
        marta = new Employee("[email protected]", "Marta Ruiz");
    }

    @Test
    @DisplayName("expires the reservations requested more than 3 days ago")
    void expiresTheOldReservations() {
        // Requested on the 10th -> expired on the 13th -> today is the 20th: expired
        Reservation old = new Reservation(book, marta, LocalDate.of(2026, 3, 10), 3);
        when(reservations.pending()).thenReturn(List.of(old));

        processor.processPending();

        verify(reservations).expire(old);                         // COMMAND: verified
        assertThat(old.getStatus()).isEqualTo(ReservationStatus.EXPIRED);    // real STATE
        verify(manager, never()).lend(anyString(), anyString());
    }

    @Test
    @DisplayName("does not expire a reservation still within the 3-day window")
    void doesNotExpireTheRecentOnes() {
        // Requested on the 18th -> expires on the 21st -> today is the 20th: still valid
        Reservation recent = new Reservation(book, marta, LocalDate.of(2026, 3, 18), 3);
        when(reservations.pending()).thenReturn(List.of(recent));
        when(manager.isAvailable("978-0000000001")).thenReturn(false);

        processor.processPending();

        verify(reservations, never()).expire(any());
        assertThat(recent.getStatus()).isEqualTo(ReservationStatus.PENDING);
    }

    @Test
    @DisplayName("lends and completes the reservation when a copy is available")
    void completesTheReservationIfACopyIsFree() {
        Reservation valid = new Reservation(book, marta, LocalDate.of(2026, 3, 18), 3);
        when(reservations.pending()).thenReturn(List.of(valid));
        when(manager.isAvailable("978-0000000001")).thenReturn(true);

        processor.processPending();

        verify(manager).lend("978-0000000001", marta.getEmail());     // COMMAND
        verify(reservations).complete(valid);                          // COMMAND
        verify(reservations, never()).expire(any());
    }

    @Test
    @DisplayName("on the exact expiry day the reservation is still valid")
    void theEdgeOfTheWindow() {
        // Requested on the 17th -> expires on the 20th -> today is the 20th: IT IS the last day, still valid
        Reservation onTheEdge = new Reservation(book, marta, LocalDate.of(2026, 3, 17), 3);
        when(reservations.pending()).thenReturn(List.of(onTheEdge));
        when(manager.isAvailable(anyString())).thenReturn(false);

        processor.processPending();

        verify(reservations, never()).expire(any());
    }

    @Test
    @DisplayName("does nothing if there are no pending reservations")
    void noReservations() {
        when(reservations.pending()).thenReturn(List.of());

        processor.processPending();

        verifyNoInteractions(manager);
    }
}

What has changed and why:

Before Now Benefit
@Mock Clock with two whens A real Clock.fixed Two lines fewer, more readable, no risk of null
@Mock on two properties records Built with new Four lines fewer, and the value is visible in the test
@Mock Reservation, @Mock Material Real entities The domain logic gets exercised (getStatus, transitions)
Five verifys of queries None It survives refactorings
verifyNoMoreInteractions Targeted never() and verifyNoInteractions Checks what matters without blocking legitimate changes
One test method Five tests with descriptive names When it fails, you know what
No state assertions assertThat(reservation.getStatus()) Checks the real effect, not just the call
No edge cases The exact expiry day That is where the < versus <= bug lives

Solution 3

package com.nexussoftware.bibliotech.service;

import static org.assertj.core.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;

import java.math.BigDecimal;
import java.time.*;
import java.util.*;
import org.junit.jupiter.api.*;
import org.mockito.*;
import org.mockito.junit.jupiter.MockitoExtension;

@ExtendWith(MockitoExtension.class)
@DisplayName("ReturnManager")
class ReturnManagerTest {

    private static final ZoneId MADRID = ZoneId.of("Europe/Madrid");
    private static final LocalDate TODAY = LocalDate.of(2026, 3, 20);
    private static final Clock CLOCK = Clock.fixed(TODAY.atStartOfDay(MADRID).toInstant(), MADRID);

    // MOCKS: collaborators with external effects or infrastructure
    @Mock private LoanRepository loans;
    @Mock private FineRegistry fines;
    @Mock private ReservationProcessor reservations;
    @Mock private NoticeService notices;

    @Captor private ArgumentCaptor<String> messageCaptor;
    @Captor private ArgumentCaptor<Employee> employeeCaptor;
    @Captor private ArgumentCaptor<BigDecimal> amountCaptor;

    private ReturnManager manager;
    private Material book;
    private Employee marta;
    private Employee diego;

    @BeforeEach
    void setUp() {
        var properties = new BiblioTechProperties(
                "BiblioTech",
                new BiblioTechProperties.Loan(15, 3),
                new BiblioTechProperties.Fine(new BigDecimal("0.50"), new BigDecimal("20.00")),
                new BiblioTechProperties.Reservation(3));

        // REAL: pure, cheap, deterministic. Mocking it would weaken the test
        var calculator = new FineCalculator(properties, CLOCK);

        manager = new ReturnManager(loans, calculator, fines, reservations, notices, CLOCK);

        book  = new Book("978-0000000001", "Effective Java", 2, "J. Bloch");
        marta = new Employee("[email protected]", "Marta Ruiz");
        diego = new Employee("[email protected]", "Diego Alonso");
    }

    // ============ (1) ON TIME ============
    @Nested
    @DisplayName("on-time return")
    class OnTime {

        @Test
        @DisplayName("generates neither a fine nor a notice")
        void noFineNoNotice() {
            Loan loan = loanDueOn(TODAY.plusDays(5));                // still within the term
            when(loans.findById(1L)).thenReturn(Optional.of(loan));
            when(reservations.nextFor(book)).thenReturn(Optional.empty());

            ReturnResult result = manager.returnItem(1L);

            assertThat(result.fine()).isEqualByComparingTo("0.00");
            assertThat(result.hasPendingReservation()).isFalse();
            assertThat(loan.getStatus()).isEqualTo(LoanStatus.RETURNED);
            assertThat(loan.getReturnDate()).contains(TODAY);

            verify(loans).save(loan);                  // COMMAND
            verifyNoInteractions(fines);               // no fine was recorded
            verify(notices, never()).notify(any(), anyString());
        }

        // ============ (6) COPIES ============
        @Test
        @DisplayName("the material recovers an available copy")
        void recoversACopy() {
            Loan loan = loanDueOn(TODAY.plusDays(5));
            when(loans.findById(1L)).thenReturn(Optional.of(loan));
            when(reservations.nextFor(book)).thenReturn(Optional.empty());

            int before = book.getAvailableCopies();
            manager.returnItem(1L);

            assertThat(book.getAvailableCopies()).isEqualTo(before + 1);
        }
    }

    // ============ (2) LATE ============
    @Nested
    @DisplayName("late return")
    class Late {

        @Test
        @DisplayName("records a €5.00 fine for 10 days and notifies with the detail")
        void tenDayFine() {
            Loan loan = loanDueOn(TODAY.minusDays(10));
            when(loans.findById(1L)).thenReturn(Optional.of(loan));
            when(reservations.nextFor(book)).thenReturn(Optional.empty());

            ReturnResult result = manager.returnItem(1L);

            // The fine is computed by the REAL code: 10 days x €0.50 = €5.00
            assertThat(result.fine()).isEqualByComparingTo("5.00");

            // COMMAND 1: recording the fine, capturing the real arguments
            verify(fines).register(eq(loan), amountCaptor.capture());
            assertThat(amountCaptor.getValue()).isEqualByComparingTo("5.00");

            // COMMAND 2: the notice, with its CONTENT verified
            verify(notices).notify(employeeCaptor.capture(), messageCaptor.capture());
            assertThat(employeeCaptor.getValue()).isEqualTo(marta);
            assertThat(messageCaptor.getValue())
                    .contains("fine")
                    .contains("5.00")
                    .contains("Effective Java")
                    .doesNotContain("null");
        }

        @Test
        @DisplayName("the fine is capped at the €20 maximum for very long delays")
        void cappedFine() {
            Loan loan = loanDueOn(TODAY.minusDays(300));
            when(loans.findById(1L)).thenReturn(Optional.of(loan));
            when(reservations.nextFor(book)).thenReturn(Optional.empty());

            assertThat(manager.returnItem(1L).fine()).isEqualByComparingTo("20.00");
            verify(fines).register(eq(loan), argThat(f -> f.compareTo(new BigDecimal("20.00")) == 0));
        }
    }

    // ============ (3) WITH A PENDING RESERVATION ============
    @Nested
    @DisplayName("when there is a pending reservation for the material")
    class WithReservation {

        @Test
        @DisplayName("also notifies the next employee in the queue")
        void notifiesTheNextOne() {
            Loan loan = loanDueOn(TODAY.plusDays(5));                // on time: only 1 notice
            Reservation diegosReservation = new Reservation(book, diego, TODAY.minusDays(1), 3);

            when(loans.findById(1L)).thenReturn(Optional.of(loan));
            when(reservations.nextFor(book)).thenReturn(Optional.of(diegosReservation));

            ReturnResult result = manager.returnItem(1L);

            assertThat(result.hasPendingReservation()).isTrue();
            assertThat(diegosReservation.getStatus()).isEqualTo(ReservationStatus.NOTIFIED);
            assertThat(diegosReservation.getNotifiedAt()).isPresent();

            verify(notices).notify(employeeCaptor.capture(), messageCaptor.capture());
            assertThat(employeeCaptor.getValue()).isEqualTo(diego);
            assertThat(messageCaptor.getValue())
                    .contains("available")
                    .contains("Effective Java");
        }

        @Test
        @DisplayName("late AND reserved sends two notices, the fine one first")
        void twoNoticesInOrder() {
            Loan loan = loanDueOn(TODAY.minusDays(10));
            Reservation diegosReservation = new Reservation(book, diego, TODAY.minusDays(1), 3);

            when(loans.findById(1L)).thenReturn(Optional.of(loan));
            when(reservations.nextFor(book)).thenReturn(Optional.of(diegosReservation));

            manager.returnItem(1L);

            verify(notices, times(2))
                    .notify(employeeCaptor.capture(), messageCaptor.capture());

            // Order IS a requirement here: the person hit by the fine hears about it first
            assertThat(employeeCaptor.getAllValues()).containsExactly(marta, diego);
            assertThat(messageCaptor.getAllValues().get(0)).contains("fine");
            assertThat(messageCaptor.getAllValues().get(1)).contains("available");
        }
    }

    // ============ (4) and (5) ERROR CASES ============
    @Nested
    @DisplayName("error cases")
    class Errors {

        @Test
        @DisplayName("non-existent loan: exception and no effects")
        void loanNotFound() {
            when(loans.findById(99L)).thenReturn(Optional.empty());

            assertThatThrownBy(() -> manager.returnItem(99L))
                    .isInstanceOf(LoanNotFoundException.class)
                    .hasMessageContaining("99");

            verify(loans, never()).save(any());
            verifyNoInteractions(fines, notices, reservations);
        }

        @Test
        @DisplayName("already-returned loan: exception and no effects")
        void loanAlreadyReturned() {
            Loan returned = loanDueOn(TODAY.minusDays(10));
            returned.returnItem(TODAY.minusDays(5));               // already returned
            when(loans.findById(1L)).thenReturn(Optional.of(returned));

            int copiesBefore = book.getAvailableCopies();

            assertThatThrownBy(() -> manager.returnItem(1L))
                    .isInstanceOf(LoanAlreadyReturnedException.class);

            verify(loans, never()).save(any());
            verifyNoInteractions(fines, notices, reservations);
            // No effect on the material's state
            assertThat(book.getAvailableCopies()).isEqualTo(copiesBefore);
        }
    }

    // --- scenario factory ---
    private Loan loanDueOn(LocalDate dueDate) {
        return new Loan(book, marta, dueDate.minusDays(15), dueDate);
    }
}

Justification of the decisions:

Collaborator Decision Reason
LoanRepository Mock Infrastructure. findById is a query (stub), save is a command (verify)
FineCalculator REAL Pure, cheap, deterministic. Using it for real makes the test check that the fine really is €5.00, not that the value the mock returned was passed along
BiblioTechProperties REAL It is a record. The values are in plain sight in the test
Clock Clock.fixed, not a mock It is a value class with a proper factory. Mocking it would be worse in every way
FineRegistry Mock A command with an external effect. It gets verified
NoticeService Mock + captor A command with an external effect, and the message content has to be checked
ReservationProcessor Mock nextFor is a query (stub)
Loan, Material, Reservation, Employee REAL Domain entities with their own logic. Mocking them would make it impossible to check state transitions such as getStatus() or getNotifiedAt()

And the criterion applied in each test:

  • findById and nextFor are never verified: they are queries, and their effect is already implicit in the result.
  • save, register and notify are always verified: they are commands with an observable effect.
  • The real state of the entities is checked (getStatus, getAvailableCopies, getNotifiedAt), not just the calls.
  • verifyNoInteractions is used in the error cases to guarantee the method's most important property: if it fails, it must leave no partial effects.
  • Order is verified in one case only, and because it is an explicit business requirement, not because the code happens to do it that way.
  • The 300-day delay case checks the fine cap, with the calculation done by the real code.

Conclusion

BiblioTech's tests no longer stop where the easy classes end.

You know why JUnit is not enough: real collaborators are slow, non-deterministic, hard to put into a specific state or carry real effects. And you know the five kinds of double with precise definitions —dummy, stub, spy, mock and fake— with the vocabulary clarification that avoids confusion: in Mockito everything is called a mock, and the distinction is conceptual. And you know the option almost everybody forgets: the in-memory fake, which is written once, gives state coherence for free and does not break when you refactor — often the best investment for an application's main repository.

You have mastered Mockito 5: @ExtendWith(MockitoExtension.class) with its strict validation that warns you about programmed-and-never-used behaviour; mock creation; and why building the object under test explicitly is better than @InjectMocks, which injects null silently, does not accept non-mockable objects and hides the signal of a constructor with too many parameters. You program behaviour with when(...).thenReturn(...) —knowing it is not magic, but a recorded invocation—, you provoke failures that are impossible to reproduce any other way with thenThrow, and you give dynamic answers with thenAnswer, with the warning that when the Answer grows logic, what you really wanted was a fake.

You know when the do... syntax is mandatoryvoid methods, spies and reprogramming—, and why: because when(x.method()) executes the method, which on a spy fires the real code with all its consequences. You know a mock's default values, including the two that save the most trouble: Optional.empty() and empty collections.

You verify interactions with verify and its whole family —times, never, atLeast, only, inOrder, verifyNoMoreInteractions— knowing that never() is probably the most valuable one, because checking that something did not happen is impossible with assertions on the result. And above all you have the judgement, which is what separates good tests from brittle ones:

Stub the queries, verify the commands.

Verifying a query tests implementation, breaks when you refactor and is redundant anyway, because the assertion on the result already checks it indirectly. Verifying a command tests an observable effect that appears in no return value. If you remember only one sentence from this lesson, make it that one.

You handle matchers with the all-or-nothing rule —and you know why InvalidUseOfMatchersException can show up on the next line or in the next test, because the stack is left contaminated—, and you use ArgumentCaptor when the question is not "was it called with something like this?" but "with what exactly?", preferring it to argThat for its error messages.

You know about spies and their warning: needing one on your own class almost always means that class does two things. And you know about mockStatic with an even stronger warning: needing it is almost always the sign that the design should change — if you have to mock LocalDate.now(), what is missing is an injected Clock.

You know what must not be mocked: types you do not control —because you are mocking your belief about their behaviour and the test can be green while the code fails—, value classes, the framework, JDK collections and the class under test itself. And you recognise over-mocking, with its full diagnosis: it transcribes the implementation, it breaks when you refactor, it does not check the result, it is unreadable and it gives false confidence, because the method can be wrong and the test still green.

You tell state-based tests from interaction-based ones, you have seen the same requirement solved both ways and you know why the second version is better: it checks that the fine really is €5.00, computed by the real code, instead of checking that the value the mock itself returned was passed along. With a clear recommendation: prefer state; use interaction only when the effect is not observable any other way.

And you have the most important lesson of the whole testing module: sometimes the design removes the need for the double. The injected Clock from 10-05 instead of mockStatic; @ConfigurationProperties instead of a static BusinessRules that is impossible to mock; an extracted pure function instead of a spy on yourself; a reusable fake instead of twenty mocks. Before writing a complicated mock, ask yourself whether the problem is that the design needs to change.

BiblioTech now has LoanManager tested with mocked repositories —including the error paths and the retry after the optimistic-locking conflict of 11-03—, NoticeService verified with ArgumentCaptor right down to the message content, and module 9's MetadataClient tested without touching the network, after wrapping it in an interface of our own as the "do not mock what you do not control" criterion demands.

And you know the necessary complement: @SpringBootTest with @MockitoBean —with its performance warning about multiple contexts—, @DataJpaTest with H2 and its em.clear() without which the test would pass even with a broken mapping, Testcontainers as today's standard for testing against the real database, and coverage with its honest reading: an executed line is not a tested line, and a high target produces bad tests.


BiblioTech now has a reproducible Maven project with Spring, JPA on H2, and a battery of unit and integration tests covering the domain, the services and the persistence layer. It builds with ./mvnw clean verify and it deploys with java -jar.

Three debts from module 10 remain unpaid, and all three are of the same nature: they are libraries, not frameworks.

BiblioTech's JSON is still parsed with indexOf. That "teaching stopgap" from 09-06 has been waiting two modules for its replacement, and it breaks with the first escape character or the first nested object. The entities still carry fifty lines of getters, equals, hashCode and toString written by hand — the boilerplate that an annotation processor, like the one you studied in 10-02, can generate on its own. And the logging is still java.util.logging, chosen in 06-07 to avoid adding dependencies, with the limitation already pointed out there: the whole ecosystem uses SLF4J, and mvn dependency:tree has shown you three times that Spring Boot already brought it in without you asking.

In the next lesson, which closes the module, all three get paid off. You will see Jackson and the real ObjectMapper —with its annotations, TypeReference for the generics that survive the type erasure of 10-01, the JavaTimeModule for the java.time of 10-05, and the rewrite of MetadataClient promised two modules ago—; you will see Lombok, how it generates code at compile time and an honest assessment of its problems, including @Data on JPA entities as a genuine source of bugs; and you will see SLF4J and Logback, the facade versus the implementation, parameterised logging with {} and why it is measurably better than concatenating, and BiblioTech's complete migration.

And you will close the module with an overview of the libraries every Java developer should know — including the one that settles the last outstanding debt: the hand-written CSV of 07-07.

Java Programming Course

Module 1: Introduction to Java

Module 2: Control Flow

Module 3: Object-Oriented Programming

Module 4: Advanced Object-Oriented Programming

Module 5: Data Structures and Collections

Module 6: Exception Handling

Module 7: File Input/Output

Module 8: Multithreading and Concurrency

Module 9: Networking

Module 10: Advanced Topics

Module 11: Java Frameworks and Libraries

Module 12: Building Real-World Applications

© Copyright 2026. All rights reserved