Junior 10 min · March 09, 2026

Spring Boot Testing — Flaky CI/CD from Mockito Static Leaks

Mockito static mock leaks from missing try-with-resources cause random CI failures.

N
Naren Founder & Principal Engineer

20+ years shipping production Java in banking & fintech. Lessons pulled from things that broke in production.

Follow
Production
production tested
May 24, 2026
last updated
1,554
articles · all by Naren
 ● Production Incident 🔎 Debug Guide ⚙ Triage Commands
Quick Answer
  • JUnit 5 runs tests; Mockito creates fake dependencies so you test one class in isolation
  • @Mock works without Spring context; @MockBean replaces beans inside the ApplicationContext
  • Test slices (@WebMvcTest, @DataJpaTest) load only the layer you need — 10x faster than full context
  • Static mocks from MockedStatic must be closed with try-with-resources — forgetting this causes flaky CI failures
  • Use @MockitoSettings(strictness = STRICT_STUBS) to catch unnecessary stubbing before it becomes dead test code
  • Biggest mistake: using @SpringBootTest everywhere and watching your CI pipeline crawl to a stop
✦ Definition~90s read
What is Spring Boot Testing with JUnit and Mockito?

Spring Boot testing with JUnit and Mockito is the de facto standard for verifying that your application behaves correctly without deploying to a real environment. JUnit provides the test runner and lifecycle hooks, Mockito handles mock creation and behavior verification, and Spring Boot’s test slices (like @WebMvcTest and @DataJpaTest) auto-configure only the beans needed for a specific layer.

Think of Spring Boot Testing with JUnit and Mockito as a powerful tool in your developer toolkit.

This combination lets you isolate units, test controllers against mocked services, and validate database interactions in-memory — all within seconds. Without it, you’re either deploying to staging for every change or writing brittle integration tests that take minutes to run and fail randomly due to shared state.

The ecosystem offers alternatives: Testcontainers for real database integration, WireMock for external HTTP stubs, and ArchUnit for architectural rules. But for the vast majority of service-layer and controller tests, JUnit + Mockito + Spring Boot’s test slices is the sweet spot.

The key tradeoff is speed vs. fidelity — @SpringBootTest loads the full context (~30-60 seconds) while @WebMvcTest starts only the web layer (~5-10 seconds). Use the pyramid: 70% unit tests (fast, no Spring context), 20% slice tests (focused Spring context), 10% full integration tests.

Common pitfalls include Mockito static mocks leaking across test classes (causing flaky CI builds), forgetting to reset mocks between tests, and over-mocking to the point where tests pass but production fails. The static mock leak issue is particularly insidious — if you mock a static method in one test and don’t close the mock, subsequent tests inherit that behavior, leading to non-deterministic failures that only surface in CI.

Tools like Mockito’s MockedStatic try-with-resources pattern and JUnit’s @ExtendWith(MockitoExtension.class) help, but you must ensure every static mock is scoped to a single test method.

Plain-English First

Think of Spring Boot Testing with JUnit and Mockito as a powerful tool in your developer toolkit. Once you understand what it does and when to reach for it, everything clicks together. Imagine you are building a car. JUnit is like the diagnostic machine that checks if the engine starts correctly. Mockito, however, is like a fake battery or a simulated fuel tank you use during that test. Instead of building a whole gas station just to see if the engine turns over, you use Mockito to 'pretend' there is fuel. This allows you to test the engine in isolation without worrying about the rest of the car being finished.

Spring Boot Testing with JUnit and Mockito is a fundamental concept in Java development. Understanding it will make you a more effective developer by ensuring your code is resilient, maintainable, and bug-free before it ever reaches production. In professional environments, testing isn't an afterthought — it's the living documentation of your system's behaviour.

We will explore the critical differences between Unit Testing (testing a single class in isolation) and Integration Testing (testing how components work together within the Spring context using test slices). We'll look at ArgumentCaptors, parameterized tests, void method testing, and the @DataJpaTest slice that most guides skip entirely.

By the end, you'll have both the conceptual understanding and practical code examples to write tests with confidence — and significantly reduce the bugs that make it to production. And if you've ever wondered why your CI tests pass locally but fail randomly, the answer is often a leaked static mock.

Why Spring Boot Testing with JUnit and Mockito Is Not Optional

Spring Boot testing with JUnit and Mockito is the standard approach for writing unit and integration tests in Spring applications. JUnit provides the test lifecycle and assertions, while Mockito handles mocking of dependencies — replacing real beans with controlled doubles. The core mechanic is that Mockito can mock both interfaces and concrete classes, and since Spring Boot 2.x, it integrates seamlessly via @MockBean and @SpyBean annotations, which inject mocks into the ApplicationContext.

In practice, you write a test class annotated with @SpringBootTest or @WebMvcTest, then use @MockBean to replace a real service or repository with a mock. Mockito’s when().thenReturn() defines stub behavior, and verify() checks interactions. This gives you deterministic, fast tests that isolate the layer under test — a controller, service, or repository — without needing a full database or external API. The key property is that mocks are reset between tests by default, but static mocks (via Mockito.mockStatic()) persist across the JVM unless explicitly closed, which is where flaky CI/CD originates.

Use this stack for any Spring Boot service where you need fast, reliable feedback during development and CI. It’s not optional for production-grade systems: without it, you either write slow integration tests that hit real databases, or you skip testing altogether. Real teams rely on this trio to catch regressions in minutes, not hours — but only if they manage Mockito’s static mocking lifecycle correctly.

Static Mocking Leaks
Mockito.mockStatic() registers a static mock for the entire JVM — if you forget to close it in @AfterEach, it leaks to other tests, causing spurious failures in CI.
Production Insight
A payment service team saw 30% of CI builds fail randomly — the root cause was a Mockito.mockStatic() on a utility class that wasn't closed in @AfterEach, corrupting subsequent tests.
The exact symptom: java.lang.IllegalStateException: static mocking is already registered in the active mock maker — or worse, silent wrong behavior when the mock persists into a test that expects the real implementation.
Rule: always wrap static mocks in a try-with-resources or close them in @AfterEach — never rely on test order or cleanup from other tests.
Key Takeaway
Mockito.mockStatic() must be closed in the same test method or @AfterEach — it leaks across the JVM otherwise.
Use @MockBean for Spring beans, not static mocks — static mocks are for utility classes only, and they require explicit lifecycle management.
Flaky CI from mock leaks is a design smell — prefer dependency injection over static methods to avoid the problem entirely.
Spring Boot Testing: Mockito Static Leaks in CI/CD THECODEFORGE.IO Spring Boot Testing: Mockito Static Leaks in CI/CD Flow from test selection to common pitfalls and fixes Test Slice Selection @SpringBootTest vs @WebMvcTest vs @DataJpaTest Mockito Static Leaks Unclosed static mocks cause flaky CI/CD MockMvc & jsonPath Validate status, content, and structure ArgumentCaptors Verify what gets passed to dependencies Void Method Testing doThrow, doAnswer, and verify Persistence Layer Test @DataJpaTest for isolated DB testing ⚠ Mockito static mocks not closed after test Use @MockitoSettings or close in @AfterEach to avoid leaks THECODEFORGE.IO
thecodeforge.io
Spring Boot Testing: Mockito Static Leaks in CI/CD
Spring Boot Testing Junit Mockito

