Spring Boot test slices

@WebMvcTest with MockMvc, @DataJpaTest, @SpringBootTest with a random port, and keeping the context cache warm.

5 min read🧪 Testing Java Services

@SpringBootTest starts the whole application: every bean, the embedded server if you ask, the database connection, the Kafka listener containers. It is the right test for "does the application start and answer", and the wrong one for "does this controller return 400 on a missing field", because it takes seconds to start and those seconds are paid per context, not per test. Slices exist so that a controller test loads controllers and a repository test loads repositories, and the context cache exists so that whichever you load, you load it once.

Slices: load the layer under test

A slice annotation replaces @SpringBootTest and auto-configures only one layer:

SliceLoadsDoes not loadGives you
@WebMvcTest(OrderController.class)that controller, @ControllerAdvice, converters, validation, security filtersservices, repositories, the databaseMockMvc
@DataJpaTestentities, repositories, an EntityManager, a transaction per testcontrollers, servicesTestEntityManager
@JsonTestJackson and your @JsonComponentseverything elseJacksonTester
@RestClientTest(PricingClient.class)that client, RestClient builderthe restMockRestServiceServer
@WebFluxTest, @JdbcTest, @DataMongoTestthe equivalents for those stacks

What the slice does not load, you provide: a @WebMvcTest of a controller that needs an OrderService gets it from @MockitoBean (@MockBean before Boot 3.4). That is the honest shape of a controller test — the HTTP layer is real, the service is scripted — and it keeps the test about status codes, validation and JSON, which is what a controller is for.

MockMvc: the HTTP layer without a socket

java
@WebMvcTest(OrderController.class)
class OrderControllerTest {
    @Autowired MockMvc mvc;
    @MockitoBean OrderService orders;
 
    @Test
    void missingSkuIs400WithProblemDetail() throws Exception {
        mvc.perform(post("/orders").contentType(APPLICATION_JSON).content("""
                {"lines":[{"qty":2}]}
                """))
           .andExpect(status().isBadRequest())
           .andExpect(jsonPath("$.errors[0].field").value("lines[0].sku"));
        verifyNoInteractions(orders);              // validation stopped it before the service
    }
}

The request goes through the real DispatcherServlet, the real converters and the real @RestControllerAdvice, without Tomcat. That is why it is the right place to test the error model from the REST course: the 400 body is produced by the same code production runs. What it does not test is the servlet container, filters registered outside Spring MVC, and anything about the actual port; @SpringBootTest(webEnvironment = RANDOM_PORT) with TestRestTemplate or WebTestClient is that test, and one or two of them per application is enough.

Security is in the slice: a @WebMvcTest with Spring Security on the classpath gets the filter chain, so an unauthenticated request is a 401 before it reaches the controller. @WithMockUser(roles = "ADMIN") on the test sets the principal; spring-security-test's with(csrf()) adds the token a POST needs. A controller test that "mysteriously" 403s is almost always this.

@DataJpaTest: the repository against a database

java
@DataJpaTest
@AutoConfigureTestDatabase(replace = NONE)         // use the configured database, not an embedded one
@Import(PostgresTestcontainer.class)               // the next lesson: a real PostgreSQL 16
class OrderRepositoryTest {
    @Autowired OrderRepository orders;
    @Autowired TestEntityManager em;
 
    @Test
    void findsOpenOrdersOldestFirst() {
        em.persist(order(PAID, "2026-01-02")); em.persist(order(PAID, "2026-01-01")); em.persist(order(SHIPPED, "2026-01-01"));
        em.flush(); em.clear();                    // past the first-level cache: the query hits the database
        assertThat(orders.findByStatusOrderByPlacedAt(PAID)).extracting(Order::placedAt).isSorted();
    }
}

Three things the slice does that you should know about. Every test runs in a transaction that is rolled back at the end, so tests do not see each other's rows — and so a test that checks a @Transactional(REQUIRES_NEW) path is not testing what it thinks. By default it replaces your datasource with an embedded H2; replace = NONE keeps the real one, and the Testcontainers lesson explains why an H2 that accepts SQL PostgreSQL rejects is the wrong thing to be green against. And flush(); clear(); is how you defeat the persistence context from the JPA course, which would otherwise hand back the objects you just persisted without running your query at all.

The context cache, and how tests spend it

Spring caches application contexts across test classes by configuration: two test classes with the same annotations, the same @MockitoBeans, the same properties and the same @Imports share one context. Change any of those and you have a second context, started from scratch. In a large suite this is the whole build time: twelve @SpringBootTest classes with twelve different @MockitoBean combinations are twelve application starts.

The habits that keep the cache warm:

  • One base class or one annotation per kind of test — @IntegrationTest composed of @SpringBootTest + @Import(PostgresTestcontainer.class) + @ActiveProfiles("test") — so every integration test has the same key.
  • @MockitoBean sparingly, and the same set everywhere it is used. A mock per test class is a context per test class.
  • @DirtiesContext almost never. It throws the context away; the next class rebuilds it. It exists for tests that genuinely corrupt shared state, and it is usually applied because a test leaked state that a rollback or a reset would have handled.
  • Properties in application-test.yml, not in @TestPropertySource per class.

-Dspring.test.context.cache.maxSize and the org.springframework.test.context.cache logger at DEBUG show the hit and miss counts, which is the measurement to take before guessing.

Test configuration that stays in tests

@TestConfiguration declares beans that only tests see — a fixed Clock, the container from the next lesson, a stub for an outbound client — and @Import pulls it into the tests that want it. Keep the fixed clock there rather than mocking time in each test, and keep production configuration out of src/test entirely: a @Profile("test") bean in src/main is production code that behaves differently when someone sets the wrong profile.

Progress is saved on this device and to your account when signed in.