1. Overview
In this lesson, we’ll explore spies and partial mocks in Mockito. Unlike standard mocks that fully fake all method calls, a spy wraps a real object so that specific calls invoke actual code, while others can be stubbed or verified. We’ll discuss how to create spies, when partial mocks make sense, and best practices to avoid overcomplicating our tests.
The relevant module we need to import when starting this lesson is: mockito-spies-start.
If we want to reference the fully implemented lesson, we can import: mockito-spies-end.
The end reference also updates closeCampaign() to save the changed campaign through campaignRepository.save(campaign) before returning it. The start reference returns the changed campaign without that save call. This production-code difference isn’t required for the spy examples in this lesson.
The supplied Maven test configuration loads Mockito’s agent when the test JVM starts. We’ll run the tests with mvn test from the imported project’s directory.
2. Understanding Spies in Mockito
A spy in Mockito is a special kind of mock that wraps a real object, allowing the actual methods to be called unless we explicitly override them.
So, when we spy on an InMemoryCampaignRepository or DefaultCampaignService from our codebase, any method that isn’t explicitly stubbed invokes the class’s real implementation.
When we pass an existing object to spy(), Mockito copies its state rather than forwarding calls to that original object. We should interact with the spy in the test instead of expecting changes to the original instance to be reflected automatically.
By contrast, a standard mock in Mockito returns default values for unstubbed calls, such as null, zero, or empty collections, without executing the real code. Spying can be helpful if we need to rely on some real behavior but still want the flexibility to stub or verify certain calls, gaining fine-grained control over particular interactions. Thus, this approach is particularly useful for large, complex classes.
3. Creating and Using Spies
In this section, let’s look at the different ways to create the spies and override their behaviors.
3.1. @Spy Annotation vs. spy() Method
Mockito gives us two main ways to create spies: using the @Spy annotation or calling the spy() method manually. Both approaches wrap a real object, allowing us to invoke its actual methods unless we explicitly override them.
Let’s start by using @Spy. In the following example, MockitoExtension initializes the spy, and @InjectMocks supplies it to the service under test.
First, let’s create a new test class:
@ExtendWith(MockitoExtension.class)
class DefaultCampaignServiceSpyUnitTest {
@Spy
InMemoryCampaignRepository spyRepository;
@InjectMocks
DefaultCampaignService service;
@Test
void givenSpyOnRepository_whenCallingMethod_thenRealMethodIsCalled() {
Campaign sampleCampaign = new Campaign();
sampleCampaign.setId(1L);
spyRepository.save(sampleCampaign);
// When
Optional<Campaign> result = service.findById(1L);
// Then
assertTrue(result.isPresent());
assertEquals(1L, result.get()
.getId());
verify(spyRepository).save(sampleCampaign);
verify(spyRepository, times(2)).findById(1L);
}
}
In this example, we’re using a spy to monitor or override specific behavior of the InMemoryCampaignRepository. Since we haven’t stubbed any methods, the real save() and findById() methods are called, allowing the test to verify real behavior with the flexibility to override if needed.
Notice we verify that findById() is called twice: once internally by spyRepository.save() and once by service.findById().
Now, let’s try the second approach: using the static method spy() directly.
We’ll add a new test:
@Test
void givenSpyOnRepositoryUsingStaticMethod_whenCallingMethod_thenRealMethodIsCalled() {
// Given
Campaign sampleCampaign = new Campaign();
sampleCampaign.setId(1L);
CampaignRepository realRepository = new InMemoryCampaignRepository();
CampaignRepository spyRepository = spy(realRepository);
spyRepository.save(sampleCampaign);
DefaultCampaignService service = new DefaultCampaignService(spyRepository);
// When
Optional<Campaign> result = service.findById(1L);
// Then
assertTrue(result.isPresent());
assertEquals(1L, result.get()
.getId());
verify(spyRepository).save(sampleCampaign);
verify(spyRepository, times(2)).findById(1L);
}
Note: we’re using a static import for the org.mockito.Mockito.spy method.
Unlike the previous approach, where we used the @Spy annotation, here we manually create the object and pass it to the spy() method; no annotation is involved.
This approach gives us more control over object creation and is often used when setting up spies outside of a test framework’s lifecycle or inside individual test methods.
Let’s verify both spy-creation examples by running mvn test. Both tests should retrieve the saved campaign and confirm the expected repository calls.
3.2. Overriding Behavior
Sometimes, we don’t want a method to execute its real code. With spies, we can override specific behavior using stubbing. Let’s say we want findAll() to return an empty list, regardless of what is saved.
We’ll add a new test to understand the overriding behavior:
@Test
void givenSpyOnRepository_whenCallingMethodWithOverride_thenStubbedMethodIsCalledInsteadOfReal() {
// Given
InMemoryCampaignRepository realRepo = new InMemoryCampaignRepository();
InMemoryCampaignRepository repoSpy = spy(realRepo);
Campaign campaign = new Campaign("CODE", "Name", "Desc");
when(repoSpy.findAll()).thenReturn(Collections.emptyList());
DefaultCampaignService service = new DefaultCampaignService(repoSpy);
// When
service.create(campaign);
List<Campaign> allCampaigns = service.findCampaigns();
// Then
assertTrue(allCampaigns.isEmpty());
verify(repoSpy).save(campaign);
}
In the above test, we used when().thenReturn() to stub the findAll() method on the spy. The stubbing expression calls the real findAll() once during setup. After stubbing, subsequent calls return an empty list instead of executing its real logic, even though the test saves a campaign successfully. All other methods continue to behave normally unless explicitly stubbed. This selective control is what makes spies powerful. They allow us to combine real and mocked behaviors based on the specific needs of each test.
If calling the real method during setup would cause a side effect or throw an exception, we can use doReturn(…).when(spy).method() instead. For this in-memory findAll() example, the setup call is harmless.
Let’s run mvn test again to verify that the campaign is saved while the stubbed query returns an empty list.
4. Best Practices
We should use spies or partial mocks only when we truly need to isolate or control specific behaviors. Overusing them can lead to brittle tests and unexpected side effects, especially if the real methods perform actions such as network calls, file I/O, or database access.
Relying heavily on partial mocks may indicate that the class under test does more than one logical job. If we find ourselves stubbing many methods on a spy, it might be time to split or simplify the class.
Additionally, it’s better to clearly separate real vs. mocked behavior. We should make it obvious in the tests which methods are using real logic and which are stubbed.
This can be done by grouping all stubbing logic (e.g., using when(…).thenReturn(…)) at the start of the test and using descriptive naming or comments to indicate mocked behavior. This improves readability and helps avoid confusion when maintaining tests later.
5. Conclusion
In this lesson, we’ve looked at spies and partial mocks in Mockito. We explored two ways to create spies, using the @Spy annotation and the static spy() method, and we saw how to selectively override specific methods while preserving real behavior for the rest.
Spies are a powerful tool when we need fine-grained control over a real object’s behavior in tests, but they should be used deliberately to keep our tests clean and maintainable.