A Pragmatic Spring Boot Testing Strategy
What to unit-test, what to slice-test, and when to spin up Testcontainers. A pyramid you can actually maintain.
We deleted half our tests and shipped faster
A Spring Boot project I inherited had 4,800 tests. The full suite ran in 47 minutes. About 60% of them were @SpringBootTest integration tests that booted the entire application context to assert on a single method. Once we deleted the redundant ones and rewrote a quarter of them as plain JUnit tests, the suite ran in 7 minutes and caught the same bugs.
Test quantity is not test quality. This article is the testing pyramid I actually defend for a Spring Boot service, with the specific Spring annotations that map to each layer.
The three test types worth writing
| Layer | Annotation | When |
|-------|-----------|------|
| Unit | none (plain JUnit + Mockito) | Pure logic, validation, mapping |
| Slice | @WebMvcTest, @DataJpaTest, @JsonTest | One Spring layer in isolation |
| Integration | @SpringBootTest + Testcontainers | One full request path end to end |
The ratio I aim for: 80% unit, 15% slice, 5% integration. Most codebases I see invert that and pay for it in CI time and flakiness.
Unit tests — the only ones that should be the majority
A pure unit test instantiates the class under test with new, mocks its dependencies, and asserts on behaviour. No Spring context. Sub-millisecond per test.
```java
class OrderPricingTest {@Test void appliesPercentageDiscount() { var pricing = new OrderPricing(new FixedDiscount(BigDecimal.TEN)); var total = pricing.totalFor(List.of(item("widget", 100))); assertThat(total).isEqualByComparingTo("90.00"); } } ```
If your business logic cannot be tested without booting Spring, the business logic is in the wrong place. Pull it out of the @Service into a plain class and let the service delegate.
Slice tests — when you actually need a Spring layer
@WebMvcTest loads only the MVC layer. Use it for controller tests:
```java
@WebMvcTest(OrderController.class)
class OrderControllerTest {@Autowired MockMvc mvc; @MockBean OrderService service;
@Test void returns404WhenMissing() throws Exception { when(service.find(any())).thenReturn(Optional.empty());
mvc.perform(get("/v1/orders/{id}", UUID.randomUUID())) .andExpect(status().isNotFound()) .andExpect(jsonPath("$.title").value("Resource not found")); } } ```
What you get: the full HTTP serialization, validation, exception handling, security config. What you do not get: a database, a real service layer. Test the controller in isolation; test the service with unit tests.
@DataJpaTest does the equivalent for repositories — it boots only JPA, against an in-memory or (better) Testcontainer database. Use it for custom queries, not for trivial save/findById.
Integration tests — the expensive ones
A @SpringBootTest test boots the full context. It is slow (1–3 seconds per test of pure overhead) and the failure modes are confusing because everything is wired together. Reserve these for "this whole request path works" smoke tests.
```java
@SpringBootTest(webEnvironment = RANDOM_PORT)
@AutoConfigureMockMvc
@Testcontainers
class OrderCreationFlowTest {@Container static PostgreSQLContainer<?> db = new PostgreSQLContainer<>("postgres:16-alpine");
@DynamicPropertySource static void props(DynamicPropertyRegistry r) { r.add("spring.datasource.url", db::getJdbcUrl); r.add("spring.datasource.username", db::getUsername); r.add("spring.datasource.password", db::getPassword); }
@Autowired MockMvc mvc;
@Test void createsOrderAndPersistsIt() throws Exception { var res = mvc.perform(post("/v1/orders") .contentType(APPLICATION_JSON) .content(""" {"items":[{"sku":"WIDGET-01","qty":2}]} """)) .andExpect(status().isCreated()) .andReturn();
var location = res.getResponse().getHeader("Location"); mvc.perform(get(location)).andExpect(status().isOk()); } } ```
Things to notice:
- Testcontainers, not H2. H2 has different SQL semantics from real Postgres and will pass tests that fail in production. Run the database you ship with.
- One container per class, reused across tests. Spinning up a Postgres container per test is several seconds of overhead each.
- Round-trip the API. The test creates an order, then reads it back. That tiny addition catches whole categories of bug.
What I do not write
- Tests for getters and setters. Coverage theatre. Delete them.
- Tests that re-assert framework behaviour. "I called
save, does it callsave?" — no value, lots of mock noise. - Tests that mock the class under test. If you find yourself doing this, you are testing the mock.
- End-to-end tests of UI + API + DB in CI. Those are valuable but they belong in a separate pipeline that runs on merge, not on every push. They will be flaky; isolating them keeps the developer feedback loop fast.
The flakiness rules
Flaky tests are worse than no tests because they teach the team to ignore failures. The rules I enforce:
1. No Thread.sleep. Ever. Use Awaitility for "wait for a condition" with a timeout.
2. No real network calls. Use WireMock for HTTP dependencies.
3. No real clock. Inject a Clock bean and use Clock.fixed in tests.
4. No randomness in assertions. Seed any random number generator from a fixed value in tests.
A test that fails once a week wastes more team time than ten honest test failures, because everyone learns to retry instead of investigate.
Coverage as a tool, not a metric
I track line coverage but I do not enforce a threshold. Threshold-driven coverage produces tests written to hit lines, not to catch bugs. Instead, I look at the *delta* — if a PR adds 200 lines of business logic and zero tests, that gets flagged in review even at 90% project-wide coverage.
The useful question is not "what's the coverage number?" but "what would have to change about this code for a bug to slip through?" If the answer is "nothing", you have enough tests.
Speed as a feature
A test suite that runs in under 30 seconds gets run before every commit. A suite that runs in 10 minutes gets run before every push. A suite that runs in 47 minutes gets run by CI, sometimes, and developers stop trusting it.
The single highest-leverage improvement on most Spring Boot projects is rewriting slow @SpringBootTest tests as unit tests or @WebMvcTest slices. The bugs caught are the same; the wall-clock time is 100x lower; the feedback loop transforms.
That is the rewrite I would push for first on any project I joined.
Go deeper
Further reading
Source Code
Get the full project on GitHub
Stay in the Loop
Get the next tutorial in your inbox
Continue reading in Java & Spring Boot →
API Rate Limiting in Spring Boot with Bucket4j and Redis
Protect your APIs from abuse with per-user and per-IP rate limiting using Bucket4j, Redis and a clean filter-based implementation.
Related tutorials
API Rate Limiting in Spring Boot with Bucket4j and Redis
Protect your APIs from abuse with per-user and per-IP rate limiting using Bucket4j, Redis and a clean filter-based implementation.
Building REST APIs with Spring Boot: A Complete Guide
Design and build a production-ready REST API with Spring Boot — proper layering, DTOs, validation, error handling and testing.
Spring Boot + Kafka — Build a Real-Time Messaging System
Produce and consume Kafka messages from Spring Boot with proper serialization, error handling and consumer groups.
