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
- Why JUnit alone is not enough
- The five kinds of test double
- The in-memory fake: the underrated alternative
- Mockito 5: dependency and set-up
- Creating mocks: annotations and factory methods
@InjectMocksand its caveats- Defining behaviour:
when(...).thenReturn(...) thenThrow: testing the error pathsthenAnswer: dynamic responsesdoReturn,doThrow,doNothingand when they are mandatory- A mock's default values
- Verifying interactions:
verify times,never,atLeast,onlyinOrderandverifyNoMoreInteractions- The criterion: verify commands, not queries
- Matchers:
any,eq,argThat - The all-or-nothing rule
ArgumentCaptor- Spies with
@Spy mockStaticand why it is a warning sign- What must NOT be mocked
- The risk of over-mocking
- State-based versus interaction-based tests
- The same case, solved both ways
- When the design removes the need for a mock
- Combining Mockito with AssertJ
- BiblioTech:
LoanManagerwith mocked repositories - BiblioTech:
NoticeServiceverified withArgumentCaptor - BiblioTech:
MetadataClienttested without the network - Integration tests:
@SpringBootTestand@MockitoBean @DataJpaTestwith H2- Testcontainers, introduced
- Coverage: an honest reading
- Common Mistakes and Tips
- Exercises
- 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:
- Slow: database, network, disk.
- Non-deterministic: the clock, randomness, API responses that change.
- Hard to put into a specific state: how do you make the database fail on exactly the third
INSERT? - 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.
- 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.
- 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.
- 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.
- 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.
@InjectMocks and its caveats
@InjectMocks and its caveatsMockito 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:
- It fails silently. If Mockito cannot find a mock for a parameter, it injects
nullwithout warning. The test then fails with aNullPointerExceptionwhose cause is not obvious. - It does not accept non-mockable values. BiblioTech's
Clock.fixedandBiblioTechPropertiesare not mocks: they are real objects.@InjectMocksdoes not supply them. - 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".
@InjectMockshides 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.
- Defining behaviour:
when(...).thenReturn(...)
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).
thenThrow: testing the error paths
thenThrow: testing the error pathsHere 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.
thenAnswer: dynamic responses
thenAnswer: dynamic responsesWhen 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.
doReturn, doThrow, doNothing and when they are mandatory
doReturn, doThrow, doNothing and when they are mandatoryThere 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.
- 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).
- Verifying interactions:
verify
verifyUp 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.
times, never, atLeast, only
times, never, atLeast, onlyverify(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 onesnever() 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.
inOrder and verifyNoMoreInteractions
inOrder and verifyNoMoreInteractionsWhen 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 calledUse it sparingly: any new and legitimate call —adding a metric, a trace— breaks the test without the behaviour having changed.
- 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.
- Matchers:
any, eq, argThat
any, eq, argThatWhen 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.
- The all-or-nothing rule
A mistake everyone makes the first time:
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.
ArgumentCaptor
ArgumentCaptorverify 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.
- Spies with
@Spy
@SpyA 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:
- Legacy code that cannot be refactored and where a method has to be isolated.
- Verifying calls on a real object without replacing its behaviour.
- 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.
mockStatic and why it is a warning sign
mockStatic and why it is a warning signSince 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
mockStaticis 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.
- 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:
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.
- 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:
- It describes the implementation, not the behaviour. It is a transcription of the method body.
- 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.
- It does not check the result. Not one assertion on the returned loan. It could return
nulland the test would pass. - It mocks things it should not.
propertiesis an immutablerecordandcalculatoris a pure function: both are cheap to use for real. - It is unreadable. Twelve lines of configuration to test one case.
- 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.
- 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).
- 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.
- 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.
- 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.
- BiblioTech:
LoanManager with mocked repositories
LoanManager with mocked repositoriesThe 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:
FineCalculatorandBiblioTechPropertiesare real. They are cheap, deterministic and effect-free: mocking them would only add noise and weaken the test.findByIsbnandfindByEmailare never verified. They are queries. The fact that they were called is already implicit in the result being correct.saveandnotifyare verified. They are commands with an effect.never()andverifyNoInteractionsin the failure cases. Checking absences is whereverifyadds 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.
- BiblioTech:
NoticeService verified with ArgumentCaptor
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.
- BiblioTech:
MetadataClient tested without the network
MetadataClient tested without the networkThe 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.
- Integration tests:
@SpringBootTest and @MockitoBean
@SpringBootTest and @MockitoBeanUnit 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".
@DataJpaTest with H2
@DataJpaTest with H2To 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.
- 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.
- 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.
- 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.
- 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.
- Testing that
FineCalculatorcaps the fine at €20 when the delay is 200 days. - Testing that lending the last copy sends a notice to the librarian in charge.
- Testing that
LoanManagersaves nothing if the employee has reached the limit. - Testing
BiblioTechStatistics, which makes eight different repository queries and aggregates the results with streams. - Testing that
CatalogEnrichercarries on processing when the external API returns a 503 error. - Testing that
OperationLogwrites an audit entry for every loan. - Testing that
ReservationProcessorexpires 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:
- On-time return: no fine, no fine notice.
- Return 10 days late: a €5.00 fine recorded and notified, with the message content verified using
ArgumentCaptor. - Return of a material with a pending reservation: the next employee is also notified (two notices in total, checked for order and content).
- Non-existent loan: exception and no effects.
- Already-returned loan: exception and no effects.
- 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:
findByIdandnextForare never verified: they are queries, and their effect is already implicit in the result.save,registerandnotifyare always verified: they are commands with an observable effect.- The real state of the entities is checked (
getStatus,getAvailableCopies,getNotifiedAt), not just the calls. verifyNoInteractionsis 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 mandatory —void 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
- Introduction to Java
- Setting Up the Development Environment
- Basic Syntax and Structure
- Variables and Data Types
- Operators
- Console Input and Output
- Your First Complete Program: BiblioTech
Module 2: Control Flow
- Conditional Statements
- Loops
- Switch Statements
- Break and Continue
- Debugging and Execution Traces
- Project: The BiblioTech Interactive Menu
Module 3: Object-Oriented Programming
- Introduction to OOP
- Classes and Objects
- Methods
- Constructors
- Inheritance
- Polymorphism
- Encapsulation
- Abstraction
- The Object Class: equals, hashCode and toString
Module 4: Advanced Object-Oriented Programming
- Interfaces
- Abstract Classes
- Inner Classes
- Anonymous Classes
- Lambda Expressions
- Functional Interfaces and Method References
- Enums and Records
Module 5: Data Structures and Collections
- Arrays
- The Collections Framework
- ArrayList
- LinkedList
- HashMap
- HashSet
- Queue and Deque
- Stack
- Sorting and Searching Collections
Module 6: Exception Handling
- Introduction to Exceptions
- The Try-Catch Block
- Throw and Throws
- Custom Exceptions
- The Finally Block
- Try-with-resources and AutoCloseable
- Error Handling Strategies and Logging
Module 7: File Input/Output
- Reading Files
- Writing Files
- File Streams
- BufferedReader and BufferedWriter
- Serialization
- The NIO.2 API: Path and Files
- Interchange Formats: CSV and Properties
Module 8: Multithreading and Concurrency
- Introduction to Multithreading
- Creating Threads
- Thread Lifecycle
- Synchronization
- Concurrency Utilities
- Concurrent Collections and Atomic Variables
- Asynchronous Tasks with CompletableFuture
Module 9: Networking
- Introduction to Networking
- Sockets
- ServerSocket
- DatagramSocket and DatagramPacket
- URL and HttpURLConnection
- The Modern HTTP Client
Module 10: Advanced Topics
- Generics
- Annotations
- Reflection
- Java 8 Features: Streams and Optional
- Dates and Times with java.time
- Java 9 and Beyond
- Memory, Garbage Collection and Performance
Module 11: Java Frameworks and Libraries
- Introduction to Java Frameworks
- Spring Framework
- Hibernate
- JUnit
- Maven
- Advanced Testing with Mockito
- Essential Ecosystem Libraries