Choosing the Right Test Slice: @SpringBootTest vs @WebMvcTest vs @DataJpaTest

One of the most common questions when starting with Spring Boot testing is: which annotation should I use? The answer depends on what layer of your application you're testing. Each slice loads a different subset of the Spring context, offering a trade-off between speed and scope.

@WebMvcTest loads only the web layer: the specific controller you specify, Spring MVC infrastructure (DispatcherServlet, converters, exception handlers), and MockMvc. It does NOT load service, repository, or security beans. Use it to test request mapping, validation, and JSON serialization.

@DataJpaTest loads only the JPA layer: Hibernate, the embedded database, and your repository beans. It does NOT load any web or service beans. Each test runs in a transaction that is rolled back automatically after the test method. Use it to verify derived queries, custom @Query statements, and entity mappings.

@SpringBootTest loads the entire application context — all beans in your configuration. This is the slowest option but also the most complete. Reserve it for end-to-end scenarios that must exercise the full stack (e.g., testing an HTTP request that flows through the controller, service, and database).

Feature@SpringBootTest@WebMvcTest@DataJpaTest
Context loadedFull applicationWeb layer onlyJPA/repository layer only
Typical speed5-30 seconds1-3 seconds~2 seconds
Primary use caseEnd-to-end flows, security testingController/API testingRepository/query testing
What's mockedNone (or @MockBean)Services via @MockBeanNothing (real DB)
DatabaseReal or in-memoryNot loadedIn-memory H2 or Testcontainers
MockMvcAuto-configured by @AutoConfigureMockMvcAuto-configuredNot available

Production insight: On a real project with 200+ tests, switching from @SpringBootTest to test slices for controller and repository tests cut the CI build time from 18 minutes to 3 minutes. The only tests that remained @SpringBootTest were those that verified the full request flow, security filters, and Flyway migrations. That focused use saved over 10 developer-hours per week waiting for builds.

Key takeaway: Always choose the narrowest slice that gives you the confidence you need. @SpringBootTest is the hammer — use it only when you really need to drive a nail.@WebMvcTest and @DataJpaTest are your everyday screwdrivers.

Production Insight
On a real project with 200+ tests, switching from @SpringBootTest to test slices cut CI build time from 18 minutes to 3 minutes.
The only tests that remained @SpringBootTest were those that verified full request flow, security filters, and Flyway migrations.
This focused use saved over 10 developer-hours per week waiting for builds.
Key Takeaway
Always choose the narrowest slice that gives you the confidence you need.
@SpringBootTest is the hammer — use it only when you really need to drive a nail.
@WebMvcTest and @DataJpaTest are your everyday screwdrivers.
When to Use Each Slice
IfNeed to test controller endpoints with MockMvc
UseUse @WebMvcTest
IfNeed to test repository queries and entity mappings
UseUse @DataJpaTest
IfNeed to test full request flow across all layers
UseUse @SpringBootTest (cautiously)

The Testing Pyramid: Visualising Your Test Strategy

The testing pyramid is a classic model that helps you decide how to distribute your testing effort across different levels. The idea is simple: write many fast, isolated unit tests at the base, fewer integration tests in the middle, and only a handful of slow, end-to-end (E2E) tests at the top.

Unit tests (70-80%): Use @ExtendWith(MockitoExtension.class) and mock every dependency. These tests run in milliseconds and cover business logic, validation, and edge cases. If a bug can be caught with a unit test, it should be.

Integration tests (15-20%): Use @WebMvcTest and @DataJpaTest. These tests load part of the Spring context and verify that your controller mappings or repository queries work correctly. They are slower than unit tests but still run in seconds.

E2E tests (5-10%): Use @SpringBootTest with Testcontainers. These tests simulate real user flows and verify that all layers work together. They are slow and brittle — limit them to critical paths like login, order placement, or report generation.

Production Insight
Teams that invert the pyramid suffer the longest CI times and the most flaky failures.
Every time a test fails due to network timeouts or external service instability, trust erodes.
Invert to unit-test-heavy and your pipeline becomes reliable again.
Key Takeaway
Aim for 70-80% unit tests, 15-20% integration tests, and 5-10% E2E tests.
The pyramid is a guide, not a rule, but it will keep your CI pipeline fast and your team confident.
Test Type Selection by Layer
IfTesting business logic in a service class
UseUnit test (MockitoExtension)
IfTesting controller request/response
UseIntegration test (@WebMvcTest)
IfTesting repository queries
UseIntegration test (@DataJpaTest)
IfTesting full user flow
UseE2E test (@SpringBootTest + Testcontainers)
Testing Pyramid
Testing PyramidSpringBootTest plusTestcontainersWebMvcTest and DataJpaTestMockitoExtensionE2E Tests 5 to 10 percentIntegration Tests 15 to 20percentUnit Tests 70 to 80 percentSlow and broad coverageMedium speed focused sliceFast and isolated

Common Mistakes and How to Avoid Them

When learning Spring Boot Testing with JUnit and Mockito, most developers hit the same set of gotchas. The biggest is using @SpringBootTest for every single test. While powerful, @SpringBootTest starts the entire application context — which is slow and unnecessary for simple logic tests.

For service-level testing, use @ExtendWith(MockitoExtension.class) to keep your pipeline fast. For controller testing, use @WebMvcTest, which only loads the web layer. Another mistake that's harder to catch: forgetting to verify that a mock was actually called. Tests that don't verify interactions can pass green even when the underlying logic is short-circuited completely.

The example below demonstrates @WebMvcTest with BDD-style Mockito. The given(...).willReturn(...) syntax from BDDMockito is functionally identical to when(...).thenReturn(...) but reads more naturally in tests structured around Arrange-Act-Assert. Both styles are valid — pick one and use it consistently across your test suite. Mixing them in the same class is the kind of inconsistency that erodes readability over time.

src/test/java/io/thecodeforge/controller/ProductControllerTest.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
package io.thecodeforge.controller;

import io.thecodeforge.model.Product;
import io.thecodeforge.service.ProductService;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;

import java.math.BigDecimal;
import java.util.NoSuchElementException;

import static org.mockito.BDDMockito.given;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

