What's the right balance between unit tests, slice tests, and full integration tests in a Spring Boot application?

8 minintermediatetest-pyramidunit-testsintegration-tests

Quick Answer

A healthy Spring Boot test suite generally follows a pyramid shape: many fast, isolated unit tests (plain JUnit + Mockito, no Spring context, testing business logic directly) at the base; a moderate number of slice tests (@WebMvcTest, @DataJpaTest) verifying each layer's Spring-specific wiring and behavior in the middle; and a smaller number of full @SpringBootTest/Testcontainers-based integration tests at the top, covering the most critical end-to-end paths where the interaction between layers genuinely needs to be verified together.

Detailed Answer

A common anti-pattern is defaulting to @SpringBootTest for nearly everything, because it's the most "realistic" option — but this produces a slow, expensive test suite where most tests don't actually need the full context they're paying for. A more deliberate, layered strategy (the classic "test pyramid" shape) tends to work better in practice:

Base — plain unit tests (most numerous, fastest): test business logic in isolation, with the class under test constructed directly (new OrderService(mockGateway, mockRepo)) and its collaborators replaced with plain Mockito mocks — no Spring context loads at all. This is exactly what constructor injection is designed to make trivial, and is where the bulk of business-logic edge cases, branching logic, and validation rules should be covered, since these tests run in milliseconds and give the fastest possible feedback.

Middle — slice tests (moderate number): verify each layer's Spring-specific wiring and behavior using the narrowest slice annotation that fits — @WebMvcTest for controller request/response handling, exception mapping, and validation; @DataJpaTest for repository query correctness (derived queries, @Query methods, entity mapping) against a real (often Testcontainers-backed) database. These catch integration mistakes specific to the framework layer (a malformed @RequestMapping, an incorrect JPQL query) that pure unit tests, with everything mocked out, wouldn't catch.

Top — full integration tests (fewest, most expensive): @SpringBootTest (optionally combined with Testcontainers and a real HTTP client like TestRestTemplate/WebTestClient) exercising a complete, realistic request flow end-to-end — reserved for the most critical user-facing paths (e.g., "placing an order end-to-end actually charges the customer and persists the order"), not every possible code path, since each of these tests is comparatively slow and expensive to run and maintain.

Why this shape, rather than "just use @SpringBootTest everywhere":

  • Speed of feedback — a large suite of full-context integration tests can turn a few-second test run into a multi-minute one, which directly slows down the development/CI feedback loop.
  • Failure locality — when a narrowly-scoped unit or slice test fails, the cause is usually obvious and localized; a failure in a large @SpringBootTest covering many layers at once gives a much vaguer signal about where the actual problem lies.
  • Genuine value of each layer — unit tests catch business-logic bugs cheaply; slice tests catch framework-wiring bugs; full integration tests catch the (hopefully rarer) bugs that only manifest from the interaction of multiple correctly-tested layers together — each layer is pulling its own weight rather than duplicating the same coverage at increasing cost.