MockMvc in Depth
The previous lesson introduced @WebMvcTest and showed how to wire MockMvc into a test class. This lesson digs into what you can actually do with it: how to craft every kind of HTTP request, and how to assert status codes, response headers, and JSON body content precisely and readably. By the end you will be able to verify a REST endpoint's complete contract — not just "it returned 200".
The MockMvc Request Builder Pattern
Every test starts with a static factory method from MockMvcRequestBuilders (usually statically imported as get, post, put, delete, etc.). The builder accumulates headers, parameters, request body, and content type; mockMvc.perform() then dispatches it through the full Spring MVC pipeline — handler mapping, argument resolvers, interceptors, message converters — without actually starting an HTTP server.
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.*;
// GET with a query parameter and a custom header
mockMvc.perform(
get("/api/orders")
.param("status", "PENDING")
.header("X-Tenant-Id", "acme")
.accept(MediaType.APPLICATION_JSON)
)
.andExpect(status().isOk());
The accept() method sets the Accept header, which drives Spring's content-negotiation. Always set it in API tests so you get deterministic serialisation.
Sending a Request Body
For POST / PUT endpoints that consume JSON, supply the serialised body and declare Content-Type: application/json.
import com.fasterxml.jackson.databind.ObjectMapper;
@Autowired
private ObjectMapper objectMapper; // auto-configured by @WebMvcTest
@Test
void createOrder_returnsCreated() throws Exception {
OrderRequest req = new OrderRequest("widget-9", 3, "STANDARD");
mockMvc.perform(
post("/api/orders")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req))
)
.andExpect(status().isCreated())
.andExpect(header().string("Location", org.hamcrest.Matchers.containsString("/api/orders/")));
}
Use ObjectMapper, not hand-rolled JSON strings. Auto-configuring ObjectMapper means your test uses the same serialisation settings (date formats, null handling, custom serialisers) as the running application. Hand-rolled strings silently drift when the DTO changes.
Asserting HTTP Status
MockMvcResultMatchers.status() is a ResultMatcher factory. Every standard status code has a named shorthand, and there is also a numeric escape hatch:
.andExpect(status().isOk()) // 200
.andExpect(status().isCreated()) // 201
.andExpect(status().isNoContent()) // 204
.andExpect(status().isBadRequest()) // 400
.andExpect(status().isUnauthorized()) // 401
.andExpect(status().isForbidden()) // 403
.andExpect(status().isNotFound()) // 404
.andExpect(status().is(422)) // any code via int
Pick the named form whenever possible — it documents intent more clearly in the test output than a bare integer.
Asserting Response Headers
header() gives you both equality and Hamcrest-matcher assertions:
// Exact equality
.andExpect(header().string("Content-Type", "application/json"))
// Hamcrest matcher — header exists and contains a substring
.andExpect(header().string("Location",
org.hamcrest.Matchers.startsWith("/api/orders/")))
// Assert a header is present at all
.andExpect(header().exists("X-Request-Id"))
// Assert a header is absent
.andExpect(header().doesNotExist("X-Internal-Debug"))
Test the Location header on every 201 response. RFC 9110 requires it, and many client SDKs rely on it to follow the redirect. A missing Location is a contract violation that MockMvc catches for free.
Asserting the JSON Body with jsonPath()
The most expressive way to verify JSON responses is jsonPath(), backed by the JsonPath library (included transitively by spring-boot-starter-test). It uses a dot-notation path language similar to XPath.
mockMvc.perform(get("/api/orders/42").accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(42))
.andExpect(jsonPath("$.status").value("PENDING"))
.andExpect(jsonPath("$.items").isArray())
.andExpect(jsonPath("$.items.length()").value(3))
.andExpect(jsonPath("$.items[0].sku").value("widget-9"))
.andExpect(jsonPath("$.customer.email").exists())
.andExpect(jsonPath("$.internalAuditLog").doesNotExist()); // must NOT leak
The $ root token, . child operator, [] array subscript, and length() function cover the vast majority of assertions. For richer logic you can pass any Hamcrest matcher as the second argument to jsonPath():
import static org.hamcrest.Matchers.*;
// Total price is between 50 and 500
.andExpect(jsonPath("$.totalPrice", allOf(greaterThan(50.0), lessThan(500.0))))
// Status is one of a valid set
.andExpect(jsonPath("$.status", oneOf("PENDING", "PROCESSING")))
// Array contains at least one item with sku "widget-9"
.andExpect(jsonPath("$.items[*].sku", hasItem("widget-9")))
Asserting the Full JSON Body with content()
For small, stable response payloads where you want to verify the entire document, content().json() is cleaner than a list of jsonPath lines. It ignores extra fields by default (non-strict mode), or you can enforce exact equality:
// Non-strict: asserts every declared field matches; ignores additional fields
.andExpect(content().json("""
{
"id": 42,
"status": "PENDING"
}
"""))
// Strict: the response must contain EXACTLY these fields and no others
.andExpect(content().json("""
{ "id": 42, "status": "PENDING", "items": [] }
""", true))
Prefer jsonPath() for large or dynamic responses. Strict content().json() breaks whenever a new field is added to the DTO, even if the field is irrelevant to the test. Reserve it for tiny, intentionally stable contracts (e.g., an error envelope shape).
Printing the Response for Debugging
When a test fails and you cannot see why, chain andDo(print()) before the assertions. It dumps the full request/response — status, headers, body — to the console:
mockMvc.perform(get("/api/orders/99"))
.andDo(print()) // remove before committing
.andExpect(status().isNotFound());
There is also andReturn() if you need the raw MvcResult object (for instance, to extract a generated ID from the response body and use it in a subsequent request):
MvcResult result = mockMvc.perform(post("/api/orders")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req)))
.andExpect(status().isCreated())
.andReturn();
String location = result.getResponse().getHeader("Location");
long newId = Long.parseLong(location.substring(location.lastIndexOf('/') + 1));
Putting It All Together — A Complete Controller Test
@WebMvcTest(OrderController.class)
class OrderControllerTest {
@Autowired MockMvc mockMvc;
@Autowired ObjectMapper objectMapper;
@MockBean OrderService orderService;
@Test
void getOrder_found_returns200WithBody() throws Exception {
OrderResponse resp = new OrderResponse(42L, "PENDING", List.of());
given(orderService.findById(42L)).willReturn(resp);
mockMvc.perform(get("/api/orders/42").accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(42))
.andExpect(jsonPath("$.status").value("PENDING"))
.andExpect(jsonPath("$.items").isArray());
}
@Test
void getOrder_notFound_returns404() throws Exception {
given(orderService.findById(99L))
.willThrow(new OrderNotFoundException(99L));
mockMvc.perform(get("/api/orders/99").accept(MediaType.APPLICATION_JSON))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.message").exists());
}
@Test
void createOrder_validBody_returns201WithLocation() throws Exception {
OrderRequest req = new OrderRequest("widget-9", 3, "STANDARD");
OrderResponse resp = new OrderResponse(7L, "PENDING", List.of());
given(orderService.create(any())).willReturn(resp);
mockMvc.perform(post("/api/orders")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req)))
.andExpect(status().isCreated())
.andExpect(header().string("Location", containsString("/api/orders/7")))
.andExpect(jsonPath("$.id").value(7));
}
}
Summary
MockMvc's fluent API gives you surgical control over every dimension of an HTTP interaction. Use param(), header(), contentType(), and content() to build requests; use status(), header(), and jsonPath() (backed by Hamcrest matchers) to assert the response contract. Assert only what the test actually cares about — keep each test focused, use andDo(print()) to debug failures, and reach for andReturn() when you need to chain requests. The next lesson moves the focus from the service layer to the persistence layer with @DataJpaTest.