Foundation · Build
Test Controllers with MockMvc and WebTestClient
Prove routing, binding, validation, filters, advice, and JSON contracts without turning every HTTP check into a full application test.
Primary source: Spring Boot 3.5: Testing Web Controllers
Lesson 10 of 11
1d streakThe Architectural Question
A full @SpringBootTest boots for seconds per test, while most controller assertions only need mapped arguments, validators, and Jackson.
What this guide proves
- Use WebMvcTest for servlet MVC contracts
- Mock application collaborators rather than HTTP infrastructure
- Assert status, headers, body, and failure payloads together
Load the HTTP boundary, not the whole application
| Test target | Tool | Collaborators |
|---|---|---|
| Routing, JSON, validation, advice | @WebMvcTest + MockMvc | Mock service; real MVC infrastructure |
| Business decisions | Plain JUnit test | Fake or mocked repository |
| Repository mapping and queries | @DataJpaTest | Test database or container |
| Cross-layer deployment path | @SpringBootTest | Use sparingly for integration confidence |
@WebMvcTest selects MVC components around the chosen controller. Mock the application service because the test is proving HTTP translation, not persistence. Import the real advice when the error contract is part of the endpoint behavior.
Pin success, validation, and not-found contracts
package com.example.tasks.task;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import com.example.tasks.api.ApiExceptionHandler;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.context.annotation.Import;
import org.springframework.http.MediaType;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
@WebMvcTest(TaskController.class)
@Import(ApiExceptionHandler.class)
class TaskControllerTest {
@Autowired MockMvc mvc;
@MockitoBean TaskService taskService;
@Test void createsTask() throws Exception {
when(taskService.create(new CreateTaskRequest("Trace MVC")))
.thenReturn(new TaskResponse(7, "Trace MVC", "OPEN"));
mvc.perform(post("/api/tasks")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"title\":\"Trace MVC\"}"))
.andExpect(status().isCreated())
.andExpect(header().string("Location", "/api/tasks/7"))
.andExpect(jsonPath("$.status").value("OPEN"));
}
@Test void rejectsBlankTitleBeforeService() throws Exception {
mvc.perform(post("/api/tasks")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"title\":\" \"}"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.errorCode").value("INVALID_REQUEST"));
verifyNoInteractions(taskService);
}
@Test void reportsMissingTask() throws Exception {
when(taskService.find(99)).thenThrow(new TaskNotFoundException(99));
mvc.perform(get("/api/tasks/99"))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.errorCode").value("TASK_NOT_FOUND"));
}
}Security is part of the HTTP contract
Assert what clients can observe
- 01Assert status, Location or other meaningful headers, Content-Type, and stable JSON fields.
- 02Verify the service command created from the request when mapping itself matters.
- 03Verify no service interaction when binding or validation should short-circuit.
- 04Avoid assertions about private controller methods or MockMvc internals.
- 05Keep a small number of full-context tests for wiring that a slice intentionally excludes.
Active Recall Checkpoint
A controller slice returns 401 after the security starter is added. Is disabling filters the best repair?
Hint: The filter chain is now observable endpoint behavior.
Show answer & explanation
No. Import or select the real security configuration and authenticate the request with the intended test support. Disable filters only when security is explicitly outside that test contract and another test proves it.
Working Implementation
Code Walkthrough
Write a controller slice test for create-order success, validation failure, and missing-resource ProblemDetail response.
@WebMvcTest(CatalogController.class)
class CatalogControllerTest {
@Autowired MockMvc mvc;
@MockitoBean CatalogService catalog;
@Test
void returns_created_contract() throws Exception {
given(catalog.create(any())).willReturn(new ProductView("p-42", "Book"));
mvc.perform(post("/api/products")
.contentType(APPLICATION_JSON)
.content("{\"name\":\"Book\"}"))
.andExpect(status().isCreated())
.andExpect(header().string("Location", "/api/products/p-42"));
}
}Synthesis & Production Verification
Deliberate Failure Condition
- Break: Test the create-order endpoint with @SpringBootTest and a real database
- Observe: The HTTP suite slows to minutes and couples to migration state it never needed
- Repair: @WebMvcTest loads only MVC machinery with mocked collaborators, keeping failures local to the HTTP contract.
Production Acceptance Criteria
- Cover success, validation failure, and 404 in one @WebMvcTest class
- Mock application collaborators rather than MockMvc internals
- Assert status, headers, and JSON body in the same test
Key Takeaway
A controller slice proves the real MVC contract with mocked application collaborators; it complements service, repository, and sparse full-context tests.
Architectural & Technical Review
Core Interview Questions
Q01What does @WebMvcTest prove that a plain controller unit test does not?Show answer
It exercises MVC route mapping, argument resolution, validation, conversion, controller advice, and configured filters around the selected controller.
Q02Which three paths should a create endpoint slice pin first?Show answer
Pin successful creation, invalid input that never reaches the service, and a known domain failure translated to the documented ProblemDetail response.
Q03Why should controller tests assert headers as well as JSON?Show answer
Status and headers such as Location and Content-Type are part of the observable HTTP contract and can break clients even when body fields remain unchanged.
Production Pitfalls
Critical Anti-Patterns
- Pitfall: Mocking MVC infrastructure — Mock the service seam; mocking converters, validators, or MockMvc itself removes the framework behavior the slice should verify.
- Pitfall: Disabling security filters to make tests green — When authorization is part of the endpoint contract, authenticate requests and load the intended chain so a real 401 regression cannot hide.
Primary Documentation & Specifications
Lesson Primary References
Supplementary Module Resources