/**
 * @WebMvcTest loads only the web layer:
 *   - The specified controller
 *   - Spring MVC infrastructure and MockMvc
 *   - Jackson for JSON serialisation
 *
 * It does NOT load:
 *   - Service beans (provide via @MockBean)
 *   - Repository beans
 *   - Security configuration by default
 *
 * This makes it 10-50x faster than @SpringBootTest for controller testing.
 */
@WebMvcTest(ProductController.class)
class ProductControllerTest {

    @Autowired
    private MockMvc mockMvc;

    // @MockBean replaces the real ProductService bean in the Spring context
    // @Mock would not work here — there is no @InjectMocks in a Spring slice test
    @MockBean
    private ProductService productService;

    @Test
    void shouldReturnOkStatusAndProductJson() throws Exception {
        // Arrange — BDD style: given a product exists
        Product forgeItem = new Product(1L, "Cloud Core", new BigDecimal("45.00"));
        given(productService.getProductById(1L)).willReturn(forgeItem);

        // Act + Assert
        mockMvc.perform(get("/api/v1/products/1")
                       .accept(MediaType.APPLICATION_JSON))
               .andExpect(status().isOk())
               .andExpect(content().contentType(MediaType.APPLICATION_JSON))
               .andExpect(jsonPath("$.name").value("Cloud Core"))
               .andExpect(jsonPath("$.price").value(45.00));
    }

    @Test
    void shouldReturn404WhenProductDoesNotExist() throws Exception {
        // Arrange — stub the failure path
        given(productService.getProductById(99L))
                .willThrow(new NoSuchElementException("Product not found: 99"));

        // Act + Assert — verifies that the controller maps the exception to 404
        // This test only passes if GlobalExceptionHandler is loaded in the slice
        mockMvc.perform(get("/api/v1/products/99")
                       .accept(MediaType.APPLICATION_JSON))
               .andExpect(status().isNotFound());
    }
}
Output
MockHttpServletResponse:
Status = 200
Content type = application/json
Body = {"id":1,"name":"Cloud Core","price":45.00}
MockHttpServletResponse:
Status = 404
Content type = application/json
Test Scope Mental Model
  • Wide lens (@SpringBootTest): sees everything, slow to focus, use for critical integration paths
  • Medium lens (@WebMvcTest): sees controllers and web config, fast enough for endpoint testing
  • Macro lens (MockitoExtension): sees one class in isolation, millisecond speed, use for business logic
  • Always choose the narrowest lens that lets you verify what you need — wider is not better
Production Insight
Teams using @SpringBootTest for all tests report 15-30 minute CI pipeline times.
Switching service tests to MockitoExtension typically cuts build time by 60-80%.
Developers stop running slow tests locally, pushing bugs directly to CI where the feedback loop is 10 minutes instead of 10 seconds.
That gap — local test run vs CI failure — is where production bugs incubate.
Key Takeaway
Using @SpringBootTest for everything is the single biggest testing performance killer.
Test slices give you 80% of the confidence at 10% of the cost — learn them and use them by default.
Always verify() mock interactions — a passing test without verification proves nothing about your logic.
When to Use @SpringBootTest vs Test Slices
IfTesting a single service class with mocked dependencies
UseUse @ExtendWith(MockitoExtension.class) — no Spring context needed
IfTesting controller request/response mapping
UseUse @WebMvcTest — loads only web layer, mock services with @MockBean
IfVerifying security rules across multiple endpoints
UseUse @SpringBootTest with @AutoConfigureMockMvc — security filter chain must be active
IfTesting database migration scripts or complex JPA queries
UseUse @DataJpaTest with Testcontainers — real database, isolated transaction

MockMvc jsonPath Assertions: Validating Status, Content Type, and Field Values

Once you have your MockMvc test set up (either with @WebMvcTest or @SpringBootTest + @AutoConfigureMockMvc), you need to assert on the HTTP response. The andExpect() method chain provides a rich set of matchers. The three most common categories are:

1. Status codes.andExpect(status().isOk()), .andExpect(status().isNotFound()), .andExpect(status().is4xxClientError())

2. Content type.andExpect(content().contentType(MediaType.APPLICATION_JSON)), .andExpect(content().contentType("application/json"))

3. JSON field values.andExpect(jsonPath("$.fieldName").value(expected))

The jsonPath method uses the JsonPath expression language, which is far more powerful than simple string matching. You can check nested objects, arrays, and even apply filters.

Common patterns
  • $.id — top-level field
  • $.address.city — nested field
  • $.items[0].name — first element of an array
  • $.items.length() — array length
  • $.items[?(@.price > 50)] — filter (advanced)
src/test/java/io/thecodeforge/controller/ProductControllerJsonPathTest.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
package io.thecodeforge.controller;

import io.thecodeforge.model.Product;
import io.thecodeforge.service.ProductService;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;

import java.math.BigDecimal;
import java.util.List;

import static org.mockito.BDDMockito.given;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

@WebMvcTest(ProductController.class)
class ProductControllerJsonPathTest {

    @Autowired
    private MockMvc mockMvc;

    @MockBean
    private ProductService productService;

    @Test
    void shouldReturnProductWithJsonPathAssertions() throws Exception {
        // Arrange
        Product product = new Product(1L, "Forge Blade", new BigDecimal("99.99"));
        given(productService.getProductById(1L)).willReturn(product);

        // Act & Assert
        mockMvc.perform(get("/api/v1/products/1")
                       .accept(MediaType.APPLICATION_JSON))
               .andExpect(status().isOk())                          // Status code assertion
               .andExpect(content().contentType(MediaType.APPLICATION_JSON)) // Content type assertion
               .andExpect(jsonPath("$.id").value(1))                // Top-level field
               .andExpect(jsonPath("$.name").value("Forge Blade")) // String field
               .andExpect(jsonPath("$.price").value(99.99));        // Numeric field
    }

    @Test
    void shouldReturnListOfProducts() throws Exception {
        // Arrange
        List<Product> products = List.of(
            new Product(1L, "Forge Blade", new BigDecimal("99.99")),
            new Product(2L, "Cloud Core", new BigDecimal("45.00"))
        );
        given(productService.getAllProducts()).willReturn(products);

        // Act & Assert
        mockMvc.perform(get("/api/v1/products")
                       .accept(MediaType.APPLICATION_JSON))
               .andExpect(status().isOk())
               .andExpect(jsonPath("$.length()").value(2))          // Array length
               .andExpect(jsonPath("$[0].name").value("Forge Blade")) // First element
               .andExpect(jsonPath("$[1].price").value(45.00));      // Second element price
    }

