Skip to content
Home / Skills / Tdd / Test Doubles
TD

Test Doubles

Tdd patterns v1.0.0

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

AspectStubMock
PurposeProvide dataVerify behavior
VerificationState verification (assert output)Behavior verification (verify calls)
Test focusWhat SUT returnsHow SUT interacts
CouplingLow (returns values)Higher (knows about calls)
Failure modeWrong outputWrong interaction

When to Use Each Type

Test DoubleUse WhenExample Scenario
DummyNeed to fill parameter, but value doesn’t matterLogger passed but not used in test
StubNeed to provide data to SUTReturn user from repository
SpyWant to verify interactions without full mockTrack how many times cache was hit
MockNeed to verify specific interactionsEnsure email sent with correct parameters
FakeNeed realistic behavior for complex dependencyIn-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