The middle of the test pyramid
Unit tests skip Spring entirely; full @SpringBootTest boots everything. Slices sit in between: they
load only the part of the context relevant to one layer. The web layer. The persistence layer. The JSON
layer. You get real framework behavior — routing, SQL, serialization — without paying to start the whole
application.
@WebMvcTest(OrderController.class) // controllers + MVC infra
@DataJpaTest // repositories + JPA + embedded DB
@JsonTest // Jackson + JSON beans
@RestClientTest(WeatherClient.class) // an HTTP client + mock server
Because each slice registers only its layer's beans and skips the rest, it starts far faster than a full context. Slices should be the majority of your Spring-aware tests.
@WebMvcTest: controllers without a server
@WebMvcTest boots the web layer — controllers, @ControllerAdvice, filters, converters — plus an
auto-configured MockMvc. It does not scan your @Service or @Repository beans, so you supply them
as mocks:
@WebMvcTest(OrderController.class)
class OrderControllerTest {
@Autowired MockMvc mvc;
@MockBean OrderService service; // controller's dependency, mocked
@Test void returnsOrder() throws Exception {
when(service.find(1L)).thenReturn(new Order(1L, "PAID"));
mvc.perform(get("/orders/1"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.status").value("PAID"));
}
}
Forget the @MockBean and the context fails with "No qualifying bean of type ... OrderService." That's the
slice doing its job: it deliberately leaves the lower layers out and expects you to mock them.
MockMvc itself is worth understanding. It drives the DispatcherServlet in-process — no Tomcat, no
socket. It runs the full MVC machinery (mapping, argument resolution, the controller, exception handling,
JSON rendering) and lets you assert on the outcome:
mvc.perform(post("/orders")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"item\":\"widget\"}"))
.andExpect(status().isCreated())
.andExpect(header().exists("Location"))
.andExpect(jsonPath("$.id").isNumber());
Fast and deterministic — but it does not exercise the real servlet container or HTTP stack. For that you need a running server, which is integration-test territory.
Asserting JSON with jsonPath
Don't string-match raw JSON. jsonPath evaluates JSONPath expressions against the body, including proving a
field is absent — handy for confirming you never leak a password:
mvc.perform(get("/orders/1"))
.andExpect(jsonPath("$.id").value(1))
.andExpect(jsonPath("$.items").isArray())
.andExpect(jsonPath("$.items.length()").value(3))
.andExpect(jsonPath("$.secret").doesNotExist());
Security applies in @WebMvcTest
A surprise that bites people: @WebMvcTest does apply your Spring Security filter chain. An
unauthenticated request can get a 401/403 and fail the test. Use spring-security-test to supply a user and
a CSRF token:
@Test
@WithMockUser(roles = "ADMIN")
void adminCanDelete() throws Exception {
mvc.perform(delete("/orders/1").with(csrf()))
.andExpect(status().isNoContent());
}
@WithMockUser injects a principal; .with(csrf()) supplies a valid token for state-changing methods.
@DataJpaTest: repositories against a real database
@DataJpaTest boots the persistence slice — entities, Spring Data repositories, EntityManager,
TestEntityManager, and by default an in-memory embedded database. It's @Transactional, so each test
rolls back and the DB stays clean between tests:
@DataJpaTest
class OrderRepositoryTest {
@Autowired OrderRepository repo;
@Autowired TestEntityManager em;
@Test void findsByStatus() {
em.persist(new Order("PAID"));
assertThat(repo.findByStatus("PAID")).hasSize(1);
}
}
Note the use of TestEntityManager to set up the data rather than the repository under test. If you used
repo.save to seed a test of repo.findByStatus, a broken save could mask a broken find. TestEntityManager
also gives you flush and clear so you can force SQL and detach entities, ensuring a read hits the database
rather than the first-level cache:
Long id = em.persistAndGetId(new Order("PAID"), Long.class);
em.flush();
em.clear(); // detach so the next read hits the DB
Order found = repo.findById(id).orElseThrow();
The H2 trap
By default @DataJpaTest swaps your datasource for H2. Convenient — and a liability. H2 quietly diverges from
Postgres on JSON columns, sequences, upserts, and native SQL, giving you "green locally, red in prod." For
dialect-sensitive repositories, keep the real engine with Testcontainers:
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Testcontainers
class OrderRepositoryIT {
@Container @ServiceConnection
static PostgreSQLContainer<?> db = new PostgreSQLContainer<>("postgres:16");
}
@JsonTest and @RestClientTest
Two narrower slices round things out. @JsonTest boots just Jackson and your configured modules, so you can
lock down the exact JSON shape of a DTO using the app's real ObjectMapper:
@JsonTest
class OrderJsonTest {
@Autowired JacksonTester<Order> json;
@Test void serializes() throws Exception {
assertThat(json.write(new Order(1L, "PAID")))
.extractingJsonPathStringValue("$.status").isEqualTo("PAID");
}
}
@RestClientTest slices in the beans for an outbound API client plus a MockRestServiceServer that stubs
the remote endpoint — so you can test URL building, headers, parsing, and error handling without hitting the
live third party:
@RestClientTest(WeatherClient.class)
class WeatherClientTest {
@Autowired WeatherClient client;
@Autowired MockRestServiceServer server;
@Test void parsesResponse() {
server.expect(requestTo("/weather?city=NYC"))
.andRespond(withSuccess("{\"temp\":21}", MediaType.APPLICATION_JSON));
assertThat(client.tempFor("NYC")).isEqualTo(21);
}
}
Keep the context cache happy
@MockBean is how slices receive collaborators, but it has a cost: each distinct set of mocked beans is a
different context configuration that Spring caches separately. Spawn dozens of unique combinations and you
defeat context caching, and the suite slows to a crawl. Keep the set of mocks consistent across tests in a
slice. (Boot 3.4+ offers @MockitoBean as the replacement for the deprecated @MockBean.)
When a slice excludes something you actually need — a custom converter, a @ControllerAdvice that isn't
scanned — widen it surgically rather than escalating to a full @SpringBootTest:
@WebMvcTest(OrderController.class)
@Import(JacksonConfig.class) // bring in a specific @Configuration
@AutoConfigureMockMvc(addFilters = false) // skip security filters if desired
class T { /* ... */ }
The pitfalls, collected
- Using
@SpringBootTestwhen a slice would do — slow tests, no extra coverage. - Forgetting a
@MockBeanfor a controller dependency. - Assuming
@WebMvcTestignores security — it doesn't. - Trusting H2 in
@DataJpaTestfor Postgres-specific SQL. - Seeding
@DataJpaTestthrough the repo under test instead ofTestEntityManager. - Fragmenting the context cache with many unique
@MockBeancombinations.
Recap
Slices load one layer so you get real framework wiring without booting the whole app. @WebMvcTest +
MockMvc tests controllers (mock the services, mind security, assert with jsonPath); @DataJpaTest tests
repositories against a database (set up with TestEntityManager, and don't trust H2 for dialect-specific
SQL); @JsonTest and @RestClientTest cover serialization and API clients. Keep your @MockBean sets
consistent to preserve context caching, and reach for the narrowest slice that proves the behavior.