Spring Boot Test Slices, and When to Use Which
Most Spring Boot test suites have one annotation in them, @SpringBootTest, applied to everything. It works, and it is also why the suite takes four minutes to tell you a mapper is wrong.
Spring provides slices: annotations that start a context containing only the layer under test. Knowing which is which turns most of those four minutes back into seconds.
Start with no Spring at all
The fastest Spring test is the one that does not use Spring. A service with constructor injection is an ordinary class:
class PriceCalculatorTest {
private final PriceCalculator calculator = new PriceCalculator(new FixedRateProvider());
@Test
void appliesBulkDiscount() {
assertThat(calculator.total(10, BigDecimal.TEN)).isEqualByComparingTo("90.00");
}
}
No context, no annotations, milliseconds. This is one of the practical payoffs of constructor injection over field injection: with fields you cannot construct the object in a test without reflection, so you are pushed towards loading a context you do not need.
Most business logic tests should look like this. Reach for a slice only when you are testing the integration itself.
@WebMvcTest for the HTTP layer
@WebMvcTest(OrderController.class)
class OrderControllerTest {
@Autowired MockMvc mockMvc;
@MockitoBean OrderService service;
@Test
void returns404WhenMissing() throws Exception {
when(service.findById(42L)).thenThrow(new OrderNotFoundException(42L));
mockMvc.perform(get("/orders/42"))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.detail").value("No order with id 42"));
}
}
This loads controllers, the Jackson configuration, argument resolvers, filters and your @ControllerAdvice. It does not load @Service or @Repository beans, which is why the service is a mock: without it the context fails to start with a missing dependency.
Naming the controller in the annotation matters. @WebMvcTest on its own loads every controller in the application, and then every one of their dependencies needs mocking.
What belongs here: status codes, JSON shape, request mapping, validation failures, error handling. What does not: business rules, which do not need a servlet to be tested.
Note @MockitoBean rather than @MockBean. The old annotation is deprecated as of Spring Boot 3.4 and gone in recent versions.
@DataJpaTest for the persistence layer
@DataJpaTest
class OrderRepositoryTest {
@Autowired OrderRepository repository;
@Autowired TestEntityManager entityManager;
@Test
void findsByStatus() {
entityManager.persist(new Order("A-1", Status.PENDING));
entityManager.flush();
entityManager.clear();
assertThat(repository.findByStatus(Status.PENDING)).hasSize(1);
}
}
This loads entities, repositories and a DataSource, and nothing above them. Each test runs in a transaction that is rolled back afterwards, so tests do not leak into each other.
Two things about that rollback. It means the test never verifies what a real commit would do, so constraint violations that fire on commit can go unnoticed. And the transaction keeps the persistence context open for the whole test, so lazy associations resolve happily in an assertion and then fail in production. The flush and clear above exist to force a genuine reload rather than reading back the instance already in the context.
By default this replaces your database with an in memory one, which is fine until a query uses anything vendor specific. If you are on Postgres in production, test on Postgres:
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Testcontainers
class OrderRepositoryTest { ... }
The other slices
@JsonTest for serialisation, @RestClientTest for outbound HTTP clients, @WebFluxTest for the reactive stack, @DataMongoTest and friends for other stores. Each follows the same shape: a narrow context, and everything outside it mocked.
@SpringBootTest, used deliberately
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
class CheckoutFlowTest {
@Autowired TestRestTemplate restTemplate;
}
This starts the whole application, which is the right thing when the test is genuinely about the whole application: a request going in one end and a row landing in the database at the other. Have a handful of these covering the critical paths, not one per class.
Why context caching matters
Spring caches an application context between test classes and reuses it when the configuration matches exactly. Every distinct combination of annotations, properties and mocked beans creates a new one, and each new context is another full startup.
This is the hidden cost of scattering @MockitoBean and @TestPropertySource across classes: not the mocking, but the cache misses. Keeping test configuration uniform within a slice does more for suite time than any individual optimisation.