Testing controllers with MockMvc
A controller's job is HTTP behaviour — routing, status codes, validation, serialisation, security — and to
test that without booting the whole application, Spring gives you @WebMvcTest with MockMvc. It
loads just the web layer, mocks the layers below, and lets you fire requests and assert on the response.
This lesson tests Dakiya's ParcelController, verified by running the tests.
@WebMvcTest: the web layer only
@WebMvcTest boots a slice of the application — the Spring MVC infrastructure and one controller — but
not the service or data layers, and not the full context. It is far faster than @SpringBootTest and
focused on exactly what a controller is responsible for. Because the layers below are not loaded, you
provide them as mocks:
@WebMvcTest(ParcelController.class) // load only this controller + MVC infrastructure
@Import(SecurityConfig.class) // include the security rules (so auth is tested realistically)
class ParcelControllerTest {
@Autowired MockMvc mvc; // the tool to make requests
@MockitoBean ParcelRepository repository; // mock the dependency the controller needs
}
@WebMvcTest(ParcelController.class) loads that controller and the web machinery; @MockitoBean puts a
Mockito mock of ParcelRepository into the (sliced) context, because the real one is not loaded. Importing
SecurityConfig makes the test exercise your actual security rules, so auth behaviour is tested for real.
(@MockitoBean is the current annotation in recent Spring Boot; older code used @MockBean.)
MockMvc: firing requests and asserting
MockMvc performs requests against the controller in-process (no real HTTP server) and lets you assert on the response — status, headers, body:
@Test
@WithMockUser // run as an authenticated user
void getOne_returns404_whenMissing() throws Exception {
when(repository.findById(999L)).thenReturn(Optional.empty());
mvc.perform(get("/api/parcels/999"))
.andExpect(status().isNotFound()); // the NotFoundException → 404 (via the advice)
}
@Test
@WithMockUser
void create_rejectsInvalidBody_with400() throws Exception {
mvc.perform(post("/api/parcels")
.contentType("application/json")
.content("{\"recipientName\":\"\",\"destinationCity\":\"P\"}"))
.andExpect(status().isBadRequest()); // @Valid fails → 400
}
mvc.perform(get(...)) fires the request; .andExpect(status().isNotFound()) asserts on the result. You can
also assert on the body (jsonPath("$.status").value(404)), headers, and content type. Verified by running:
these pass — the missing parcel produced a 404 (the controller threw NotFoundException, the advice
turned it into 404), and the invalid body produced a 400 (@Valid rejected it). So @WebMvcTest tests
the real controller, real validation, and real error handling — with only the data layer mocked.
Testing security in the web slice
Because you imported SecurityConfig, the security rules apply, and you test them:
@Test
void list_requiresAuthentication() throws Exception {
mvc.perform(get("/api/parcels")).andExpect(status().isUnauthorized()); // no @WithMockUser → 401
}
@WithMockUser (from spring-security-test) runs a test as an authenticated user; omit it and the
request is anonymous. Verified: without @WithMockUser, GET /api/parcels returned 401 — the
authentication rule enforced, in a fast slice test. Testing the denied path (anonymous → 401) is as
important as the allowed path; it is exactly the assertion that catches a security rule you accidentally
removed. @WithMockUser(roles = "ADMIN") tests role-based rules the same way.
What @WebMvcTest is for — and not
Use @WebMvcTest for web-layer behaviour:
- Routing — the right URL/method hits the right method.
- Status codes — 200/201/400/404/401/403 for each situation.
- Validation —
@Validrejects bad input with 400. - Serialisation — the response JSON has the expected shape.
- Error handling — your
@RestControllerAdvicemaps exceptions to the right responses. - Security — the endpoint's auth rules.
Do not use it to test business logic (that is a unit test — the service is mocked here anyway) or real
database queries (that is @DataJpaTest — there is no real database in a @WebMvcTest). The controller's
collaborators are mocks, so you are testing the web layer's behaviour, not the whole flow. Keep the mocks
simple — stub what the controller needs to produce each response — and let the assertions focus on the HTTP
outcome. It is the fast, focused way to pin your API's contract at the web layer.
Check your work
@WebMvcTest. Boots only the MVC infrastructure + one controller (not services/data/full context —
fast); provide dependencies as @MockitoBean. @Import(SecurityConfig.class) includes real security rules.
MockMvc. Fires requests in-process (mvc.perform(get/post(...))) and asserts on the response
(status(), jsonPath(...), headers). Verified: missing → 404 (via advice), invalid body → 400 (via
@Valid).
Security in the slice. @WithMockUser runs as authenticated (with roles); omit it for anonymous.
Verified: no @WithMockUser → 401. Test the denied path too.
What it is for. Web-layer behaviour — routing, status, validation, serialisation, error handling,
security. Not business logic (unit test) or real queries (@DataJpaTest); the collaborators are mocked.
Practice
- Write a
@WebMvcTest(ParcelController.class)with@MockitoBean ParcelRepositoryand@Import(SecurityConfig.class); test that a missing id returns 404 (reproduce the verified pass). - Test that an invalid POST body returns 400 (
@Valid), and assert on the error body withjsonPath. - Test the authenticated success path with
@WithMockUserand the anonymous path (401) without it (reproduce the verified 401). - Test a role rule with
@WithMockUser(roles="ADMIN")on an admin-only endpoint (403 for a non-admin). - Stub the repository to return a parcel and assert the response JSON shape with
jsonPath("$.trackingCode"). - Try to test business logic in a
@WebMvcTestand note the service is mocked — reason about why that belongs in a unit test instead.
Official documentation
- Spring Boot — @WebMvcTest — Slicing the web layer.
- Spring — MockMvc — Performing requests and assertions.
- Spring Security — Testing (@WithMockUser) — Authenticating in tests.
Next: testing the data layer with @DataJpaTest.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship