Spring Boot Testing — Flaky CI/CD from Mockito Static Leaks
Mockito static mock leaks from missing try-with-resources cause random CI failures.
- 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
- @SpringBootTest loads everything — reserve it for end-to-end integration tests only
- Always verify() mock interactions to catch silently skipped logic
- 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 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.
What Is Spring Boot Testing with JUnit and Mockito and Why Does It Exist?
Spring Boot Testing with JUnit and Mockito is a core feature-set provided by the spring-boot-starter-test dependency. It was designed to solve the problem of tight coupling — where testing one class requires the entire database, security layer, and external APIs to be active.
JUnit 5 (Jupiter) provides the framework for running tests and making assertions, while Mockito allows you to create mocks — simulated objects that mimic the behaviour of real dependencies. This combination exists so developers can achieve high test coverage with fast-running, isolated tests that don't rely on external infrastructure.
Before writing a single test, the model and service you're testing need to exist. Here's the minimal production code that all examples in this guide reference:
The Product model is a plain Java record — no JPA annotations needed to explain testing concepts. The ProductService owns the business logic; the ProductRepository is its only dependency. That dependency is what Mockito replaces in every unit test below.
By using the Arrange-Act-Assert (AAA) pattern, tests become readable as documentation. A developer who has never seen the code should be able to read a test and understand exactly what behaviour it verifies — not just that it passes.
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.
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:
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.
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().
@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.
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.
That's Spring Boot. Mark it forged?
9 min read · try the examples if you haven't