Every microservice calls other services. An Order Service calls a User Service to verify a customer exists. A Notification Service calls SendGrid to send emails. A Payment Service calls Stripe to charge a card. In production, those dependencies are real. In tests, they are a problem: they're slow, they require network access, they may have rate limits, and their state is outside your control.
The solution is to replace real dependencies with substitutes that you fully control. These substitutes have a collective name: test doubles — a term coined by Gerard Meszaros in xUnit Test Patterns. Understanding the five types, and knowing when to reach for each, is one of the sharpest skills a QA engineer can develop.
The five types of test double
Meszaros named five distinct types. Each has a specific job.
Dummy — passed to fill a required parameter but never actually used during the test. If a method signature requires a Logger and your test doesn't care about logging, you pass null or mock(Logger.class). That is a dummy.
Stub — returns pre-configured, canned responses to calls made during the test. A stub does not care how many times it was called or with what arguments. It just returns what you told it to return. Use a stub when you need data to flow into the system under test and you don't need to verify the interaction itself.
Mock — records every call it receives and lets you assert on those calls at the end of the test. A mock both returns configured responses and verifies that specific interactions happened — with the right arguments, the right number of times. Use a mock when the outgoing call is the behaviour you are testing (sending a notification, publishing an event, writing to an audit log).
Fake — a real, working implementation that takes a shortcut for testing purposes. An in-memory repository instead of a database. An in-process message queue instead of RabbitMQ. Fakes have actual logic; they just skip the infrastructure. Use a fake when you need stateful behaviour to persist across multiple calls within a test.
Spy — wraps the real implementation and records what was called, without changing what is returned. Less common in microservices testing — if you are running the real implementation you usually just use the real thing and observe side effects.
Concrete Java examples with Mockito
Stub
// Stub — returns data, doesn't verify HOW it was called
UserService userStub = mock(UserService.class);
when(userStub.getUser(42L)).thenReturn(new User(42L, "Alice", "alice@example.com"));
OrderService orderService = new OrderService(userStub, productRepository);
Order order = orderService.createOrder(42L, 100L, 1);
assertThat(order.getUserName()).isEqualTo("Alice");
// We don't verify getUser was called — that's not the test's concernThe test is checking that createOrder uses the user's name correctly. Whether getUser was called once or twice is irrelevant — that is not what this test is asserting. Calling verify here would tie the test to implementation details and make it brittle.
Mock
// Mock — verify the outgoing call happened with correct arguments
NotificationService mockNotification = mock(NotificationService.class);
OrderService orderService = new OrderService(userService, mockNotification);
orderService.placeOrder(new Order(42L, 100L, 1));
// Verify the notification was sent correctly
verify(mockNotification).sendOrderConfirmation(
eq(42L),
eq("alice@example.com"),
argThat(order -> order.getStatus().equals("CONFIRMED"))
);Here, the outgoing notification is the behaviour under test. A customer must receive a confirmation email when an order is placed. If sendOrderConfirmation is never called, or is called with a wrong email address, the test fails. That is the correct use of a mock.
Fake
// Fake — real logic, simplified implementation
public class InMemoryOrderRepository implements OrderRepository {
private final Map<Long, Order> store = new HashMap<>();
private long nextId = 1;
@Override
public Order save(Order order) {
order.setId(nextId++);
store.put(order.getId(), order);
return order;
}
@Override
public Optional<Order> findById(Long id) {
return Optional.ofNullable(store.get(id));
}
}A fake is useful when multiple test methods need to share state or when the behaviour of the dependency matters across several calls — saving an order and then retrieving it, for example. A Mockito stub can return a fixed value but cannot simulate the full save-then-find roundtrip without complex configuration. A fake does it naturally because it has real logic. The trade-off is that you now own and maintain that fake implementation.
Decision guide: which double to use
Choosing the wrong type is a common source of brittle tests. A practical heuristic:
- Use a Stub when you need data to flow into the system under test and you don't intend to verify the interaction. Keep tests focused: assert on outcomes, not on every method call.
- Use a Mock when the outgoing call is itself the behaviour being verified. Notifications, audit events, analytics pings, message queue publishes — if the test is checking that a side effect happened, reach for a mock.
- Use a Fake when you need stateful, multi-call behaviour within a test. In-memory repositories are the canonical example. Fakes are also useful when setting up Mockito stubs for every possible call would be more code than writing the fake itself.
- Use WireMock (covered in the next lesson) when the dependency is a separate HTTP service. WireMock stubs at the network level — it intercepts the actual HTTP call your service makes, not just the Java method call. That extra realism is what makes component tests so valuable.
Comparing the three you will use most
Stub
Returns canned responses
No interaction verification
Use for: incoming data
Example: UserService returns Alice
Library: Mockito when().thenReturn()
Mock
Returns responses AND records calls
Verifies calls happened correctly
Use for: outgoing side effects
Example: NotificationService was called
Library: Mockito verify()
Fake
Working implementation
Simplified (in-memory)
Use for: stateful behaviour
Example: InMemoryRepository
Trade-off: more code to maintain
Why over-mocking is the most common mistake in microservices testing
Mockito makes it trivially easy to mock everything. That ease becomes a trap. Consider a test that mocks the database repository: the test will pass even if your SQL is completely wrong, because the SQL never runs. You've tested that your service class calls repository.save() — not that the order actually persists. When the bug is in the SQL, the mock test keeps going green.
The same problem applies at a higher level. If every component test stubs all its dependencies with Mockito mocks instead of WireMock + Testcontainers, the test never exercises the HTTP client configuration, the JSON serialisation, the database schema, or the Flyway migrations. You end up with tests that pass locally and fail in production.
A healthy test suite for a microservice looks like this:
- Unit tests use Mockito stubs and mocks to test individual classes in isolation — fast and focused.
- Component tests use WireMock for HTTP dependencies and Testcontainers for the database — verifying the full internal behaviour of the service.
- Contract tests (Pact) verify that your WireMock stubs match what the real services actually return.
Test doubles are powerful precisely because they are targeted. Use them at the right level and for the right reason.
⚠️ Common mistakes
- Using a mock when a stub is enough. Adding
verifycalls to every test ties your tests to implementation details. If you refactor how many times a method is called internally without changing the observable behaviour, every mock-heavy test breaks for no good reason. Only verify when the call itself is the observable side effect you are testing. - Forgetting to reset mock state between tests. Mockito mocks created with
@Mockare fresh per test method in JUnit 5. But if you create them manually or share them as static fields, recorded interactions from one test bleed into the next. UseMockitoAnnotations.openMocks(this)in@BeforeEachor annotate the class with@ExtendWith(MockitoExtension.class). - Building fakes that are too smart. A fake should be the simplest implementation that passes the tests. If your
InMemoryOrderRepositorystarts implementing pagination, sorting, and soft deletes, it becomes a second codebase to maintain and test. Keep fakes minimal and delete them the moment you can switch to Testcontainers for the same coverage.
🎯 Practice task
Solidify your understanding by working through three implementations for the same scenario: an OrderService that calls a UserService and a NotificationService.
- Write a unit test using a Stub for
UserService. Configure it to return a known user, callorderService.createOrder(userId, productId, quantity), and assert on the returnedOrderobject. Do not add anyverifycalls. - Write a unit test using a Mock for
NotificationService. CallorderService.placeOrder(order)and useverifyto assert thatsendOrderConfirmationwas called with the correct user ID and email address. - Implement an
InMemoryOrderRepositoryFake. Write a test that saves two orders, retrieves each by ID, and asserts both are returned correctly. Confirm the test would catch a bug wherefindByIdalways returns the first saved order. - Review the three tests. For each, ask: "Would this test catch a real production bug that the others would miss?" Identify what each test covers that the others do not.
- Stretch: replace the
UserServicestub in step 1 with a WireMock stub that returns the same JSON body over HTTP. Run both tests and compare the execution time. Note the trade-off between speed and realism.