    @Test
    void shouldReturnErrorJsonWhenNotFound() throws Exception {
        // Arrange
        given(productService.getProductById(99L))
                .willThrow(new RuntimeException("Not found"));

        // Act & Assert
        mockMvc.perform(get("/api/v1/products/99")
                       .accept(MediaType.APPLICATION_JSON))
               .andExpect(status().isInternalServerError())
               .andExpect(jsonPath("$.error").exists());           // Check field exists, but not value
    }
}
Output
BUILD SUCCESSFUL in 1.2s
3 tests passed, 0 failed
All jsonPath assertions validated correctly.
Note: The last test expects $.error to exist — your error response must include an 'error' field.
Production Insight
jsonPath assertions are the backbone of contract testing in microservices.
When you change an API response, your tests should fail unless you intentionally update the expected JSON shape.
This prevents accidental breaking changes from reaching consumers.
Key Takeaway
Use jsonPath for precise validation of JSON responses.
Combine status, content type, and field assertions to create a tight contract for your API.
Prefer explicit values over fuzzy matchers unless you have a strong reason.

ArgumentCaptors — Verifying What Gets Passed to Dependencies

Sometimes verify() is not enough. You need to confirm not just that a method was called, but what it was called with — specifically when your code transforms data before handing it to a dependency.

A concrete example: a service receives a CreateProductRequest DTO, maps it into a Product entity, sets the creation timestamp, and calls repository.save(). A plain verify(repository.save(any())) tells you save was called. It says nothing about whether the timestamp was set, whether the name was trimmed, or whether the price was correctly parsed. An ArgumentCaptor captures the exact object that was passed and lets you inspect it directly.

This is where I see a lot of senior engineers still writing weak tests. They verify the method call but never check the argument. The transformation logic — often where bugs live — goes completely untested. ArgumentCaptors close that gap.

One important ordering detail that trips people up constantly: you must call verify(mock).method(captor.capture()) before calling captor.getValue(). The captor populates during the verify call — not before it.

src/test/java/io/thecodeforge/service/ProductServiceCaptorTest.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
package io.thecodeforge.service;

import io.thecodeforge.model.Product;
import io.thecodeforge.repository.ProductRepository;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Captor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;

import java.math.BigDecimal;

import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.STRICT_STUBS)
class ProductServiceCaptorTest {

    @Mock
    private ProductRepository repository;

    @InjectMocks
    private ProductService productService;

    // Declare the captor as a field — @Captor initializes it via MockitoExtension
    @Captor
    private ArgumentCaptor<Product> productCaptor;

    @Test
    @org.junit.jupiter.api.DisplayName("save: persists product with correct name and price")
    void shouldPersistProductWithCorrectFields() {
        // Arrange
        Product input = new Product(null, "  Forge Blade  ", new BigDecimal("99.99"));
        Product savedProduct = new Product(1L, "Forge Blade", new BigDecimal("99.99"));
        when(repository.save(org.mockito.ArgumentMatchers.any(Product.class)))
                .thenReturn(savedProduct);

        // Act
        productService.save(input);

        // Assert — capture what actually reached the repository
        // verify() MUST come before getValue() — the captor populates during verify
        verify(repository).save(productCaptor.capture());
        Product captured = productCaptor.getValue();

        // Now inspect the object that the service passed to the repository
        // This tests the transformation logic, not just that save() was called
        assertThat(captured.name()).isEqualTo("Forge Blade");
        assertThat(captured.price()).isEqualByComparingTo("99.99");
    }

    @Test
    @org.junit.jupiter.api.DisplayName("save: ID is null on the entity passed to repository")
    void shouldPassNullIdToRepository() {
        // Arrange — ID generation is the database's job, not the service's
        Product input = new Product(null, "Forge Blade", new BigDecimal("99.99"));
        when(repository.save(org.mockito.ArgumentMatchers.any(Product.class)))
                .thenReturn(new Product(1L, "Forge Blade", new BigDecimal("99.99")));

        // Act
        productService.save(input);

        // Assert
        verify(repository).save(productCaptor.capture());
        assertThat(productCaptor.getValue().id()).isNull();
    }
}
Output
BUILD SUCCESSFUL in 0.9s
2 tests passed, 0 failed
ProductServiceCaptorTest > shouldPersistProductWithCorrectFields() PASSED
ProductServiceCaptorTest > shouldPassNullIdToRepository() PASSED
verify() vs ArgumentCaptor: When Each Is Enough
  • verify(mock).method(anyLong()) — confirms the interaction happened, argument is irrelevant
  • verify(mock).method(eq(42L)) — confirms interaction with a specific, known argument
  • ArgumentCaptor — confirms the exact transformed object that reached the dependency
  • Use ArgumentCaptor when your code transforms data before passing it — that transformation is the logic you need to test
Production Insight
The most common class of bug that verify()-without-captor misses: data transformation errors.
A service maps a DTO to an entity, sets an incorrect field name, or fails to trim whitespace — verify(repo.save(any())) passes green.
ArgumentCaptor catches it by letting you assert on the actual object that reached the database layer.
Key Takeaway
ArgumentCaptors let you verify not just that a dependency was called, but what it was called with.
They are essential for testing transformation logic — mapping, trimming, defaulting — that happens inside the method under test.
Always call verify() before captor.getValue() — the captor populates during the verify call, not before.
Choosing Between verify(), eq(), and ArgumentCaptor
IfYou only need to confirm a method was called once
UseUse verify(mock).method() — simplest, clearest
IfYou need to confirm it was called with a specific literal value
UseUse verify(mock).method(eq(expectedValue)) or verify(mock).method(expectedValue) directly
IfThe code transforms the input before passing it to the dependency
UseUse ArgumentCaptor to capture and inspect the transformed object
IfThe method is called multiple times and you need to inspect each call
UseUse captor.getAllValues() to get a List of every captured argument in call order

Testing Void Methods — doThrow, doAnswer, and Verifying Side Effects

Void methods have no return value to assert against. The instinct for most developers is to assume they're trivially testable with just verify() — and for simple delegation, that's true. But void methods often own side effects: sending an email, publishing an event, updating a status field. Testing those properly requires a different set of Mockito tools.

The pattern breakdown
  • verify() — confirms the method was called with expected arguments. Sufficient when the test is 'did this call happen?'
  • doThrow() — makes a void method throw when called. Used to test how your code handles failures in a dependency.
  • doNothing() — explicitly documents that a void method should have no effect. Mockito voids do nothing by default, but making it explicit is clearer intent.
  • doAnswer() — custom behaviour: inspect or modify arguments at the time of the call. Useful when the method modifies a passed-in object as a side effect.

One mistake I see constantly: developers try to use when(mock.voidMethod()).thenReturn(...) on a void method. The compiler accepts it initially (because when() can be chained), but Mockito throws at runtime. The correct syntax for void methods is always do*().when(mock).method().

src/test/java/io/thecodeforge/service/NotificationServiceTest.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
package io.thecodeforge.service;

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;

import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.*;

