Spring Boot Testing — Flaky CI/CD from Mockito Static Leaks
Mockito static mock leaks from missing try-with-resources cause random CI failures.
20+ years shipping production Java in banking & fintech. Lessons pulled from things that broke in production.
- ✓Solid grasp of fundamentals
- ✓Comfortable reading code examples
- ✓Basic production concepts
- 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
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.
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.
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.
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.Mockito.mockStatic() on a utility class that wasn't closed in @AfterEach, corrupting subsequent tests.Mockito.mockStatic() must be closed in the same test method or @AfterEach — it leaks across the JVM otherwise.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).
The table below summarises the differences:
| Feature | @SpringBootTest | @WebMvcTest | @DataJpaTest |
|---|---|---|---|
| Context loaded | Full application | Web layer only | JPA/repository layer only |
| Typical speed | 5-30 seconds | 1-3 seconds | ~2 seconds |
| Primary use case | End-to-end flows, security testing | Controller/API testing | Repository/query testing |
| What's mocked | None (or @MockBean) | Services via @MockBean | Nothing (real DB) |
| Database | Real or in-memory | Not loaded | In-memory H2 or Testcontainers |
| MockMvc | Auto-configured by @AutoConfigureMockMvc | Auto-configured | Not 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.
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.
The visual below shows the shape of a healthy test suite:
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.
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()); } }
- 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
verify() mock interactions — a passing test without verification proves nothing about your logic.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.
$.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)
Let's see a clean example that verifies all three layers:
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 } }
ArgumentCaptors — Verifying What Gets Passed to Dependencies
Sometimes 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.verify()
A concrete example: a service receives a CreateProductRequest DTO, maps it into a Product entity, sets the creation timestamp, and calls . A plain repository.save()verify(repository.save( tells you any()))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( before calling captor.capture())captor.getValue(). The captor populates during the verify call — not before it.
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(); } }
- 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
verify()-without-captor misses: data transformation errors.verify(repo.save(any())) passes green.verify() before captor.getValue() — the captor populates during the verify call, not before.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 — 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.verify()
— confirms the method was called with expected arguments. Sufficient when the test is 'did this call happen?'verify()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 can be chained), but Mockito throws at runtime. The correct syntax for void methods is always when()do*().when(mock).method().
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); } }
- 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
when().thenThrow() does not work and throws at runtime.when() syntax on void methodsnever()).method()@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.
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(); } }
- 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
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.
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>
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.
// 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); } }
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.
// 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); }
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.Flaky Tests in CI/CD Pipeline
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.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.- 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
when() blocks — never call assertThat() inside a stubbing callverify() before getValue() — the captor only populates after the interaction is verifiedmvn dependency:tree | grep mockitogrep -r '@ExtendWith' src/test/java/mvn test -X | grep 'BeanCreationException'grep -r '@MockBean' src/test/java/ | wc -lmvn test -Dsurefire.failIfNoSpecifiedTests=false -Dtest=FailingTestgrep -r 'static' src/test/java/ | grep -v 'final'grep -r 'mockStatic' src/test/java/grep -r 'try.*MockedStatic' src/test/java/ | wc -l| Feature | @SpringBootTest | @WebMvcTest | @DataJpaTest |
|---|---|---|---|
| Context loaded | Full application | Web layer only | JPA/repository layer only |
| Typical speed | 5-30 seconds | 1-3 seconds | ~2 seconds |
| Primary use case | End-to-end flows, security testing | Controller/API testing | Repository/query testing |
| What's mocked | None (or @MockBean) | Services via @MockBean | Nothing (real DB) |
| Database | Real or in-memory | Not loaded | In-memory H2 or Testcontainers |
| MockMvc | Auto-configured by @AutoConfigureMockMvc | Auto-configured | Not available |
| File | Command / Code | Purpose |
|---|---|---|
| src | /** | Common Mistakes and How to Avoid Them |
| src | @WebMvcTest(ProductController.class) | MockMvc jsonPath Assertions |
| src | @ExtendWith(MockitoExtension.class) | ArgumentCaptors |
| src | /** | Testing Void Methods |
| src | /** | @DataJpaTest |
| src | /** | Testcontainers |
| UserServiceTest.java | class UserServiceTest { | The Real Reason You Need @Mock and @InjectMocks (Not Just Be |
| OrderServiceTest.java | class OrderServiceTest { | Why Stubbing with when() Is Safer Than BDDMockito.given() (A |
Key takeaways
when().thenThrow()Common mistakes to avoid
4 patternsUsing @SpringBootTest for every test
Forgetting to close static mocks
Not verifying mock interactions
verify() for critical calls; use ArgumentCaptor to inspect transformed objectsUsing when().thenThrow() on void methods
Interview Questions on This Topic
Explain the difference between @Mock and @MockBean in Spring Boot tests.
How would you debug a flaky test that passes individually but fails in the CI suite?
--order random in JUnit 5 configurator). Common causes: (1) Static mocks from MockedStatic that aren't closed — wrap in try-with-resources. (2) Shared mutable state in static fields — reset in @BeforeEach. (3) Missing mock reset — use @MockitoSettings(strictness = STRICT_STUBS) to catch stale stubs. For CI specifically, check if test ordering differs from local IDE. Run the failing test in isolation first, then with surrounding tests in various orders until you reproduce the leak.What is the purpose of @MockitoSettings(strictness = STRICT_STUBS)?
Describe a scenario where you would use ArgumentCaptor instead of verify() with eq().
verify(repo.save(any())) only confirms the call happened. An ArgumentCaptor captures the actual entity object, allowing you to assert that the timestamp was set, the name was trimmed, and the ID was null (for new entity). This catches data transformation bugs that verify() with eq() cannot — especially when the transformed object is built dynamically.How do you test a void method that throws an exception internally?
doThrow(new EmailDeliveryException()).when(emailClient).send(any(), any()). Then call the void method and assert that it throws an appropriate exception (e.g., RuntimeException wrapping the cause). Alternatively, if the method catches internally and logs, verify the error logging was called. The key is to test what the caller experiences — not the internal catch block.What is the difference between @WebMvcTest and @SpringBootTest?
Frequently Asked Questions
@Mock creates a mock outside the Spring context — used with MockitoExtension. @MockBean replaces a real bean in the Spring ApplicationContext with a mock — used with @WebMvcTest or @SpringBootTest. Use @Mock for pure unit tests, @MockBean when you need the Spring context but want to mock a specific dependency.
The most common cause is shared mutable state from static mocks that aren't closed. CI runs tests in a different order than your IDE, exposing order-dependent failures. Upgrade to Mockito 5.x and wrap all MockedStatic usage in try-with-resources.
Use Testcontainers when your queries use PostgreSQL-specific features (jsonb, native queries, window functions) or when you need to verify unique constraints and referential integrity. H2 is fine for simple CRUD and derived queries during development.
Use verify() to confirm the void method was called with the right arguments. Use doThrow() to simulate failure in the dependency. Use doAnswer() if the void method modifies arguments as a side effect. Never use when().thenThrow() on void methods — it throws at runtime.
Follow the testing pyramid: 70-80% unit tests (MockitoExtension), 15-20% integration tests (WebMvcTest, DataJpaTest), and 5-10% E2E tests (SpringBootTest with Testcontainers). This keeps CI fast and focused.
20+ years shipping production Java in banking & fintech. Lessons pulled from things that broke in production.
That's Spring Boot. Mark it forged?
8 min read · try the examples if you haven't