The previous lesson ended on a clear limit. StationService could be tested without Spring because InMemoryStationRepository existed, a fake written by hand back in module 2. But RentalService —the class that concentrates CicloUrbana's business rules— depends on four JPA repositories, on FareSelector, on the Clock, on NetworkProperties and on Spring's event publisher. Writing a double by hand for each one would be hundreds of lines of code nobody would maintain, and many of those types are interfaces generated by Spring Data that we cannot even implement comfortably.
Mockito solves exactly that: it generates doubles on the fly for any interface or class, lets you program what they return and lets you check how they were called. In this lesson we will see why doubles are needed, how they are created, how their answers are programmed, how argument matchers work and why their all-or-nothing rule produces a baffling error, how to verify interactions without ending up with brittle tests, how to capture arguments with ArgumentCaptor, what spies are and why they are almost always a design smell, what UnnecessaryStubbingException is telling you and when a fake is still better than five lines of stubbing. And we will apply it to a complete test of RentalService, with its scenarios of unavailable bike, full station, exceeded duration and amount calculation.
Contents
- Why doubles are needed
- Three ways of creating a mock
- Programming answers: stubbing
- Argument matchers
- Verifying interactions
ArgumentCaptor- Spies: what they are and why hardly ever
- Strictness and
UnnecessaryStubbingException - Mocking statics and constructors
- The centrepiece:
RentalServiceTest - Mock or fake: when to use each
- What must not be mocked
- Common Mistakes and Tips
- Exercises
- Why doubles are needed
Three reasons, and all three show up in CicloUrbana:
Isolating the unit. If RentalServiceTest used real repositories, a failure could come from the query, the mapping, the transaction or the logic. With doubles, if the test fails, the bug is in RentalService. That is the whole value of a unit test.
Avoiding slow or external resources. Database, network, file system, third-party services. Each one multiplies the time by a thousand and adds another reason for the test to fail when nothing is wrong with the code.
Forcing difficult or impossible scenarios. This is the least obvious reason and the most valuable. How do you test that RentalService reacts properly when the repository throws a DataAccessException because of a connection failure? Or when FareSelector is given a fare name that does not exist? Provoking it for real is hard; with a double, it is one line:
Mockito 5 is included in spring-boot-starter-test. No additional dependency is declared, and declaring one with a version of your own is the fast route to a classpath conflict (06-01).
- Three ways of creating a mock
// 1. Programmatic: useful inside one specific method
StationRepository repository = Mockito.mock(StationRepository.class);
// 2. With annotations + extension: the usual form in this course
@ExtendWith(MockitoExtension.class)
class RentalServiceTest {
@Mock private BikeRepository bikeRepository;
@Mock private RentalRepository rentalRepository;
@Mock private FareSelector fareSelector;
private RentalService service; // built by hand in @BeforeEach
@BeforeEach
void prepareService() {
service = new RentalService(bikeRepository, rentalRepository,
fareSelector, /* ... */);
}
}@ExtendWith(MockitoExtension.class) is what makes the @Mock fields be initialised before every test —using the same parameter resolution mechanism we saw in 06-02— and what switches on the strictness checks of section 8. Without the extension the annotated fields stay null and the test fails with a NullPointerException that sends you the wrong way.
The third form is @InjectMocks, and it deserves a warning:
It looks convenient, but it carries three real risks:
| Risk | What happens |
|---|---|
| Silent failure | If a @Mock for one dependency is missing, Mockito injects null without warning; the failure shows up as a NullPointerException inside the production code |
| Ambiguity by type | With two dependencies of the same type, Mockito resolves by field name, and an innocent rename changes what gets injected where |
| It hides the design problem | If building the class by hand is awkward because it has eight dependencies, that is information that @InjectMocks conceals |
The course policy: build the object with new in the @BeforeEach. It is possible precisely because in 02-02 we decided on constructor injection: the class can be instantiated in two lines with no magic apparatus. And when those two lines start to take up ten, the test is telling you that RentalService has too many responsibilities.
By default a mock returns empty values: null for objects, 0 for numbers, false for booleans, empty collections for List or Set and Optional.empty() for Optional. That last one is very handy: the "not found" case of any findById needs no stubbing.
- Programming answers: stubbing
// Fixed answer
when(bikeRepository.findById(1L)).thenReturn(Optional.of(bikeRB0142));
// Throwing an exception
when(rentalRepository.save(any())).thenThrow(new DataAccessResourceFailureException("outage"));
// Consecutive answers: the first call returns one thing, the second another
when(bikeRepository.findById(1L))
.thenReturn(Optional.of(bikeRB0142))
.thenReturn(Optional.empty()); // and from the third call onwards, this one
// thenAnswer: the answer depends on the arguments received
when(rentalRepository.save(any(Rental.class))).thenAnswer(invocation -> {
Rental received = invocation.getArgument(0);
received.assignId(99L); // simulates what the database does
return received;
});thenAnswer is the tool for the commonest case of all in Spring Data: save returns the entity with the identifier already assigned. Without it, the stubbed save would return null and the production code would fail for a reason that has nothing to do with what is being tested.
There is a second syntax, with the verb up front:
doReturn(Optional.of(bikeRB0142)).when(bikeRepository).findById(1L);
doThrow(new StationFullException("Main Square", 24)).when(stationService).dock(1L);
doNothing().when(eventPublisher).publishEvent(any());| Style | When to use it |
|---|---|
when(...).thenReturn(...) |
By default: it reads better and checks types at compile time |
doReturn(...).when(...) |
Mandatory with void methods, with spies and when the real method must not run |
The reason for the exception is mechanical: when(mock.voidMethod()) does not compile, because void is not an expression. And with a spy, when(spy.method()) runs the real method before programming it, with whatever side effects that entails. With doReturn the call never happens.
- Argument matchers
A stub with a literal value only answers to that exact value. Matchers generalise:
| Matcher | Matches |
|---|---|
any() |
Anything, null included |
any(Rental.class), anyLong(), anyString() |
Any non-null value of the type |
eq(1L) |
That exact value, in a context that already uses matchers |
argThat(r -> r.getTotalAmount().compareTo(TEN) > 0) |
Whatever satisfies the predicate |
isNull() / isNotNull() |
Null or not null |
anyList(), anyMap(), anySet() |
Non-null collections |
The all-or-nothing rule. If a method takes several arguments and you use a matcher on one, you must use matchers on all of them:
// ❌ Mixed: fails at run time
when(repo.findByStationAndStatus(1L, any())).thenReturn(List.of());
// ✅ All matchers: eq() wraps the literal
when(repo.findByStationAndStatus(eq(1L), any(BikeStatus.class))).thenReturn(List.of());The error the first form produces is famous for how badly it reads:
org.mockito.exceptions.misusing.InvalidUseOfMatchersException: Invalid use of argument matchers! 2 matchers expected, 1 recorded
The cause lies in the implementation: matchers are not values, they are pushed onto an internal stack when evaluated. Mockito counts the ones recorded and compares them with the number of arguments; if they do not tally, it cannot know which was which. The practical consequence is that the error sometimes appears on the following line, in a test that has nothing to do with it, which wastes a good while. If you see an InvalidUseOfMatchersException in an absurd place, look for the mix in the previous stubbing.
Prefer literal values when you know the argument. when(repo.findById(1L)) is more specific and more informative than when(repo.findById(anyLong())): if the production code starts asking for identifier 2, you want the test to tell you, not to carry on passing.
- Verifying interactions
So far we have used mocks as stubs: sources of data. They also serve to check that they were called:
verify(rentalRepository).save(any(Rental.class)); // exactly once
verify(rentalRepository, times(2)).save(any());
verify(rentalRepository, never()).delete(any()); // must not happen
verify(bikeRepository, atLeastOnce()).findById(1L);
verify(bikeRepository, atMost(3)).findById(anyLong());
verify(fareSelector, only()).calculate("standard", TWO_HOURS); // this one and no other
// Order across several mocks
InOrder order = inOrder(bikeRepository, rentalRepository, eventPublisher);
order.verify(bikeRepository).save(any());
order.verify(rentalRepository).save(any());
order.verify(eventPublisher).publishEvent(any(RentalStarted.class));
verifyNoMoreInteractions(rentalRepository); // nothing beyond what is already verified
verifyNoInteractions(userRepository); // not touched at allAnd now the warning, which is the most important thing in this section. Verifying interactions couples the test to how the method is written, not to what it does. A test full of verify breaks with every refactoring, even when the behaviour has not changed, and then the team starts seeing tests as a tax rather than a safety net.
| Verify when... | Do not verify when... |
|---|---|
| The interaction is the observable effect: the event was published, the email was sent, the entity was saved | You have already checked the returned result: also verifying how it was calculated is redundant |
You have to check something did not happen: never() on a delete |
The mock is just a source of data: findById was already "verified" by using its answer |
| The order genuinely matters for correctness | The order is an implementation detail |
verifyNoMoreInteractions deserves a separate mention: it looks rigorous and in practice produces the most brittle tests in the suite, because any added call —a log, a supporting query— breaks it. Save it for cases where "touch nothing else" is the business rule.
ArgumentCaptor
ArgumentCaptorWhen the object you care about is passed to a collaborator instead of being returned, you have to capture it. That is exactly the case of RentalService.start: it builds a Rental internally and hands it to save.
@Test
void savesTheRentalWithTheBikeTheStationAndTheStartTime() {
// Arrange
when(bikeRepository.findById(1L)).thenReturn(Optional.of(bikeRB0142));
when(rentalRepository.save(any(Rental.class))).thenAnswer(i -> i.getArgument(0));
// Act
service.start(new StartRentalRequest(1L, 7L, 1L));
// Assert: capture the object the repository received
ArgumentCaptor<Rental> captor = ArgumentCaptor.forClass(Rental.class);
verify(rentalRepository).save(captor.capture());
Rental saved = captor.getValue();
assertSoftly(softly -> {
softly.assertThat(saved.getBike().getPlate()).isEqualTo("RB-0142");
softly.assertThat(saved.getOriginStation().getId()).isEqualTo(1L);
softly.assertThat(saved.getStartedAt()).isEqualTo(NOW_IN_RIBALTA);
softly.assertThat(saved.getEndedAt()).isNull();
softly.assertThat(saved.getStatus()).isEqualTo(RentalStatus.IN_PROGRESS);
});
}With @Captor on a field you avoid the forClass line, and with getAllValues() you retrieve every capture when the method was called several times.
Captor or argThat: argThat(r -> r.getStatus() == IN_PROGRESS) is shorter, but when it fails all it says is "there were no matching invocations", without showing what actually arrived. ArgumentCaptor produces a failure message with the concrete value. For assertions, the captor; for selecting between several calls, argThat.
- Spies: what they are and why hardly ever
A spy wraps a real object: by default all of its methods really run, and only the ones you program are replaced.
@Spy private FareSelector fareSelector = new FareSelector(List.of(new StandardFare()));
@Test
void usesTheRealFareExceptForTheCaseThatIsStubbed() {
doReturn(new BigDecimal("99.00")).when(fareSelector).calculate("standard", TWO_HOURS);
// ^ doReturn is mandatory: with when(...) the real calculation would run first
}Why they are a design smell. Needing to mock part of a class almost always means that class does two things: the one you want to test and the one you want to avoid. The right solution is to extract the second into a collaborator and inject it, which is what we did with FareCalculator in 02-02 and with Clock in 02-01. A spy is a patch over a structural problem.
Their two defensible uses: legacy code you cannot restructure yet, and third-party classes where you only want to alter one method. Beyond that, when you find yourself writing @Spy, stop and look at the class.
- Strictness and
UnnecessaryStubbingException
UnnecessaryStubbingExceptionMockitoExtension applies the STRICT_STUBS mode by default, which is one of Mockito 5's best decisions:
| Mode | Behaviour |
|---|---|
LENIENT |
Anything goes; unused stubs are ignored silently |
WARN |
Warns on the console |
STRICT_STUBS (default) |
Fails if there are unused stubs or calls with arguments no stub covers |
The typical error:
org.mockito.exceptions.misusing.UnnecessaryStubbingException: Unnecessary stubbings detected. 1. -> at RentalServiceTest.rejectsBikeUnderMaintenance(RentalServiceTest.java:64)
What it is telling you, which is hardly ever "delete that line". There are three possible causes, in order of seriousness:
- The code does not reach where you thought. You stubbed
stationRepository.findByIdand the bike validation cuts in first. The stub is redundant because the test is not testing what you thought it was: that is a finding, not an annoyance. - The test drags along another one's arrangement. A
@BeforeEachwith five stubs was copied over and this case only uses two. Move only what is common into the@BeforeEach. - The code changed and the test did not. This is the signal you want: there is dead stubbing.
If a stub must exist even though it is not always used, lenient().when(...) exempts it, and @MockitoSettings(strictness = Strictness.LENIENT) switches the mode off for the whole class. Use the first sparingly and the second almost never: silencing the warning loses the information.
- Mocking statics and constructors
Since Mockito 3.4 static methods can be mocked, and since 3.5 object construction:
try (MockedStatic<LocalDateTime> clock = mockStatic(LocalDateTime.class)) {
clock.when(LocalDateTime::now).thenReturn(LocalDateTime.of(2026, 3, 14, 10, 0));
// ... inside the try, LocalDateTime.now() returns that value
} // outside the try, everything goes back to normal
try (MockedConstruction<Random> generator = mockConstruction(Random.class,
(mock, context) -> when(mock.nextInt(100)).thenReturn(42))) {
// any new Random() created in here is a mock
}Two important technical details: the static mock only affects the current thread and it must be closed, hence the try-with-resources; if it escapes, it contaminates the following tests with failures that are impossible to attribute.
And now the part that matters: it is almost always the wrong solution. Compare the two ways of testing a rental duration calculation:
With mockStatic(LocalDateTime.class) |
With the injected Clock (02-01) |
|---|---|
| Requires try-with-resources and special syntax | Clock.fixed(...) in the constructor |
| Only affects the current thread: surprises with concurrent code | No global effects |
Breaks if the code moves to Instant.now() |
Carries on working |
| Run-time instrumentation, slower | Zero cost |
| Hides the fact that the design is coupled to the system clock | Makes the dependency explicit |
The conclusion is the one from 06-02 said from the other side: inject what is non-deterministic instead of mocking it. mockStatic is the tool for legacy code you cannot change today, not a design alternative.
- The centrepiece:
RentalServiceTest
RentalServiceTestEverything above, applied to the most important class in CicloUrbana.
package com.ciclourbana.rentals;
@ExtendWith(MockitoExtension.class)
@DisplayName("RentalService · rental rules of the Ribalta network")
class RentalServiceTest {
private static final Instant NOW = Instant.parse("2026-03-14T09:00:00Z");
@Mock private BikeRepository bikeRepository;
@Mock private StationRepository stationRepository;
@Mock private UserRepository userRepository;
@Mock private RentalRepository rentalRepository;
@Mock private FareSelector fareSelector;
@Mock private ApplicationEventPublisher eventPublisher;
private RentalService service;
@BeforeEach
void prepareService() {
// Explicit construction: no @InjectMocks, thanks to the constructor
// injection of 02-02. The clock, frozen (06-02).
service = new RentalService(bikeRepository, stationRepository,
userRepository, rentalRepository, fareSelector,
eventPublisher, Clock.fixed(NOW, ZoneOffset.UTC),
TestNetworkProperties.defaults());
}
@Nested
@DisplayName("when starting a rental")
class WhenStarting {
@Test
void savesTheRentalAndPublishesTheRentalStartedEvent() {
when(bikeRepository.findById(1L))
.thenReturn(Optional.of(TestBikes.rb0142Available()));
when(rentalRepository.save(any(Rental.class)))
.thenAnswer(i -> i.getArgument(0)); // as the database would
service.start(new StartRentalRequest(1L, 7L, 1L));
// Publishing the event DOES deserve a verify: it is an observable effect
verify(eventPublisher).publishEvent(any(RentalStarted.class));
}
@Test
void rejectsTheBikeWhenTheBatteryIsBelowTheThreshold() {
when(bikeRepository.findById(1L))
.thenReturn(Optional.of(TestBikes.withBattery(12))); // threshold 20
assertThatThrownBy(() -> service.start(new StartRentalRequest(1L, 7L, 1L)))
.isInstanceOf(BikeUnavailableException.class)
.hasMessageContaining("RB-0142");
// Nothing must have been saved or published: here never() is the rule
verify(rentalRepository, never()).save(any());
verifyNoInteractions(eventPublisher);
}
@Test
void throwsResourceNotFoundWhenTheBikeDoesNotExist() {
// No stubbing: a mock returns Optional.empty() by default
assertThatThrownBy(() -> service.start(new StartRentalRequest(99L, 7L, 1L)))
.isInstanceOf(ResourceNotFoundException.class);
}
}
@Nested
@DisplayName("when finishing a rental")
class WhenFinishing {
@Test
void calculatesTheAmountWithTheUsersFareAndSavesIt() {
Rental inProgress = TestRentals.inProgressSince(NOW.minus(Duration.ofMinutes(30)));
when(rentalRepository.findById(5L)).thenReturn(Optional.of(inProgress));
when(stationRepository.findById(2L))
.thenReturn(Optional.of(TestStations.northStationWithFreeDocks()));
when(fareSelector.calculate("standard", Duration.ofMinutes(30)))
.thenReturn(new BigDecimal("4.10"));
RentalResponse response = service.finish(5L, new FinishRentalRequest(2L));
assertThat(response.totalAmount()).isEqualByComparingTo("4.10");
assertThat(response.status()).isEqualTo(RentalStatus.FINISHED);
assertThat(response.durationMinutes()).isEqualTo(30);
}
@Test
void rejectsTheReturnWhenTheDestinationStationIsFull() {
when(rentalRepository.findById(5L))
.thenReturn(Optional.of(TestRentals.inProgressSince(NOW)));
when(stationRepository.findById(1L))
.thenReturn(Optional.of(TestStations.fullMainSquare()));
assertThatThrownBy(() -> service.finish(5L, new FinishRentalRequest(1L)))
.isInstanceOf(StationFullException.class)
.hasFieldOrPropertyWithValue("code", "STATION_FULL");
}
@Test
void appliesASurchargeWhenTheTwoHourMaximumIsExceeded() {
Rental longRental = TestRentals.inProgressSince(NOW.minus(Duration.ofHours(3)));
when(rentalRepository.findById(5L)).thenReturn(Optional.of(longRental));
when(stationRepository.findById(2L))
.thenReturn(Optional.of(TestStations.northStationWithFreeDocks()));
when(fareSelector.calculate(eq("standard"), any(Duration.class)))
.thenReturn(new BigDecimal("22.10"));
RentalResponse response = service.finish(5L, new FinishRentalRequest(2L));
assertThat(response.totalAmount()).isEqualByComparingTo("27.10"); // + €5 surcharge
}
}
}Four decisions worth pointing out, because they are the practical summary of the lesson:
- The clock frozen in the
@BeforeEachmakes "thirty minutes" and "three hours" exact statements, not approximations that depend on when the suite happens to run. fareSelectoris mocked, not real. The correctness of the fare calculation was already tested in 06-02 with the parameterised tests; what is tested here is thatRentalServiceasks for the right fare and uses the result, including the surcharge it adds on its own account.- Little is verified and much is asserted. There are only three
verifycalls in the whole class, and all three correspond to genuine observable effects: publishing the event, saving nothing after a rejection and not touching the publisher. The rest are assertions on the returned value. - The "not found" case needs no stubbing, because the default value of a mock returning
OptionalisOptional.empty(). Less code and clearer.
- Mock or fake: when to use each
CicloUrbana has a hand-written fake, InMemoryStationRepository, and now mocks as well. They do not compete: they serve different purposes.
| Mock (Mockito) | Fake (in-memory implementation) | |
|---|---|---|
| Initial cost | Zero | Writing and maintaining a class |
| Arrangement per test | Explicit stubbing in each one | save(...) and that is it |
| Behaviour | Only what is programmed | Coherent: what you save, you read back |
| Verifying interactions | Yes | No |
| Readability with many cases | Falls off a cliff | Holds up |
| Risk | Stubs that lie about the real behaviour | The fake drifts from the real implementation |
The practical rule: if a test needs more than three or four lines of stubbing to prepare the same repository, or if the scenario amounts to "save something and then read it back", the fake is better. Four chained when calls to simulate a sequence of reads are unreadable; a repository.save(mainSquare()) is not.
And the other way round: if all you need is for a method to return a value and you want to check it was called, writing a whole class is a waste.
The risk of the fake is real and must be named: it can diverge from the real implementation. InMemoryStationRepository filters with stream().filter(...), while the JPA repository generates SQL with different ordering and case rules. That is why the fake never replaces the tests of 06-04 and 06-05 against the real database: it is a tool for testing the logic that uses it, not for testing persistence.
- What must not be mocked
| Do not mock | Why | What to do |
|---|---|---|
Types you do not own (Jackson, JJWT, the EntityManager) |
Your mock freezes a contract the third party can change; the test passes and production fails | Wrap it in an interface of your own and mock that; or test the integration for real (06-04) |
Value objects (Station, BigDecimal, Duration, DTO records) |
They are cheap to build and their behaviour is what you want to test | new, or a TestStations factory |
| The class under test | A partial spy on it tests your mocking, not your code | Extract the problematic collaborator |
| Everything, indiscriminately | A test where everything is a mock verifies that Mockito works | Use real objects when they are cheap and deterministic |
The first point has a painful example in CicloUrbana: mocking JJWT's JwtParser to test JwtService would produce a test that always passes, because what can genuinely fail —the signature, the expiry, the issuer— sits precisely inside the mocked part. The correct test of JwtService is the one from 06-02: real JJWT, injected Clock.
Common Mistakes and Tips
Forgetting @ExtendWith(MockitoExtension.class). The @Mock fields stay null and the failure shows up inside the production code, far from the cause.
Mixing matchers and literals. InvalidUseOfMatchersException, often pointing at the wrong line. Wrap the literals with eq(...).
when(spy.method()) on a spy. It runs the real method before programming it. With spies, always doReturn(...).when(spy).method().
Mocking final, static or private methods without meaning to. Mockito 5 uses the inline mock maker by default and can already handle final, but a private method is still out of reach: if you need to mock it, it should be a separate class.
Over-verifying. A test with eight verify calls and no assertion on the result does not check behaviour, it checks a transcript of the method. It will break on the first refactoring.
Silencing the UnnecessaryStubbingException with lenient() without reading it. This is the most important tip in the lesson: that error is information, and very often it is telling you that the test is not running the path you think it is.
Stubs that lie. when(repo.save(any())).thenReturn(rentalWithAmount) makes the test pass even if the service never calculates the amount: you put the value there yourself. Always return what the real collaborator would return —with thenAnswer(i -> i.getArgument(0)) for save calls— and assert on what the code produces.
Tip: put in the @BeforeEach only what every test uses. The rest goes in each test, where it reads next to what it verifies. With STRICT_STUBS, doing otherwise fails anyway.
Tip: name the data, not the mocks. TestBikes.withBattery(12) says why that value matters; bike1 says nothing.
Exercises
Exercise 1
Write a test of RentalService.start that verifies, using ArgumentCaptor, that the published RentalStarted event carries the identifier of the rental just saved and the instant from the injected Clock, and not the system's Instant.now(). Explain why this test would fail if somebody replaced the Clock with a static call, and why that is exactly what you want.
Exercise 2
RentalSecurity.isOwner(rentalId, user) from 05-05 queries rentalRepository.existsByIdAndUserId. Write its test class with Mockito covering four cases: the user is the owner, they are not, rentalId is null and user is null. Pay attention to one detail: in two of the four cases the repository must not be queried at all. Verify that and reason why that verify is justified.
Exercise 3
This test has five of the defects seen in the lesson. List them and rewrite it:
@ExtendWith(MockitoExtension.class)
class RentalServiceTest {
@Mock RentalRepository rentalRepository;
@Mock BikeRepository bikeRepository;
@Mock FareSelector fareSelector;
@InjectMocks RentalService service;
@Test
void test() {
when(bikeRepository.findById(anyLong())).thenReturn(Optional.of(new Bike()));
when(fareSelector.calculate(anyString(), any())).thenReturn(new BigDecimal("4.1"));
when(rentalRepository.save(any())).thenReturn(new Rental());
service.start(new StartRentalRequest(1L, 7L, 1L));
verify(bikeRepository).findById(anyLong());
verify(rentalRepository).save(any());
verify(rentalRepository, times(1)).save(any());
}
}Solutions
Solution 1
@Test
void publishesTheEventWithTheRentalIdAndTheInjectedClockTime() {
when(bikeRepository.findById(1L))
.thenReturn(Optional.of(TestBikes.rb0142Available()));
when(rentalRepository.save(any(Rental.class))).thenAnswer(invocation -> {
Rental received = invocation.getArgument(0);
received.assignId(99L); // what the PostgreSQL sequence does
return received;
});
ArgumentCaptor<RentalStarted> captor =
ArgumentCaptor.forClass(RentalStarted.class);
service.start(new StartRentalRequest(1L, 7L, 1L));
verify(eventPublisher).publishEvent(captor.capture());
assertSoftly(softly -> {
softly.assertThat(captor.getValue().rentalId()).isEqualTo(99L);
softly.assertThat(captor.getValue().occurredAt()).isEqualTo(NOW);
});
}Comment: the thenAnswer that assigns the identifier is essential. Without it, save would return null or a rental with no id, and the assertion on 99L could not tell "the service does not propagate the id" from "the mock did not set it". Here you can also see why the captor beats argThat in this case: if occurredAt did not match, the message shows the instant that arrived, whereas argThat would only say there were no matches.
Why it would fail with Instant.now(): the assertion compares against NOW, the exact instant of the Clock.fixed in the @BeforeEach. A static call would return the real time of the run, which is never that. And that is precisely the point: the test acts as a guardian of the design. If tomorrow somebody "simplifies" the code by removing the Clock, the suite goes red flagging the design regression before it reaches production. It is the fourth reason to test from 06-01 —design pressure— turned into a concrete assertion.
Solution 2
@ExtendWith(MockitoExtension.class)
@DisplayName("RentalSecurity · the @PreAuthorize of 05-05")
class RentalSecurityTest {
@Mock private RentalRepository rentalRepository;
private RentalSecurity security;
@BeforeEach
void prepareComponent() {
security = new RentalSecurity(rentalRepository);
}
@Test
void allowsACitizenToManageTheirOwnRental() {
when(rentalRepository.existsByIdAndUserId(9L, 7L)).thenReturn(true);
assertThat(security.isOwner(9L, TestUsers.marta())).isTrue();
}
@Test
void deniesACitizenSomeoneElsesRental() {
when(rentalRepository.existsByIdAndUserId(9L, 7L)).thenReturn(false);
assertThat(security.isOwner(9L, TestUsers.marta())).isFalse();
}
@Test
void deniesWithoutQueryingTheDatabaseWhenTheIdentifierIsNull() {
assertThat(security.isOwner(null, TestUsers.marta())).isFalse();
verifyNoInteractions(rentalRepository);
}
@Test
void deniesWithoutQueryingTheDatabaseWhenThereIsNoAuthenticatedUser() {
assertThat(security.isOwner(9L, null)).isFalse();
verifyNoInteractions(rentalRepository);
}
}Why verifyNoInteractions is justified here, when section 5 warned against over-verifying: because in these two cases not querying is part of the correct behaviour, not an implementation detail. If the code called existsByIdAndUserId(null, null), the result would still be false and an assertion on the returned value would not tell the two versions apart; but the second one fires a query at PostgreSQL on every anonymous request, and on a public endpoint that is a route to saturation. The rule: verify an absence of interaction when that absence is the guarantee you want.
Note as well that these four tests run in milliseconds and already turn half of the security rule from 05-05 into assertions. The other half —that @PreAuthorize is in place and that Spring applies it— cannot be checked here: it needs the context, and that is the job of 06-04.
Solution 3
The five defects:
@InjectMocks: ifRentalServicehas more dependencies than those declared —and it does:StationRepository,Clock, the publisher—, Mockito injectsnullsilently.test()as a name: it describes no rule; neither the report nor the failure will say anything.anyLong()andanyString()where the values are known: if the code starts asking for a different identifier or the "student" fare, the test carries on passing. Lax matchers hide regressions.when(fareSelector.calculate(...))is not used bystart: the fare is only calculated on finishing. WithSTRICT_STUBSthis is anUnnecessaryStubbingException, and what it is pointing out is that the test mixes two flows.- Over-verification and no assertions: three
verifycalls (two of them equivalent, becauseverify(x)already meanstimes(1)) and zeroassertThaton the result. It is the assertion-free test of 06-01 in disguise. A sixth, deeper problem comes with it:when(save(any())).thenReturn(new Rental())is a stub that lies, because it returns an empty rental with nothing to do with what was passed in.
Rewritten, with a single behaviour per test:
@Test
void savesTheRentalWithTheRequestedBikeAndPublishesTheEvent() {
when(bikeRepository.findById(1L))
.thenReturn(Optional.of(TestBikes.rb0142Available()));
when(rentalRepository.save(any(Rental.class))).thenAnswer(i -> i.getArgument(0));
RentalResponse response = service.start(new StartRentalRequest(1L, 7L, 1L));
assertThat(response.bikePlate()).isEqualTo("RB-0142");
assertThat(response.status()).isEqualTo(RentalStatus.IN_PROGRESS);
verify(eventPublisher).publishEvent(any(RentalStarted.class));
}The fare calculation moves to its own test inside the WhenFinishing block, which is where it happens.
Conclusion
RentalService has stopped being the untested part of CicloUrbana. You know why doubles are needed —isolating the unit, avoiding slow resources and, above all, forcing scenarios that would be hard to provoke for real— and the three ways of creating them, with the course policy well founded: @Mock with MockitoExtension and explicit construction of the object in the @BeforeEach, without @InjectMocks, because the constructor injection of 02-02 makes it unnecessary and because the day building the class becomes awkward, that awkwardness is information worth not hiding.
You have mastered stubbing in both syntaxes and you know when doReturn stops being a stylistic alternative and becomes mandatory: void methods and spies. You know the matchers, the all-or-nothing rule and the mechanical reason —the internal stack— why InvalidUseOfMatchersException sometimes appears on the wrong line. You know how to verify with times, never, inOrder and verifyNoInteractions, and you know the hardest part: verify little, saving it for genuine observable effects, because a test full of verify is a transcript of the method that breaks on the first refactoring. You capture arguments with ArgumentCaptor when the interesting object is handed to a collaborator —the case of the Rental going into save and of the RentalStarted event—, and you know when the captor beats argThat: when the failure message matters.
You have seen why spies are almost always a design smell and why mockStatic should not compete with injecting a Clock, and you have learnt to read the UnnecessaryStubbingException for what it is: three possible diagnoses, the most interesting of which is "your test is not running the path you think it is". You have the mock-versus-fake comparison with an operational criterion —more than three or four lines of stubbing on the same repository, or a save-and-read scenario, call for a fake— and the list of what is never mocked: types you do not own, value objects and the class under test.
But look at what is still not covered. That @PreAuthorize is in place on RentalService.finish and that Spring really applies it; that StationController returns a 404 in ProblemDetail format; that findByUserIdAndEndedAtIsNull generates the right SQL; that the authorizeHttpRequests rules close off what we believe they do. None of that can be checked with mocks, because what fails there is not the logic: it is the wiring, the annotations and the framework. The next lesson, Integration Testing, starts the Spring context with judgement: @SpringBootTest and its context cache, the @WebMvcTest and @DataJpaTest slices, MockMvc, @MockitoBean —the replacement for the deprecated @MockBean— and the spring-security-test annotations that will turn every rule from module 5 into an automatic assertion.
Spring Boot Course
Module 1: Introduction to Spring Boot
- What Is Spring Boot?
- Setting Up Your Development Environment
- Building Your First Spring Boot Application
- Understanding the Project Structure
- Application Startup and Lifecycle
Module 2: Spring Boot Core Concepts
- Spring Boot Annotations
- Dependency Injection in Spring Boot
- Bean Scope and Lifecycle
- Spring Boot Configuration
- Spring Boot Properties
- Auto-Configuration and Starters from the Inside
Module 3: Building RESTful Web Services
- Introduction to RESTful Web Services
- Creating REST Controllers
- Handling HTTP Methods
- Validating Input Data
- DTOs and Mapping Between Layers
- Exception Handling in REST
- Documenting the API with OpenAPI
Module 4: Data Access with Spring Boot
- Introduction to Spring Data JPA
- Configuring Data Sources
- Creating JPA Entities
- Relationships Between Entities
- Using Spring Data Repositories
- Query Methods in Spring Data JPA
- Transactions and Persistence Management
- Schema Migrations with Flyway
Module 5: Security in Spring Boot
- Introduction to Spring Security
- Configuring Spring Security
- User Authentication and Authorization
- Implementing JWT Authentication
- Method-Level Security and API Hardening
Module 6: Testing in Spring Boot
- Introduction to Testing
- Unit Testing with JUnit
- Mocking with Mockito
- Integration Testing
- Testing with Testcontainers
Module 7: Advanced Spring Boot Features
- Spring Boot Actuator
- Spring Boot Profiles
- Scheduled Tasks and Asynchronous Execution
- Spring Boot with Docker
- Spring Boot and Microservices
- Service Communication and Fault Tolerance
Module 8: Deploying Spring Boot Applications
- Introduction to Deployment
- Deploying to Heroku
- Deploying to AWS
- Deploying to Kubernetes
- Continuous Integration and Delivery
Module 9: Performance and Monitoring
- Performance Tuning
- Caching with Spring Cache
- Monitoring with Spring Boot Actuator
- Using Prometheus and Grafana
- Logging and Log Management
- Distributed Tracing