/**
 * NotificationService.java (referenced by tests below)
 *
 * @Service
 * public class NotificationService {
 *     private final EmailClient emailClient;
 *
 *     public NotificationService(EmailClient emailClient) {
 *         this.emailClient = emailClient;
 *     }
 *
 *     public void notifyUser(String email, String message) {\n *         try {\n *             emailClient.send(email, message);\n *         } catch (EmailDeliveryException e) {
 *             throw new RuntimeException("Notification failed for: " + email, e);
 *         }
 *     }
 * }
 */
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.STRICT_STUBS)
class NotificationServiceTest {

    @Mock
    private EmailClient emailClient; // EmailClient.send() is a void method

    @InjectMocks
    private NotificationService notificationService;

    @Test
    @DisplayName("notifyUser: delegates to emailClient with correct arguments")
    void shouldDelegateToEmailClientWithCorrectArgs() {
        // Arrange — void methods do nothing by default; doNothing() makes intent explicit
        doNothing().when(emailClient).send("user@forge.io", "Welcome!");

        // Act
        notificationService.notifyUser("user@forge.io", "Welcome!");

        // Assert — verify the side effect occurred with the right arguments
        verify(emailClient, times(1)).send("user@forge.io", "Welcome!");
    }

    @Test
    @DisplayName("notifyUser: wraps EmailDeliveryException in RuntimeException")
    void shouldWrapEmailClientExceptionInRuntimeException() {
        // Arrange — use doThrow().when() syntax for void methods
        // NOT when(emailClient.send(...)).thenThrow() — that syntax does not work on void methods
        doThrow(new EmailDeliveryException("SMTP connection refused"))
                .when(emailClient).send(anyString(), anyString());

        // Act + Assert — verify that the service wraps and rethrows correctly
        assertThatThrownBy(() -> notificationService.notifyUser("user@forge.io", "Welcome!"))
                .isInstanceOf(RuntimeException.class)
                .hasMessageContaining("Notification failed for: user@forge.io")
                .hasCauseInstanceOf(EmailDeliveryException.class);
    }

    @Test
    @DisplayName("notifyUser: does not call emailClient when email is blank")
    void shouldNotCallEmailClientWhenEmailIsBlank() {
        // Act — call with empty email (assumes service has a guard clause)
        // notificationService.notifyUser("", "Welcome!");

        // If the guard clause exists, emailClient.send() should never be called
        // verifyNoInteractions() confirms the mock was untouched
        // This test would be enabled once the guard clause is implemented
        verifyNoInteractions(emailClient);
    }
}
Output
BUILD SUCCESSFUL in 0.7s
2 tests passed, 0 failed
NotificationServiceTest > shouldDelegateToEmailClientWithCorrectArgs() PASSED
NotificationServiceTest > shouldWrapEmailClientExceptionInRuntimeException() PASSED
The Void Method Testing Pattern
  • doNothing().when(mock).voidMethod() — explicit no-op; use when documenting intent matters
  • doThrow(ex).when(mock).voidMethod() — simulate failure in a dependency's void method
  • verify(mock).voidMethod(args) — confirm the delegation happened with correct arguments
  • verifyNoInteractions(mock) — confirm a dependency was never touched at all
Production Insight
The most dangerous void method is the one nobody tested at all.
A notification service that silently swallows an exception — or never calls the email client — will report success to the caller while the user receives nothing.
Test both 'the right call happened' and 'the failure is handled correctly.'
Key Takeaway
Void methods are verified by side effects and interactions, not return values.
Always use doThrow().when() syntax for void methods — when().thenThrow() does not work and throws at runtime.
verifyNoInteractions() confirms a dependency was completely untouched — use it for guard clause tests.
Void Method Testing Patterns
IfConfirming a dependency's void method was called with specific arguments
UseUse verify(mock).voidMethod(expectedArg)
IfTesting how your code responds when a dependency's void method throws
UseUse doThrow(exception).when(mock).voidMethod() — never use when() syntax on void methods
IfConfirming a dependency was never called
UseUse verifyNoInteractions(mock) — clearer than verify(mock, never()).method()
IfA void method modifies a passed-in argument as a side effect
UseUse doAnswer(invocation -> { ... }).when(mock).voidMethod() to inspect or mutate the argument

@DataJpaTest — Testing the Persistence Layer in Isolation

Every guide that covers @WebMvcTest for the controller layer and MockitoExtension for the service layer usually drops the ball on the persistence layer. The advice is typically 'just mock the repository' — and for service-layer tests, that's correct. But it means the actual JPA queries, custom @Query annotations, derived method queries, and entity constraints never get tested at all.

@DataJpaTest is the answer. It loads only the JPA infrastructure: Hibernate, the embedded database, and your repositories. No controllers, no services, no security. The test runs in a transaction that is rolled back after each test method by default, so tests are fully isolated without manual cleanup.

There are two database choices: 1. H2 in-memory — zero setup, instant start, but H2 has subtle differences from PostgreSQL. Derived queries that work on H2 can fail on PostgreSQL due to case sensitivity, dialect differences, or SQL features H2 doesn't support. 2. Testcontainers — real PostgreSQL, real behaviour. Slower to start but eliminates an entire class of 'passes tests, fails production' bugs.

For a guide at this level, I'll show both. The H2 setup for rapid iteration during development; the Testcontainers annotation for CI and production parity.

src/test/java/io/thecodeforge/repository/ProductRepositoryTest.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
package io.thecodeforge.repository;

import io.thecodeforge.model.Product;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.test.context.ActiveProfiles;

import java.math.BigDecimal;
import java.util.List;
import java.util.Optional;

import static org.assertj.core.api.Assertions.assertThat;

/**
 * @DataJpaTest loads:
 *   - JPA infrastructure (Hibernate, EntityManager)
 *   - Embedded H2 database configured to replace your real datasource
 *   - Your @Repository beans
 *   - Flyway/Liquibase migrations if present
 *
 * @DataJpaTest does NOT load:
 *   - @Service, @Controller, @Component beans
 *   - Security configuration
 *   - The full application context
 *
 * Each test runs in a transaction rolled back on completion — no cleanup needed.
 * To test with a real PostgreSQL database instead of H2, add:
 *   @AutoConfigureTestDatabase(replace = Replace.NONE)
 *   @Testcontainers with a static @Container PostgreSQLContainer
 */
@DataJpaTest
class ProductRepositoryTest {

    @Autowired
    private ProductRepository repository;

    @BeforeEach
    void setUp() {
        // Each test gets a clean, rolled-back transaction.
        // setUp() here demonstrates @BeforeEach placement — use it for
        // test data that every test in this class needs.
        repository.deleteAll();
    }

