Mocking in Spring Boot Tests: When It Helps and When It Hurts
by Eric Hanson, Backend Developer at Clean Systems Consulting
The False Green
Here's a test that passes every time:
@ExtendWith(MockitoExtension.class)
class OrderServiceTest {
@Mock OrderRepository repository;
@Mock InventoryClient inventoryClient;
@InjectMocks OrderService orderService;
@Test
void placesOrderWhenItemInStock() {
when(inventoryClient.isAvailable("SKU-001", 2)).thenReturn(true);
when(repository.save(any())).thenAnswer(inv -> inv.getArgument(0));
var result = orderService.place(new OrderRequest("SKU-001", 2));
assertThat(result.getStatus()).isEqualTo(OrderStatus.CONFIRMED);
}
}
Six months later, InventoryClient.isAvailable is refactored to take a StockQuery object instead of two primitives. The signature change breaks the real integration. The Mockito stub still compiles — when(inventoryClient.isAvailable(any(), anyInt())) becomes a dead stub, Mockito's default return behavior kicks in, and the test either fails with a null pointer or silently returns false. Either way, you spend 20 minutes debugging a test failure that has nothing to do with the bug you were trying to catch.
That's the core problem with mocking: mocks don't enforce contracts. They enforce the contract you believed was true at the time you wrote the stub.
Where Mocking Actually Helps
The critique isn't that mocking is wrong. It's that teams apply it indiscriminately. There are clear cases where it's the right call.
Isolating Pure Business Logic
When you're testing a class that does computation — validation, calculation, transformation — and its collaborators are genuinely incidental, mock them. The test should be about the logic, not the infrastructure.
@ExtendWith(MockitoExtension.class)
class PricingEngineTest {
@Mock DiscountRepository discountRepository;
@InjectMocks PricingEngine pricingEngine;
@Test
void appliesVolumeDiscountForLargeOrders() {
when(discountRepository.findActiveFor(VOLUME_TIER)).thenReturn(
new Discount(VOLUME_TIER, BigDecimal.valueOf(0.15))
);
var price = pricingEngine.calculate(new OrderLine("SKU-X", 500, unitPrice(10.00)));
assertThat(price.getFinalAmount()).isEqualByComparingTo("4250.00");
}
}
The database is not relevant here. The discount lookup returning a Discount object is a reasonable contract assumption. This mock is justified.
Cutting Off Slow or Non-Deterministic External Dependencies
External HTTP calls, email gateways, payment processors — anything you don't own and can't reliably run in a test environment. Mock these at the boundary, not somewhere inside your service layer.
@ExtendWith(MockitoExtension.class)
class NotificationServiceTest {
@Mock EmailGateway emailGateway;
@InjectMocks NotificationService notificationService;
@Test
void sendsConfirmationOnOrderPlaced() {
notificationService.onOrderPlaced(new OrderPlacedEvent(42L, "user@example.com"));
verify(emailGateway).send(argThat(email ->
email.getTo().equals("user@example.com") &&
email.getSubject().contains("Order #42")
));
}
}
You're not testing whether the email gateway works. You're testing whether your service invokes it correctly. That's a legitimate isolation.
@MockBean in @WebMvcTest
Controller slice tests need the service layer stubbed. That's expected and correct — @WebMvcTest intentionally doesn't load your service beans.
@WebMvcTest(OrderController.class)
class OrderControllerTest {
@Autowired MockMvc mvc;
@MockBean OrderService orderService;
@Test
void returns404WhenOrderNotFound() throws Exception {
when(orderService.findById(99L)).thenThrow(new OrderNotFoundException(99L));
mvc.perform(get("/orders/99"))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.message").value("Order 99 not found"));
}
}
The mock here is structural, not lazy. @WebMvcTest can't wire the real OrderService — you're not testing the service, you're testing how the controller handles the exception. Mocking is the correct tool.
Where Mocking Hurts
Mocking What You Own at the Repository Layer
This is the most common mistake. Teams mock @Repository beans in service tests to avoid a database:
// Don't do this
@Mock OrderRepository orderRepository;
@Test
void findsOrdersByCustomer() {
when(orderRepository.findByCustomerId(7L)).thenReturn(List.of(anOrder()));
// ...
}
You're not testing whether findByCustomerId works. You're testing whether your service correctly calls a method that you pretended works. The actual query — the JPQL, the join fetch, the @Query annotation — is never executed. When that query has a bug, this test tells you nothing.
Use @DataJpaTest with a real database (Testcontainers for Postgres if dialect matters, H2 otherwise) for repository behavior. Then in service tests, trust that the repository works if it has its own coverage.
Mocking Across Module Boundaries You Don't Own
When your service depends on another team's internal SDK or a shared library, mocking it means you're committing to a behavioral contract that the other team can change without breaking your tests. This is a contract testing problem, not a mocking problem — consider consumer-driven contract tests with Pact instead of Mockito stubs for these boundaries.
@MockBean Pollution in @SpringBootTest
Every unique @MockBean in a @SpringBootTest class invalidates Spring's context cache and triggers a fresh context startup for that test class's configuration. On a project with 30 integration tests that each @MockBean different combinations of beans, you can easily end up starting 15–20 distinct Spring contexts per test run.
// Each of these triggers a new context if the MockBean set differs
@SpringBootTest
class OrderFlowTest {
@MockBean FraudCheckClient fraudCheckClient; // context A
}
@SpringBootTest
class PaymentFlowTest {
@MockBean FraudCheckClient fraudCheckClient;
@MockBean ShippingClient shippingClient; // context B — different set
}
Fix this by centralizing your @MockBean declarations in a shared base class. Any test that extends the same base with the same @MockBean set shares a context:
@SpringBootTest(webEnvironment = RANDOM_PORT)
@Testcontainers
public abstract class IntegrationTestBase {
// Declare ALL @MockBeans used anywhere in the integration suite here.
// Tests that need different mocks should use @WebMvcTest or unit tests instead.
@MockBean FraudCheckClient fraudCheckClient;
@MockBean ShippingClient shippingClient;
@MockBean NotificationGateway notificationGateway;
}
Yes, some tests get mocked beans they don't use. That's acceptable. The alternative is paying context startup cost repeatedly.
Verifying Internal Implementation Details
// This test is testing the wrong thing
verify(orderRepository).save(any(Order.class));
verify(eventPublisher).publish(any(OrderPlacedEvent.class));
verify(auditLogger).log(eq("ORDER_PLACED"), anyLong());
Three verify calls means three ways this test breaks when you refactor internals, even when the observable behavior stays correct. If the output of the method is a return value or a database state, assert that. Reserve verify for cases where the interaction itself is the contract — typically side effects like sending a notification or publishing an event that can't be observed through state.
The Litmus Test
Before writing a mock, ask: if this dependency's behavior changed, would my test catch it? If the answer is no — because the mock insulates the test from that change — ask whether the test is actually providing coverage or just providing comfort.
Mocks that enforce boundaries you don't control: useful. Mocks that substitute for real behavior you could test directly: technical debt accumulating in your test suite.
The goal isn't a test suite with 90% mock coverage. It's a test suite where green means the application works.