Test Doubles
Test Doubles
Overview
Test Doubles are objects that replace real dependencies in tests, enabling isolated unit testing. The five main types—Mocks, Stubs, Fakes, Spies, and Dummies—each serve distinct purposes. Understanding when and how to use each type is crucial for writing effective, maintainable tests that focus on the behavior of the system under test without unnecessary complexity.
Martin Fowler’s taxonomy of test doubles provides a common vocabulary for discussing test isolation strategies. Choosing the right test double prevents brittle tests, over-specification, and unnecessary coupling to implementation details.
Key Concepts
The Five Types of Test Doubles
┌─────────────────────────────────────────────────────────────┐
│ Test Double Taxonomy │
├─────────────────────────────────────────────────────────────┤
│ │
│ 🎭 DUMMY │
│ • Passed around but never used │
│ • Satisfies parameter lists │
│ • No behavior implementation │
│ │
│ 📝 STUB │
│ • Returns predetermined values │
│ • Provides indirect input to SUT │
│ • No verification of calls │
│ │
│ 🔍 SPY │
│ • Records interactions for later verification │
│ • Tracks how it was called │
│ • Often wraps real object │
│ │
│ 🎯 MOCK │
│ • Pre-programmed with expectations │
│ • Verifies behavior (how it's called) │
│ • Test fails if expectations not met │
│ │
│ 🏭 FAKE │
│ • Working implementation │
│ • Simpler than production (e.g., in-memory DB) │
│ • Behavior close to real thing │
│ │
└─────────────────────────────────────────────────────────────┘
Mock vs Stub: The Critical Difference
| Aspect | Stub | Mock |
|---|---|---|
| Purpose | Provide data | Verify behavior |
| Verification | State verification (assert output) | Behavior verification (verify calls) |
| Test focus | What SUT returns | How SUT interacts |
| Coupling | Low (returns values) | Higher (knows about calls) |
| Failure mode | Wrong output | Wrong interaction |
When to Use Each Type
| Test Double | Use When | Example Scenario |
|---|---|---|
| Dummy | Need to fill parameter, but value doesn’t matter | Logger passed but not used in test |
| Stub | Need to provide data to SUT | Return user from repository |
| Spy | Want to verify interactions without full mock | Track how many times cache was hit |
| Mock | Need to verify specific interactions | Ensure email sent with correct parameters |
| Fake | Need realistic behavior for complex dependency | In-memory database for integration tests |
Best Practices
1. Prefer Stubs Over Mocks
Use stubs for queries (methods that return data) and reserve mocks for commands (methods with side effects).
2. Don’t Mock What You Don’t Own
Avoid mocking third-party libraries directly. Instead, wrap them in your own interface and mock that.
3. Mock at the Right Boundary
Mock dependencies at architectural boundaries (repositories, external services), not internal collaborators.
4. Avoid Over-Specification
Only verify interactions that are essential to the behavior being tested. Don’t verify every call.
5. Use Test Data Builders with Stubs
Combine stubs with builder patterns to create reusable, readable test data setups.
Code Examples
Example 1: All Five Test Double Types
// System under test
public class OrderService {
private final OrderRepository repository;
private final EmailService emailService;
private final AuditLogger auditLogger;
private final PricingService pricingService;
public Order createOrder(OrderRequest request, PaymentInfo payment) {
Money totalPrice = pricingService.calculateTotal(request.getItems());
Order order = new Order(request.getCustomerId(), request.getItems(), totalPrice);
repository.save(order);
emailService.sendConfirmation(request.getCustomerEmail(), order);
auditLogger.log("Order created: " + order.getId());
return order;
}
}
// ✅ Using different test double types appropriately
@Test
void shouldCreateOrderAndSendConfirmation() {
// STUB - Returns predetermined data
PricingService pricingStub = mock(PricingService.class);
when(pricingStub.calculateTotal(anyList()))
.thenReturn(Money.usd(100));
// MOCK - Verifies behavior
EmailService emailMock = mock(EmailService.class);
// SPY - Records interactions for verification
OrderRepository repositorySpy = spy(new InMemoryOrderRepository());
// DUMMY - Passed but not used in this test
AuditLogger dummyLogger = mock(AuditLogger.class);
OrderService service = new OrderService(
repositorySpy,
emailMock,
dummyLogger,
pricingStub
);
OrderRequest request = new OrderRequest("C123", "customer@example.com", items);
// Act
Order order = service.createOrder(request, paymentInfo);
// Assert - State verification with stub
assertThat(order.getTotalPrice()).isEqualTo(Money.usd(100));
// Verify - Behavior verification with mock
verify(emailMock).sendConfirmation("customer@example.com", order);
// Verify - Interaction tracking with spy
verify(repositorySpy, times(1)).save(any(Order.class));
}
// FAKE - Working implementation for testing
class InMemoryOrderRepository implements OrderRepository {
private final Map<String, Order> orders = new HashMap<>();
@Override
public void save(Order order) {
orders.put(order.getId(), order);
}
@Override
public Optional<Order> findById(String id) {
return Optional.ofNullable(orders.get(id));
}
}
Example 2: Stub vs Mock - Query vs Command
// ✅ CORRECT - Use STUB for queries (return data)
@Test
void shouldCalculateDiscountedPrice() {
// Stub - just returns data
DiscountRepository discountStub = mock(DiscountRepository.class);
when(discountStub.getDiscountFor("VIP"))
.thenReturn(new Discount(0.10));
PricingService service = new PricingService(discountStub);
// Act
Money price = service.calculatePrice("VIP", Money.usd(100));
// Assert - state verification
assertThat(price).isEqualTo(Money.usd(90));
// No need to verify the stub was called
}
// ✅ CORRECT - Use MOCK for commands (side effects)
@Test
void shouldSendEmailAfterOrderConfirmation() {
// Mock - verifies interaction
EmailService emailMock = mock(EmailService.class);
OrderService service = new OrderService(emailMock);
Order order = new Order("ORD-123");
// Act
service.confirmOrder(order);
// Verify - behavior verification
verify(emailMock).send(
eq("customer@example.com"),
eq("Order Confirmed"),
contains("ORD-123")
);
}
// ❌ WRONG - Using mock for query with unnecessary verification
@Test
void shouldCalculatePriceBAD() {
DiscountRepository discountMock = mock(DiscountRepository.class);
when(discountMock.getDiscountFor("VIP"))
.thenReturn(new Discount(0.10));
Money price = service.calculatePrice("VIP", Money.usd(100));
assertThat(price).isEqualTo(Money.usd(90));
// Unnecessary verification - couples test to implementation
verify(discountMock).getDiscountFor("VIP"); // DON'T DO THIS
}
Example 3: Don’t Mock What You Don’t Own
// ❌ WRONG - Mocking third-party library directly
@Test
void shouldFetchUserFromAPI_BAD() {
// Mocking RestTemplate directly - brittle and coupled to Spring internals
RestTemplate restTemplateMock = mock(RestTemplate.class);
when(restTemplateMock.getForObject(anyString(), eq(User.class)))
.thenReturn(new User("John"));
UserService service = new UserService(restTemplateMock);
User user = service.getUser("123");
assertThat(user.getName()).isEqualTo("John");
}
// ✅ CORRECT - Create your own interface and mock that
// Your interface (you own this)
public interface UserApiClient {
User fetchUser(String userId);
}
// Real implementation wraps third-party library
public class RestTemplateUserApiClient implements UserApiClient {
private final RestTemplate restTemplate;
@Override
public User fetchUser(String userId) {
return restTemplate.getForObject("/users/" + userId, User.class);
}
}
// Test mocks your interface
@Test
void shouldFetchUserFromAPI_GOOD() {
UserApiClient apiClientMock = mock(UserApiClient.class);
when(apiClientMock.fetchUser("123"))
.thenReturn(new User("John"));
UserService service = new UserService(apiClientMock);
User user = service.getUser("123");
assertThat(user.getName()).isEqualTo("John");
}
Example 4: Spy for Partial Mocking
// ✅ CORRECT - Using Spy to verify calls while keeping real behavior
@Test
void shouldCacheFrequentlyAccessedData() {
// Spy on real cache implementation
Cache cache = spy(new LRUCache());
DataService service = new DataService(cache);
// First call - cache miss
String data1 = service.getData("key1");
verify(cache).get("key1");
verify(cache).put("key1", data1);
// Second call - cache hit
String data2 = service.getData("key1");
verify(cache, times(2)).get("key1");
verify(cache, times(1)).put("key1", data1); // Put only called once
assertThat(data1).isEqualTo(data2);
}
// ✅ CORRECT - Spy with partial stubbing
@Test
void shouldHandlePartialFailure() {
UserService realService = new UserService(repository);
UserService serviceSpy = spy(realService);
// Stub only one method, rest use real implementation
doThrow(new NetworkException("Connection failed"))
.when(serviceSpy).sendWelcomeEmail(any());
// Act - should handle email failure gracefully
User user = serviceSpy.createUser("john@example.com");
// Assert - user created despite email failure
assertThat(user).isNotNull();
assertThat(user.getEmail()).isEqualTo("john@example.com");
}
// ❌ WRONG - Using spy when full mock would be clearer
@Test
void shouldProcessPayment_BAD() {
PaymentProcessor processor = spy(PaymentProcessor.class);
doReturn(true).when(processor).validateCard(any());
doReturn(true).when(processor).chargeCard(any(), any());
doReturn(true).when(processor).sendReceipt(any());
// Stubbing everything - just use a mock instead!
}
Example 5: Fake for Realistic Test Implementations
// ✅ CORRECT - Fake with realistic behavior
// Production interface
public interface NotificationService {
void send(String recipient, String message);
List<Notification> getPending();
void markAsDelivered(String notificationId);
}
// Fake implementation for testing
public class FakeNotificationService implements NotificationService {
private final List<Notification> notifications = new ArrayList<>();
private int idCounter = 1;
@Override
public void send(String recipient, String message) {
Notification notification = new Notification(
"N-" + idCounter++,
recipient,
message,
Status.PENDING
);
notifications.add(notification);
}
@Override
public List<Notification> getPending() {
return notifications.stream()
.filter(n -> n.getStatus() == Status.PENDING)
.collect(Collectors.toList());
}
@Override
public void markAsDelivered(String notificationId) {
notifications.stream()
.filter(n -> n.getId().equals(notificationId))
.findFirst()
.ifPresent(n -> n.setStatus(Status.DELIVERED));
}
// Test-only helper methods
public List<Notification> getAllNotifications() {
return new ArrayList<>(notifications);
}
public void clear() {
notifications.clear();
}
}
// Usage in tests
@Test
void shouldSendNotificationToAllSubscribers() {
FakeNotificationService notificationService = new FakeNotificationService();
SubscriptionService subscriptionService = new SubscriptionService(notificationService);
subscriptionService.notifySubscribers("New article published!");
// Can query the fake directly
List<Notification> sent = notificationService.getAllNotifications();
assertThat(sent).hasSize(3);
assertThat(sent).extracting("recipient")
.containsExactlyInAnyOrder("user1@example.com", "user2@example.com", "user3@example.com");
}
@Test
void shouldRetryFailedNotifications() {
FakeNotificationService notificationService = new FakeNotificationService();
RetryService retryService = new RetryService(notificationService);
// Simulate some notifications
notificationService.send("user@example.com", "Hello");
List<Notification> pending = notificationService.getPending();
assertThat(pending).hasSize(1);
// Retry logic marks as delivered
retryService.processRetries();
assertThat(notificationService.getPending()).isEmpty();
}
// Alternative: In-memory database fake
public class InMemoryUserRepository implements UserRepository {
private final Map<Long, User> users = new ConcurrentHashMap<>();
private final AtomicLong idGenerator = new AtomicLong(1);
@Override
public User save(User user) {
if (user.getId() == null) {
user.setId(idGenerator.getAndIncrement());
}
users.put(user.getId(), user);
return user;
}
@Override
public Optional<User> findById(Long id) {
return Optional.ofNullable(users.get(id));
}
@Override
public List<User> findAll() {
return new ArrayList<>(users.values());
}
}
Anti-Patterns
❌ Mocking Everything
// WRONG - Over-mocking value objects and simple collaborators
@Test
void shouldCalculateTotalPrice_BAD() {
Money price1 = mock(Money.class);
Money price2 = mock(Money.class);
Money sum = mock(Money.class);
when(price1.add(price2)).thenReturn(sum);
// Too much mocking for simple value objects!
}
// ✅ CORRECT - Use real value objects
@Test
void shouldCalculateTotalPrice_GOOD() {
Money price1 = Money.usd(10);
Money price2 = Money.usd(20);
Money sum = price1.add(price2);
assertThat(sum).isEqualTo(Money.usd(30));
}
❌ Verifying Everything
// WRONG - Over-specification
@Test
void shouldProcessOrder_BAD() {
OrderService service = new OrderService(repository, emailService, logger);
service.processOrder(order);
// Verifying every single interaction - brittle!
verify(repository).findById(order.getId());
verify(repository).save(order);
verify(emailService).send(any());
verify(logger).info(anyString());
verify(logger).debug(anyString());
}
// ✅ CORRECT - Verify only essential behavior
@Test
void shouldProcessOrder_GOOD() {
OrderService service = new OrderService(repository, emailService, logger);
service.processOrder(order);
// Verify only the important side effect
verify(emailService).send(
eq(order.getCustomerEmail()),
contains("Order Confirmed")
);
}
❌ Tight Coupling to Implementation
// WRONG - Test knows too much about internal implementation
@Test
void shouldRegisterUser_BAD() {
UserService service = new UserService(repository, hasher, validator);
service.register("user@example.com", "password");
// Test is coupled to implementation details
InOrder inOrder = inOrder(validator, hasher, repository);
inOrder.verify(validator).validate(any());
inOrder.verify(hasher).hash(eq("password"));
inOrder.verify(repository).save(any());
// Brittle - breaks if implementation order changes
}
// ✅ CORRECT - Test behavior, not implementation
@Test
void shouldRegisterUser_GOOD() {
UserService service = new UserService(repository, hasher, validator);
User user = service.register("user@example.com", "password");
assertThat(user).isNotNull();
assertThat(user.getEmail()).isEqualTo("user@example.com");
verify(repository).save(argThat(u ->
!u.getPassword().equals("password") // Password was hashed
));
}
Testing Strategies
Test Double Selection Guide
// Decision tree for choosing test double:
//
// Is the dependency used in the test?
// NO → Use DUMMY
// YES ↓
//
// Does the test need to verify HOW it's called?
// NO → Use STUB or FAKE
// YES → Use MOCK or SPY
//
// For STUB vs FAKE:
// - Simple return value → STUB
// - Complex stateful behavior → FAKE
//
// For MOCK vs SPY:
// - Replace entire object → MOCK
// - Keep some real behavior → SPY
// Examples:
// DUMMY - parameter filler
@Test
void shouldValidateInput() {
Logger dummyLogger = mock(Logger.class); // Not used in test
Validator validator = new Validator(dummyLogger);
boolean valid = validator.isValid(input);
assertThat(valid).isTrue();
}
// STUB - simple return value
@Test
void shouldApplyDiscount() {
DiscountRepository stub = mock(DiscountRepository.class);
when(stub.getDiscount("VIP")).thenReturn(0.10);
// Just returns a value, no verification
}
// FAKE - complex stateful behavior
@Test
void shouldCacheResults() {
FakeCache cache = new FakeCache(); // Has add/get/evict logic
// Use real cache behavior in test
}
// MOCK - verify specific interaction
@Test
void shouldSendNotification() {
NotificationService mock = mock(NotificationService.class);
// ...
verify(mock).send(eq("user@example.com"), anyString());
}
// SPY - partial mock
@Test
void shouldCallParentMethod() {
MyService spy = spy(new MyService());
doReturn(true).when(spy).expensiveValidation();
// Real implementation for other methods
}
Mockito Best Practices
// ✅ Use ArgumentMatchers consistently
verify(service).process(eq("value1"), anyString()); // GOOD
// NOT: verify(service).process("value1", anyString()); // BAD - mixing
// ✅ Use ArgumentCaptor for complex verification
ArgumentCaptor<Order> orderCaptor = ArgumentCaptor.forClass(Order.class);
verify(repository).save(orderCaptor.capture());
Order savedOrder = orderCaptor.getValue();
assertThat(savedOrder.getStatus()).isEqualTo(OrderStatus.PENDING);
// ✅ Use @Mock annotation with MockitoExtension
@ExtendWith(MockitoExtension.class)
class ServiceTest {
@Mock private Repository repository;
@InjectMocks private Service service;
@Test
void test() { /* mocks automatically initialized */ }
}
// ✅ Reset mocks between tests (if needed)
@AfterEach
void tearDown() {
reset(emailService); // Only if state leaks between tests
}
References
- Mocks Aren’t Stubs (Martin Fowler)
- Mockito Documentation
- Test Double Patterns (Gerard Meszaros)
- Growing Object-Oriented Software, Guided by Tests