    @Test
    @DisplayName("findById: returns product when it exists")
    void shouldFindProductById() {
        // Arrange — save a product to the embedded H2
        Product saved = repository.save(
                new Product(null, "Forge Blade", new BigDecimal("99.99")));

        // Act
        Optional<Product> found = repository.findById(saved.id());

        // Assert
        assertThat(found).isPresent();
        assertThat(found.get().name()).isEqualTo("Forge Blade");
    }

    @Test
    @DisplayName("findAll: returns all persisted products")
    void shouldReturnAllProducts() {
        // Arrange
        repository.save(new Product(null, "Forge Blade", new BigDecimal("99.99")));
        repository.save(new Product(null, "Cloud Core", new BigDecimal("45.00")));

        // Act
        List<Product> products = repository.findAll();

        // Assert
        assertThat(products).hasSize(2);
        assertThat(products).extracting(Product::name)
                            .containsExactlyInAnyOrder("Forge Blade", "Cloud Core");
    }

    @Test
    @DisplayName("save: persists product and generates ID")
    void shouldGenerateIdOnSave() {
        // Arrange
        Product newProduct = new Product(null, "Anvil Pro", new BigDecimal("199.00"));

        // Act
        Product saved = repository.save(newProduct);

        // Assert — ID is assigned by the database, not the application
        assertThat(saved.id()).isNotNull();
        assertThat(saved.id()).isGreaterThan(0L);
    }

    @Test
    @DisplayName("delete: removes product from database")
    void shouldDeleteProduct() {
        // Arrange
        Product saved = repository.save(
                new Product(null, "Deprecated Blade", new BigDecimal("1.00")));

        // Act
        repository.deleteById(saved.id());

        // Assert
        assertThat(repository.findById(saved.id())).isEmpty();
    }
}
Output
BUILD SUCCESSFUL in 2.1s
4 tests passed, 0 failed
ProductRepositoryTest > shouldFindProductById() PASSED
ProductRepositoryTest > shouldReturnAllProducts() PASSED
ProductRepositoryTest > shouldGenerateIdOnSave() PASSED
ProductRepositoryTest > shouldDeleteProduct() PASSED
H2 vs Testcontainers: Which to Use
  • H2 for development inner loop — fast feedback on derived queries during active coding
  • Testcontainers for CI and complex queries — catches PostgreSQL-specific behaviour H2 won't surface
  • If your queries use PostgreSQL-specific functions (jsonb, pg_trgm, window functions), H2 cannot test them at all
  • The safest strategy: H2 locally, Testcontainers in CI — same @DataJpaTest class, just change the datasource
Production Insight
I've been burned by exactly one class of bug that @DataJpaTest with H2 would not have caught: a derived query that used a case-sensitive column comparison — worked on H2, failed on PostgreSQL in production.
Testcontainers added 8 seconds to the CI run and caught the issue before deployment.
That 8 seconds was worth it.
Key Takeaway
@DataJpaTest loads only the JPA layer — repositories, Hibernate, and an embedded database — with automatic transaction rollback between tests.
Test persistence logic here, not in service-layer tests where the repository is mocked.
Use Testcontainers for database-specific queries; H2 for simple CRUD and derived query testing.
When to Use @DataJpaTest
IfTesting a Spring Data derived query (findByNameContaining, existsByEmail, etc.)
UseUse @DataJpaTest — only the persistence layer needs to be loaded
IfTesting a custom @Query with JPQL or native SQL
UseUse @DataJpaTest with Testcontainers — H2 may not support the SQL dialect
IfTesting that a unique constraint is enforced
UseUse @DataJpaTest — constraint violations require a real transaction and flush
IfTesting that a service correctly uses the repository
UseUse MockitoExtension with @Mock — you do not need a real database to test service logic

Testcontainers: Real PostgreSQL in Integration Tests

While H2 is convenient for local development, it has limitations: it doesn't support PostgreSQL-specific data types (jsonb, arrays), full-text search functions (pg_trgm), or exact SQL dialect compatibility. The result is a test suite that passes on H2 but fails against your production PostgreSQL database.

Testcontainers solves this by spinning up a real PostgreSQL instance in a Docker container for your test suite. It integrates seamlessly with Spring Boot via the @Testcontainers and @Container annotations.

To use Testcontainers with @DataJpaTest: 1. Add the Testcontainers dependency (testcontainers, postgresql, and junit-jupiter) 2. Use the @Testcontainers annotation on the test class 3. Declare a static PostgreSQLContainer field annotated with @Container 4. Override the datasource properties using @DynamicPropertySource

The example below shows a complete @DataJpaTest that uses a real PostgreSQL container instead of H2.

src/test/java/io/thecodeforge/repository/ProductRepositoryTestcontainersTest.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
package io.thecodeforge.repository;

import io.thecodeforge.model.Product;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;

import java.math.BigDecimal;
import java.util.List;
import java.util.Optional;

import static org.assertj.core.api.Assertions.assertThat;

/**
 * This test class demonstrates using Testcontainers with @DataJpaTest.
 * - @Testcontainers enables lifecycle management of containers
 * - @Container marks the container to start before tests and stop after
 * - @DynamicPropertySource overrides Spring Boot datasource properties
 * - @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) 
 *   tells Spring not to replace the real datasource with H2
 *
 * In a real project you'd also add:
 *   @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
 * right below @DataJpaTest.
 */
@Testcontainers
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
class ProductRepositoryTestcontainersTest {

    @Container
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15")
            .withDatabaseName("testdb")
            .withUsername("test")
            .withPassword("test");

    @DynamicPropertySource
    static void configureProperties(DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.url", postgres::getJdbcUrl);
        registry.add("spring.datasource.username", postgres::getUsername);
        registry.add("spring.datasource.password", postgres::getPassword);
        // Ensure Hibernate uses PostgreSQL dialect
        registry.add("spring.jpa.properties.hibernate.dialect", 
                     () -> "org.hibernate.dialect.PostgreSQLDialect");
    }

    @Autowired
    private ProductRepository repository;

    @Test
    @DisplayName("should persist and find product using real PostgreSQL")
    void shouldPersistAndFind() {
        // Arrange
        Product product = new Product(null, "PG Blade", new BigDecimal("99.99"));
        Product saved = repository.save(product);

        // Act
        Optional<Product> found = repository.findById(saved.id());

        // Assert
        assertThat(found).isPresent();
        assertThat(found.get().name()).isEqualTo("PG Blade");
    }

    @Test
    @DisplayName("should enforce unique constraint (real DB only)")
    void shouldEnforceUniqueConstraint() {
        // Assumes a unique constraint exists on name
        // This test would pass on H2 but catch real constraint violations on PostgreSQL
        repository.save(new Product(null, "UniqueName", new BigDecimal("10.00")));
        org.junit.jupiter.api.Assertions.assertThrows(
                org.springframework.dao.DataIntegrityViolationException.class,
                () -> repository.save(new Product(null, "UniqueName", new BigDecimal("20.00")))
        );
    }
}

