Part 31 of 59
Testing the Web Layer with @WebMvcTest and MockMvc
Controller tests verify HTTP mapping, request parsing, validation, serialization, and security — without starting a full server. @WebMvcTest + MockMvc gives you a fast, focused web layer test.
@WebMvcTest — What It Loads
@WebMvcTest(OrderController.class)
class OrderControllerTest {
// Spring loads:
// - Your @Controller class (and its dependencies)
// - DispatcherServlet, MVC configuration
// - Jackson ObjectMapper
// - Security (if configured)
//
// Spring does NOT load:
// - @Service, @Repository beans
// - Database, JPA
//
// You @MockBean all services
}
Basic Controller Test
@WebMvcTest(OrderController.class)
class OrderControllerTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@MockBean
private OrderService orderService; // mock all service dependencies
@Test
void shouldReturn200WhenOrderFound() throws Exception {
UUID orderId = UUID.randomUUID();
Order order = Order.builder()
.id(orderId)
.status(OrderStatus.PENDING)
.totalAmount(BigDecimal.valueOf(99.99))
.build();
when(orderService.findById(orderId)).thenReturn(order);
mockMvc.perform(get("/api/orders/{id}", orderId)
.accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(content().contentType(MediaType.APPLICATION_JSON))
.andExpect(jsonPath("$.id").value(orderId.toString()))
.andExpect(jsonPath("$.status").value("PENDING"))
.andExpect(jsonPath("$.totalAmount").value(99.99));
}
@Test
void shouldReturn404WhenOrderNotFound() throws Exception {
UUID unknownId = UUID.randomUUID();
when(orderService.findById(unknownId))
.thenThrow(new OrderNotFoundException(unknownId));
mockMvc.perform(get("/api/orders/{id}", unknownId))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.title").value("Order Not Found"))
.andExpect(jsonPath("$.status").value(404));
}
}
MockMvc Request Building
// GET with query params
mockMvc.perform(get("/api/orders")
.param("status", "PENDING")
.param("page", "0")
.param("size", "20")
.accept(MediaType.APPLICATION_JSON));
// POST with JSON body
mockMvc.perform(post("/api/orders")
.contentType(MediaType.APPLICATION_JSON)
.accept(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(createOrderRequest)));
// PUT
mockMvc.perform(put("/api/orders/{id}/confirm", orderId));
// DELETE
mockMvc.perform(delete("/api/orders/{id}", orderId));
// With headers
mockMvc.perform(get("/api/orders")
.header("X-Request-ID", "test-123")
.header("Authorization", "Bearer " + token));
// With cookies
mockMvc.perform(get("/api/cart")
.cookie(new Cookie("sessionId", "abc123")));
MockMvc Response Assertions
mockMvc.perform(post("/api/orders")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
// Status
.andExpect(status().isCreated())
// Headers
.andExpect(header().exists("Location"))
.andExpect(header().string("Location", containsString("/api/orders/")))
.andExpect(header().string("Content-Type", containsString("application/json")))
// JSON body — jsonPath
.andExpect(jsonPath("$.id").isNotEmpty())
.andExpect(jsonPath("$.status").value("PENDING"))
.andExpect(jsonPath("$.items").isArray())
.andExpect(jsonPath("$.items.length()").value(2))
.andExpect(jsonPath("$.items[0].quantity").value(2))
// JSON body — content matching
.andExpect(content().json("""
{
"status": "PENDING",
"currency": "USD"
}
""", false)) // false = non-strict (extra fields allowed)
// Print for debugging
.andDo(print());
Validation Testing
@Test
void shouldReturn400WhenRequestBodyIsInvalid() throws Exception {
// Missing customerId, empty items list
Map<String, Object> invalidRequest = Map.of(
"items", List.of()
);
mockMvc.perform(post("/api/orders")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(invalidRequest)))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.title").value("Validation Failed"))
.andExpect(jsonPath("$.errors").isArray())
.andExpect(jsonPath("$.errors[*].field")
.value(containsInAnyOrder("customerId", "items")));
}
@Test
void shouldReturn400WhenItemQuantityIsNegative() throws Exception {
var request = Map.of(
"customerId", UUID.randomUUID().toString(),
"items", List.of(Map.of("productId", UUID.randomUUID().toString(), "quantity", -1))
);
mockMvc.perform(post("/api/orders")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.errors[0].field").value("items[0].quantity"))
.andExpect(jsonPath("$.errors[0].message").value("Quantity must be positive"));
}
Security Integration Testing
@WebMvcTest(OrderController.class)
@Import(SecurityConfig.class) // import your security config
class OrderControllerSecurityTest {
@Autowired MockMvc mockMvc;
@MockBean OrderService orderService;
@Test
void shouldReturn401WithoutAuthentication() throws Exception {
mockMvc.perform(get("/api/orders"))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser(roles = "USER")
void userCanReadOrders() throws Exception {
when(orderService.findAll(any())).thenReturn(Page.empty());
mockMvc.perform(get("/api/orders"))
.andExpect(status().isOk());
}
@Test
@WithMockUser(roles = "USER")
void userCannotDeleteOrders() throws Exception {
mockMvc.perform(delete("/api/orders/{id}", UUID.randomUUID()))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(roles = "ADMIN")
void adminCanDeleteOrders() throws Exception {
doNothing().when(orderService).delete(any());
mockMvc.perform(delete("/api/orders/{id}", UUID.randomUUID()))
.andExpect(status().isNoContent());
}
@Test
void shouldAuthenticateWithJwtToken() throws Exception {
when(orderService.findAll(any())).thenReturn(Page.empty());
mockMvc.perform(get("/api/orders")
.with(jwt().authorities(new SimpleGrantedAuthority("ROLE_USER"))))
.andExpect(status().isOk());
}
}
Testing Serialization/Deserialization
@WebMvcTest(OrderController.class)
class OrderSerializationTest {
@Autowired MockMvc mockMvc;
@Autowired ObjectMapper objectMapper;
@MockBean OrderService orderService;
@Test
void shouldSerializeDatesAsIso8601() throws Exception {
Instant createdAt = Instant.parse("2026-05-03T10:00:00Z");
Order order = Order.builder()
.id(UUID.randomUUID())
.createdAt(createdAt)
.build();
when(orderService.findById(any())).thenReturn(order);
mockMvc.perform(get("/api/orders/{id}", UUID.randomUUID()))
.andExpect(jsonPath("$.createdAt").value("2026-05-03T10:00:00Z"));
}
@Test
void shouldOmitNullFieldsFromResponse() throws Exception {
Order order = Order.builder()
.id(UUID.randomUUID())
.status(OrderStatus.PENDING)
.promoCode(null) // should be omitted
.build();
when(orderService.findById(any())).thenReturn(order);
MvcResult result = mockMvc.perform(get("/api/orders/{id}", UUID.randomUUID()))
.andExpect(status().isOk())
.andReturn();
String body = result.getResponse().getContentAsString();
assertThat(body).doesNotContain("promoCode");
}
@Test
void shouldDeserializePaginationParams() throws Exception {
when(orderService.findAll(any())).thenReturn(Page.empty());
mockMvc.perform(get("/api/orders")
.param("page", "2")
.param("size", "50")
.param("sort", "createdAt,desc"))
.andExpect(status().isOk());
ArgumentCaptor<Pageable> pageableCaptor = ArgumentCaptor.forClass(Pageable.class);
verify(orderService).findAll(pageableCaptor.capture());
Pageable pageable = pageableCaptor.getValue();
assertThat(pageable.getPageNumber()).isEqualTo(2);
assertThat(pageable.getPageSize()).isEqualTo(50);
assertThat(pageable.getSort().getOrderFor("createdAt").getDirection())
.isEqualTo(Sort.Direction.DESC);
}
}
File Upload Testing
@Test
void shouldAcceptFileUpload() throws Exception {
MockMultipartFile file = new MockMultipartFile(
"file", // parameter name
"test.pdf", // original filename
MediaType.APPLICATION_PDF_VALUE,
"PDF content".getBytes()
);
when(attachmentService.save(any(), any(), any()))
.thenReturn(new Attachment(UUID.randomUUID(), "test.pdf"));
mockMvc.perform(multipart("/api/orders/{id}/attachments", UUID.randomUUID())
.file(file)
.param("description", "Invoice"))
.andExpect(status().isCreated());
}
@Test
void shouldRejectTooLargeFile() throws Exception {
byte[] largeContent = new byte[11 * 1024 * 1024]; // 11MB > 10MB limit
MockMultipartFile file = new MockMultipartFile(
"file", "large.pdf", MediaType.APPLICATION_PDF_VALUE, largeContent
);
mockMvc.perform(multipart("/api/orders/{id}/attachments", UUID.randomUUID())
.file(file))
.andExpect(status().isBadRequest());
}
ResultActions and andDo()
@Test
void shouldReturnCreatedOrder() throws Exception {
var request = validCreateRequest();
var order = persistedOrder();
when(orderService.create(any())).thenReturn(order);
mockMvc.perform(post("/api/orders")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andDo(print()) // print request and response to console
.andDo(document("create-order", // Spring REST Docs (if using)
requestFields(...),
responseFields(...)
))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.id").isNotEmpty());
}
Custom ResultMatchers
// Build reusable matchers
public class OrderMatchers {
public static ResultMatcher isPendingOrder() {
return ResultMatcher.matchAll(
jsonPath("$.status").value("PENDING"),
jsonPath("$.items").isArray(),
jsonPath("$.totalAmount").isNumber()
);
}
public static ResultMatcher isValidationError() {
return ResultMatcher.matchAll(
status().isBadRequest(),
jsonPath("$.title").value("Validation Failed"),
jsonPath("$.errors").isArray()
);
}
}
// Usage
mockMvc.perform(get("/api/orders/{id}", orderId))
.andExpect(status().isOk())
.andExpect(OrderMatchers.isPendingOrder());
mockMvc.perform(post("/api/orders")
.contentType(MediaType.APPLICATION_JSON)
.content("{}"))
.andExpect(OrderMatchers.isValidationError());
What You’ve Learned
@WebMvcTestloads only the web layer — fast, focused, no database@MockBeanmocks all service layer dependenciesMockMvc.perform()builds HTTP requests;andExpect()asserts on the responsejsonPath("$.field")extracts and asserts JSON fields from the response@WithMockUserand.with(jwt())inject security context for authenticated testsMockMultipartFilesimulates file uploads- Test validation failures, error responses, serialization, and security in isolation
Next: Article 32 — Integration Testing with @SpringBootTest and Testcontainers — end-to-end tests with a real database and full application context.