In the world of unit testing, we often use Mocks—fake objects with pre-programmed behavior. But what if you need to test a real object while still tracking its interactions or overriding only specific methods? This is where the concept of a Spy comes in. A spy is a partial mock that wraps a "real" object, allowing you to use its actual implementation by default while giving you the ability to verify interactions and stub specific methods when needed.
This article explores the @Spy annotation in Mockito, demonstrating how and when to use this powerful testing tool effectively.
What is a Spy? Mock vs. Spy
Understanding the distinction is crucial:
- Mock: A fake object where all methods are stubbed by default. You must define their behavior. If you call a method on a mock without stubbing it, it typically returns a default value (e.g.,
null,0,false). - Spy: A wrapper around a real object. If you call a method on a spy without stubbing it, the real method on the underlying object is executed.
| Aspect | Mock | Spy |
|---|---|---|
| Default Behavior | Do nothing / return default values | Call the real implementation |
| Underlying Object | No real object; it's a proxy | Wraps a real, instantiated object |
| Primary Use Case | Isolating the class under test by replacing its dependencies | Verifying interactions or overriding specific methods of a real object |
Creating a Spy with Mockito
Let's use a simple BookService and BookRepository for our examples.
// Class under test
public class BookService {
private BookRepository bookRepository;
public BookService(BookRepository bookRepository) {
this.bookRepository = bookRepository;
}
public int getBookCount() {
return bookRepository.findAll().size();
}
public String getBookTitle(String isbn) {
Book book = bookRepository.findByIsbn(isbn);
return book != null ? book.getTitle() : "Book not found";
}
}
// Dependency
public class BookRepository {
public List<Book> findAll() {
// Real implementation: might connect to a database
return Arrays.asList(
new Book("123", "Effective Java"),
new Book("456", "Clean Code")
);
}
public Book findByIsbn(String isbn) {
// Real implementation
return findAll().stream()
.filter(book -> book.getIsbn().equals(isbn))
.findFirst()
.orElse(null);
}
}
public class Book {
private String isbn;
private String title;
// Constructor, getters, setters...
public Book(String isbn, String title) {
this.isbn = isbn;
this.title = title;
}
public String getIsbn() { return isbn; }
public String getTitle() { return title; }
}
Method 1: Using Mockito.spy()
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import java.util.Arrays;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.*;
class BookServiceTest {
@Test
void testSpyWithRealMethods() {
// Create a REAL repository
BookRepository realRepository = new BookRepository();
// Create a SPY that wraps the real repository
BookRepository repositorySpy = spy(realRepository);
// Create the service with the spy
BookService bookService = new BookService(repositorySpy);
// When we call getBookCount, it uses the REAL findAll() method
int count = bookService.getBookCount();
assertEquals(2, count); // Passes because real method returns 2 books
// We can verify the real method was called
verify(repositorySpy).findAll();
}
}
Method 2: Using @Spy Annotation (with JUnit 5)
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Spy;
import org.mockito.junit.jupiter.MockitoExtension;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class BookServiceAnnotationTest {
// Mockito will create a spy that wraps a real BookRepository instance
@Spy
BookRepository bookRepositorySpy = new BookRepository();
@Test
void testWithSpyAnnotation() {
BookService bookService = new BookService(bookRepositorySpy);
String title = bookService.getBookTitle("123");
assertEquals("Effective Java", title);
verify(bookRepositorySpy).findByIsbn("123"); // Verify real method was called
}
}
Stubbing Methods on a Spy
This is where spies become particularly useful. You can override specific methods while keeping the rest of the real functionality.
@Test
void testSpyWithStubbedMethod() {
BookRepository realRepository = new BookRepository();
BookRepository repositorySpy = spy(realRepository);
BookService bookService = new BookService(repositorySpy);
// Stub the findAll() method to return a different list
// WITHOUT affecting other methods like findByIsbn
when(repositorySpy.findAll()).thenReturn(Arrays.asList(
new Book("111", "Mockito Guide"),
new Book("222", "JUnit in Action"),
new Book("333", "Testing Bible")
));
// Now getBookCount uses the STUBBED findAll()
int count = bookService.getBookCount();
assertEquals(3, count); // Now returns 3 instead of 2
// But findByIsbn still uses the REAL implementation
// (which now searches through our stubbed list!)
String title = bookService.getBookTitle("222");
assertEquals("JUnit in Action", title);
}
Important Considerations and Pitfalls
1. The when(...).thenReturn() Problem
There's a subtle but important syntax issue when stubbing methods on spies.
// ❌ PROBLEMATIC: The real method gets called during stubbing!
List<Book> result = when(repositorySpy.findAll()).thenReturn(mockedList);
// This will actually call realRepository.findAll()!
// ✅ CORRECT: Use doReturn...when pattern for spies
doReturn(Arrays.asList(new Book("111", "Test Book")))
.when(repositorySpy)
.findAll();
2. Spying on Concrete Classes
You can only spy on real objects, not interfaces (unless you use a implementing class).
// This works - spying on a concrete class List<String> realList = new ArrayList<>(); List<String> listSpy = spy(realList); // This WON'T work - can't spy on an interface // List<String> listSpy = spy(List.class); // Throws exception! // This also WON'T work - can't spy on abstract classes // AbstractList<String> abstractSpy = spy(AbstractList.class);
3. Partial Mocking - A Code Smell?
Sometimes, needing to spy on an object indicates a design issue. If you find yourself spying extensively, consider if your class has too many responsibilities (violating Single Responsibility Principle).
// If you need to do this often, it might indicate a problem:
@Service
public class PaymentProcessor {
public void processPayment(Payment payment) {
validatePayment(payment); // You spy on this
saveToDatabase(payment); // And spy on this
sendNotification(payment); // And spy on this
updateInventory(payment); // And spy on this
}
// Maybe these should be separate dependencies?
}
Practical Use Cases for Spies
1. Testing Legacy Code
When working with poorly designed legacy code that's hard to test, spies can help you isolate specific methods without refactoring the entire class.
2. Verifying Internal Method Calls
Ensure that certain internal methods are called during execution.
@Test
void verifyInternalMethodCall() {
BookRepository realRepository = new BookRepository();
BookRepository repositorySpy = spy(realRepository);
BookService bookService = new BookService(repositorySpy);
bookService.getBookTitle("123");
// Verify that findByIsbn was called internally
verify(repositorySpy).findByIsbn("123");
}
3. Controlling Specific Dependencies in Integration Tests
In integration tests, you might want most dependencies to be real, but spy on one to monitor its behavior.
@Test
void integrationTestWithSpy() {
// Use real services
UserService realUserService = new UserService();
OrderService realOrderService = new OrderService();
// But spy on the payment service to verify it's called
PaymentService paymentSpy = spy(new PaymentService());
CheckoutService checkout = new CheckoutService(
realUserService, realOrderService, paymentSpy
);
checkout.processOrder(order);
verify(paymentSpy).processPayment(any(Payment.class));
}
Best Practices
- Use Sparingly: Prefer regular mocks for dependencies. Use spies only when you specifically need the real implementation.
- Watch for Side Effects: Remember that real methods are executed, which might have side effects (file I/O, database calls). Make sure this is acceptable in your test environment.
- Use
doReturn().when()Syntax: Avoid thewhen().thenReturn()pattern with spies to prevent unexpected real method execution. - Consider Refactoring: If you find yourself using spies extensively, it might indicate that your class has too many responsibilities and should be refactored.
- Clear Test Names: Name your tests to indicate that partial mocking is being used (e.g.,
processOrder_withSpyOnPaymentService).
Conclusion
Spies in Mockito provide a valuable middle ground between using real objects and full mocks. They allow you to leverage real implementations while maintaining the ability to verify interactions and override specific behavior. While they should be used judiciously—as overuse can indicate design issues—spies are an essential tool in the advanced tester's toolkit, particularly for dealing with legacy code, verifying complex internal interactions, and creating sophisticated test scenarios that require partial mocking.
When used appropriately, spies help you write focused, effective tests that give you confidence in your code's behavior without sacrificing test performance or maintainability.