// For this to compile, add these dependencies to your pom.xml:
// <dependency>
//     <groupId>org.testcontainers</groupId>
//     <artifactId>testcontainers</artifactId>
//     <scope>test</scope>
// </dependency>
// <dependency>
//     <groupId>org.testcontainers</groupId>
//     <artifactId>postgresql</artifactId>
//     <scope>test</scope>
// </dependency>
// <dependency>
//     <groupId>org.testcontainers</groupId>
//     <artifactId>junit-jupiter</artifactId>
//     <scope>test</scope>
// </dependency>
Output
BUILD SUCCESSFUL in 11.2s (container startup ~10s, tests ~1s)
2 tests passed, 0 failed
Note: The first run will download the PostgreSQL image. Subsequent runs reuse the cached image.
Container startup time is the dominant factor; once running, tests execute at near-H2 speed.
Production Insight
Testcontainers is not just for database testing — you can start Redis, Kafka, Elasticsearch, or any containerised service.
The pattern is identical: @Testcontainers + @Container + @DynamicPropertySource.
Your CI machine must support Docker; the added 10-15 seconds of container startup is a small price for production parity.
Key Takeaway
Use Testcontainers with @DataJpaTest when you need real PostgreSQL behaviour — especially for unique constraints, native queries, or database-specific functions.
It adds a few seconds to startup but eliminates an entire class of 'works on H2, fails on Postgres' bugs.
When to Use Testcontainers vs H2
IfSimple CRUD operations with standard JPA
UseH2 is sufficient — faster iteration
IfCustom native queries or PostgreSQL-specific features
UseTestcontainers required — H2 will give false positives
IfUnique constraint or referential integrity tests
UseTestcontainers recommended — real database behaviour

The Real Reason You Need @Mock and @InjectMocks (Not Just Because the Docs Say So)

The competitor blogs list @Mock and @InjectMocks as annotations to memorize. I'm telling you why if you skip them you're writing expensive, brittle tests. You have a service that calls a repository. If you spin up the full Spring context for a unit test, you're testing Hibernate, connection pools, and maybe even the database driver. That is not unit testing — that is a slow, fragile integration test dressed in unit-test clothes. @Mock creates a lightweight, controllable stand-in for the repository. @InjectMocks wires that mock into your service instance without a single Spring bean. The payoff: your test runs in milliseconds, breaks only when YOUR logic breaks, and never fails because a database is down. Use them. Every time. Or enjoy spending ten minutes debugging a test that fails because of a missing Flyway migration, not a bug in your code.

UserServiceTest.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// io.thecodeforge
class UserServiceTest {

    @Mock
    private UserRepository userRepository;

    @InjectMocks
    private UserService userService;

    @Test
    void shouldReturnUserWhenFound() {
        User mockUser = new User(1L, "Alice");
        when(userRepository.findById(1L)).thenReturn(Optional.of(mockUser));

        User result = userService.getUserById(1L);

        assertEquals("Alice", result.name());
        // Mockito.verify() ensures the interaction happened
        verify(userRepository).findById(1L);
    }
}
Output
Test passes in ~50ms. No database, no Spring context.
Production Trap:
Never annotate your service or repository fields with @Autowired inside a unit test. It defeats the isolation you fought for. Stick to @Mock and @InjectMocks. If your test class has @ExtendWith(SpringExtension.class) purely for injection, you've already lost.
Key Takeaway
If your unit test touches a real database, it's not a unit test. Mock all external dependencies; test only your logic.

Why Stubbing with when() Is Safer Than BDDMockito.given() (And When to Break the Rule)

Competitors show both 'when().thenReturn()' and 'given().willReturn()' as interchangeable. They are not. when() is the hammer for the job. It reads left-to-right: 'when this method is called, then return this'. That mirrors how your brain processes code. given() reverses the order and adds a layer of abstraction that juniors often misunderstand — it looks like BDD but delivers no behavioral benefit in a JUnit test. The only time I use given() is when working in a team that has standardized on BDD style across the board. Even then, I watch for beginners who write 'given(mock.method())' and expect it to work without a stub. It doesn't. The method call inside given() triggers the real implementation unless the mock is already configured. That's a Heisenbug waiting to happen. Stick to when(). You'll thank me when you're debugging a test at 3 AM before a prod release.

OrderServiceTest.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// io.thecodeforge
class OrderServiceTest {

    @Mock
    private PaymentGateway paymentGateway;

    @InjectMocks
    private OrderService orderService;

    @Test
    void shouldProcessPayment() {
        when(paymentGateway.charge(100.0)).thenReturn(true);

        boolean result = orderService.submitOrder(100.0);

        assertTrue(result);
        verify(paymentGateway).charge(100.0);
    }

    // Bad: hidden behavior in test setup
    // given(paymentGateway.charge(100.0)).willReturn(true);
}
Output
Test passes. Stubbing is explicit, readable, and debuggable.
Production Trap:
If your when() call uses matchers like any() or eq(), you must match exactly between stub and verify. Mixing matchers with raw values (e.g., when(mock.method(any(), "literal"))) will throw an InvalidUseOfMatchersException at runtime. Align your stubs and verifications to avoid these silent killers.
Key Takeaway
when().thenReturn() is the default for a reason. It's explicit, predictable, and less error-prone than BDD aliases. Master it before you branch out.
● Production incidentPOST-MORTEMseverity: high

Flaky Tests in CI/CD Pipeline

