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

  • @WebMvcTest loads only the web layer — fast, focused, no database
  • @MockBean mocks all service layer dependencies
  • MockMvc.perform() builds HTTP requests; andExpect() asserts on the response
  • jsonPath("$.field") extracts and asserts JSON fields from the response
  • @WithMockUser and .with(jwt()) inject security context for authenticated tests
  • MockMultipartFile simulates 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.