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.

Scale Your Backend - Need an Experienced Backend Developer?

We provide backend engineers who join your team as contractors to help build, improve, and scale your backend systems.

We focus on clean backend design, clear documentation, and systems that remain reliable as products grow. Our goal is to strengthen your team and deliver backend systems that are easy to operate and maintain.

We work from our own development environments and support teams across US, EU, and APAC timezones. Our workflow emphasizes documentation and asynchronous collaboration to keep development efficient and focused.

  • Production Backend Experience. Experience building and maintaining backend systems, APIs, and databases used in production.
  • Scalable Architecture. Design backend systems that stay reliable as your product and traffic grow.
  • Contractor Friendly. Flexible engagement for short projects, long-term support, or extra help during releases.
  • Focus on Backend Reliability. Improve API performance, database stability, and overall backend reliability.
  • Documentation-Driven Development. Development guided by clear documentation so teams stay aligned and work efficiently.
  • Domain-Driven Design. Design backend systems around real business processes and product needs.

Tell us about your project

Our offices

  • Copenhagen
    1 Carlsberg Gate
    1260, København, Denmark
  • Magelang
    12 Jalan Bligo
    56485, Magelang, Indonesia

More articles

New Zealand's Capital Has a Tech Talent Drain Problem — Async Remote Contractors Are the Practical Fix

Wellington keeps producing engineers it can't fully retain. Startups that understand this build around it rather than fight it.

Read more

Testing Spring Boot Applications With Testcontainers — Real Databases, Real Brokers, Real Tests

H2 in-memory databases don't catch PostgreSQL-specific bugs. Mocked message brokers don't verify producer and consumer integration. Testcontainers runs real infrastructure in Docker during tests, eliminating the gap between what passes locally and what breaks in production.

Read more

Spring Boot Request Processing Overhead — Filter Chains, Serialization, and What's Worth Measuring

Spring Boot's request processing pipeline adds overhead before and after your business logic runs. Most of it is negligible. Some of it isn't. Here is how to measure each layer and what actually warrants optimization.

Read more

Version Control Isn’t Optional: How Bureaucracy Breaks Developer Workflow

Every developer has been there—staring at a stack of approval emails while code rots locally. Bureaucracy can grind productivity to a halt if version control isn’t treated as a priority.

Read more