Symptom
Tests pass on developer machines but fail randomly in CI/CD with Mockito-related exceptions or stale state errors.
Assumption
Mockito automatically resets mocks between test classes and test isolation is guaranteed.
Root cause
Shared mutable state in test setup was not cleaned up between test runs. In Mockito 4 and earlier, static mocks created with Mockito.mockStatic() were not automatically closed — if a test threw an exception before the close() call, the static mock leaked into subsequent tests. The ordering of test execution in CI environments is not guaranteed to match local IDE ordering, so the leak manifested only under specific execution sequences. The problem is order-dependent: locally, tests run in a predictable order, but CI runs them in random order, exposing the leak only when the leaking test runs before a test that uses the same static class.
Fix
Upgrade to Mockito 5.x, where mockito-inline is merged into mockito-core and inline mock-making is the default. Wrap all MockedStatic usage in try-with-resources blocks. For example: ``java try (MockedStatic<UtilityClass> mocked = Mockito.mockStatic(UtilityClass.class)) { mocked.when(UtilityClass::getValue).thenReturn(42); // test code } `` For shared state, use @BeforeEach to reinitialise and @AfterEach to clean up explicitly — never rely on implicit reset.
Key lesson
  • Never assume mock state is clean between tests — explicitly reset or close mocks
  • Static mocking is a last resort — prefer dependency injection for testability
  • CI environments may execute tests in a different order than local IDE runs
  • Use try-with-resources for MockedStatic — it closes automatically even if the test throws
  • Mockito 5.x on Java 21 is the standard by 2026 — mockito-inline as a separate dependency is obsolete
Production debug guideFrom Mockito exceptions to slow builds6 entries
Symptom · 01
MockitoUnfinishedStubbingException
Fix
Move assertions outside when() blocks — never call assertThat() inside a stubbing call
Symptom · 02
Tests take over 30 seconds to run
Fix
Audit @SpringBootTest usage — replace with MockitoExtension for pure logic tests and @WebMvcTest for controller tests
Symptom · 03
@MockBean not injecting into test
Fix
Ensure test class has @WebMvcTest or @SpringBootTest — @MockBean requires a Spring context to function
Symptom · 04
ArgumentCaptor returns null values
Fix
Call verify() before getValue() — the captor only populates after the interaction is verified
Symptom · 05
UnnecessaryStubbingException with STRICT_STUBS
Fix
Remove stubbing that the code under test never hits — it signals dead test code or a logic path that changed. This is a feature, not a bug.
Symptom · 06
Tests pass individually but fail in suite with Mockito staleness
Fix
Check for MockedStatic usage without try-with-resources. Upgrade to Mockito 5.x and wrap all static mocks in try-with-resources blocks.
★ Test Suite Emergency DebuggingWhen tests break in CI/CD, start here
Mockito initialization failure
Immediate action
Check mockito-core version — on Mockito 5.x, mockito-inline is no longer a separate dependency
Commands
mvn dependency:tree | grep mockito
grep -r '@ExtendWith' src/test/java/
Fix now
Remove the separate mockito-inline dependency if on Mockito 5.x. Ensure @ExtendWith(MockitoExtension.class) is present on unit test classes.
Spring context fails to load in tests+
Immediate action
Check for missing @TestConfiguration or conflicting bean definitions
Commands
mvn test -X | grep 'BeanCreationException'
grep -r '@MockBean' src/test/java/ | wc -l
Fix now
Reduce @MockBean count or use @WebMvcTest slice instead of @SpringBootTest. Each missing @MockBean causes a NoSuchBeanDefinitionException that can look like a context load failure.
Tests pass individually but fail in suite+
Immediate action
Look for static state or shared mutable fields between test classes
Commands
mvn test -Dsurefire.failIfNoSpecifiedTests=false -Dtest=FailingTest
grep -r 'static' src/test/java/ | grep -v 'final'
Fix now
Move any static MockedStatic usage into try-with-resources blocks scoped to the individual test method. Reinitialise shared objects in @BeforeEach.
Mockito reports mock creation failure during suite run+
Immediate action
Identify test classes using MockedStatic and check for missing close() calls
Commands
grep -r 'mockStatic' src/test/java/
grep -r 'try.*MockedStatic' src/test/java/ | wc -l
Fix now
Wrap every MockedStatic instantiation in try-with-resources. If none exist, add them. If some exist but not all, fix all.
Test Slice Comparison
Feature@SpringBootTest@WebMvcTest@DataJpaTest
Context loadedFull applicationWeb layer onlyJPA/repository layer only
Typical speed5-30 seconds1-3 seconds~2 seconds
Primary use caseEnd-to-end flows, security testingController/API testingRepository/query testing
What's mockedNone (or @MockBean)Services via @MockBeanNothing (real DB)
DatabaseReal or in-memoryNot loadedIn-memory H2 or Testcontainers
MockMvcAuto-configured by @AutoConfigureMockMvcAuto-configuredNot available

Key takeaways

1
Prefer test slices (@WebMvcTest, @DataJpaTest) over @SpringBootTest to keep CI fast
2
Always close static mocks with try-with-resources to avoid flaky CI failures
3
Use ArgumentCaptor to test data transformation logic, not just that a method was called
4
Void methods require doThrow/doAnswer syntax
never use when().thenThrow()
5
Testcontainers provide production parity for database tests, catching subtle dialect differences

Common mistakes to avoid

4 patterns
×

Using @SpringBootTest for every test

Symptom
CI pipeline takes 15-30 minutes; tests are slow, flaky, and discourage local execution
Fix
Replace with @WebMvcTest for controllers and @DataJpaTest for repositories; use MockitoExtension for service logic
×

Forgetting to close static mocks

Symptom
Tests pass locally but fail intermittently in CI with Mockito staleness errors
Fix
Wrap every MockedStatic instantiation in try-with-resources; upgrade to Mockito 5.x
×

Not verifying mock interactions

Symptom
Tests pass green even when the code under test short-circuits logic
Fix
Always add verify() for critical calls; use ArgumentCaptor to inspect transformed objects
×

Using when().thenThrow() on void methods

Symptom
Runtime exception during test execution — Mockito does not support that syntax on void methods
Fix
Use doThrow().when() syntax for all void method stubbing
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01SENIOR
Explain the difference between @Mock and @MockBean in Spring Boot tests.
Q02SENIOR
How would you debug a flaky test that passes individually but fails in t...
Q03SENIOR
What is the purpose of @MockitoSettings(strictness = STRICT_STUBS)?
Q04SENIOR
Describe a scenario where you would use ArgumentCaptor instead of verify...
Q05SENIOR
How do you test a void method that throws an exception internally?
Q06JUNIOR
What is the difference between @WebMvcTest and @SpringBootTest?
Q01 of 06SENIOR

Explain the difference between @Mock and @MockBean in Spring Boot tests.

ANSWER
@Mock is a Mockito annotation used with MockitoExtension to create a mock outside the Spring context. @MockBean is a Spring Boot annotation that replaces a bean inside the ApplicationContext. Use @Mock for pure unit tests (no Spring context) and @MockBean when you need a Spring slice like @WebMvcTest and want to mock a specific dependency. The key trade-off: @Mock is faster because it doesn't load Spring; @MockBean gives you Spring's dependency injection at the cost of context loading time.
FAQ · 5 QUESTIONS

Frequently Asked Questions

01
What is the difference between @Mock and @MockBean?
02
Why does my test pass locally but fail in CI?
03
When should I use Testcontainers instead of H2?
04
How do I test a void method that calls a dependency?
05
What is the recommended test distribution?
N
Naren Founder & Principal Engineer

20+ years shipping production Java in banking & fintech. Lessons pulled from things that broke in production.

Follow
Verified
production tested
May 24, 2026
last updated
1,554
articles · all by Naren
🔥

That's Spring Boot. Mark it forged?

10 min read · try the examples if you haven't

Previous
Spring Boot Actuator and Monitoring
12 / 21 · Spring Boot
Next
Spring Boot with Docker