How to Use MockMvc to Test Spring MVC Controllers
A Spring MVC controller sits at an important boundary in a Java application. It receives an HTTP request, binds input, validates data, invokes application services, and turns the result into a response. Testing that boundary with a real web server can be useful, but it is often slower and less focused than necessary.
MockMvc provides a lightweight way to exercise controllers through Spring’s MVC infrastructure without starting a servlet container. Your tests can send requests, inspect status codes and headers, verify JSON payloads, and confirm that invalid input is rejected correctly.
The approach suits Australian development teams working on REST APIs for banking, health, government, retail, and SaaS products. Whether an application serves customers in Sydney and Melbourne or supports users in regional areas through variable NBN connections, precise controller tests help keep the HTTP contract stable.
What MockMvc Actually Tests
MockMvc simulates the request-processing portion of a Spring application. It passes a request through components such as handler mappings, argument resolvers, validation, message converters, exception handlers, and the selected controller method. The test then examines the resulting MvcResult.
This makes it different from a pure unit test. A unit test might call OrderController.createOrder() directly, but it would not prove that Spring correctly maps POST /orders, deserialises JSON, applies @Valid, or serialises the response. MockMvc covers those web concerns while avoiding the overhead of launching Tomcat or Jetty.
A typical test starts with a request builder and ends with assertions:
mockMvc.perform(post("/orders")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"productId":"AUS-42","quantity":2}
"""))
.andExpect(status().isCreated())
.andExpect(header().string("Location", "/orders/123"))
.andExpect(jsonPath("$.quantity").value(2));
The fluent API keeps the request and its expectations close together. That makes failures easier to interpret than a sequence of unrelated assertions on a manually constructed response.
Choosing A Test Slice
For most controller-focused tests, @WebMvcTest is a sensible starting point. It loads MVC-related components and the controller under test while leaving out unrelated application infrastructure such as repositories and scheduled jobs. Collaborators used by the controller are commonly supplied with @MockBean, or with the newer bean override mechanisms where supported by the project’s Spring version.
@WebMvcTest(OrderController.class)
class OrderControllerTest {
@Autowired
MockMvc mockMvc;
@MockBean
OrderService orderService;
}
This style keeps the test fast and makes its dependencies explicit. If the controller relies on a service, the test can configure the service response with Mockito and concentrate on request mapping and response behaviour. It also exposes accidental coupling: a controller that needs a large portion of the application context may be doing too much.
There are cases where a slice is too narrow. MockMvcBuilders.webAppContextSetup(context) loads the full WebApplicationContext, making it useful when testing shared configuration, custom argument resolvers, filters, Jackson modules, or Spring Security rules together. For a smaller test, standaloneSetup(controller) creates MockMvc around selected controller instances without loading Spring at all.
Use the narrowest setup that proves the behaviour you care about. A test of a custom ObjectMapper module may need the application context; a test of a simple route and status code may work perfectly with standalone setup. Mixing these styles without a reason can make the suite slower and obscure what each test is verifying.
Building Requests And Checking Responses
A controller test should represent a meaningful HTTP exchange. Set the method, path, headers, query parameters, path variables, and body explicitly. MockMvcRequestBuilders provides helpers such as get, post, put, patch, and delete, while param and content model common API inputs.
For JSON requests, specify MediaType.APPLICATION_JSON as the content type. This catches a class of errors that direct method calls miss, including unsupported media types and malformed payloads. For responses, use content().contentTypeCompatibleWith(...) when a charset or vendor-specific media type may vary.
Useful assertions include:
mockMvc.perform(get("/orders/123")
.accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(123))
.andExpect(jsonPath("$.customer.email").value("customer@example.com"))
.andExpect(jsonPath("$.items").isArray());
jsonPath is convenient for selected fields, collection sizes, and nested structures. Avoid asserting every incidental property in every test. If a response adds an irrelevant field, dozens of brittle tests should not fail. Reserve complete-body comparisons for cases where the exact representation is part of the API contract.
Negative cases deserve equal attention. Check malformed JSON, missing required fields, invalid enum values, unknown resources, duplicate commands, and unsupported methods. In an Australian government or health integration, for example, a postcode or identifier may have strict formatting rules; the test should prove that bad input produces the agreed error status and useful problem details rather than a generic 500 response.
Verifying Collaborators, Errors, And Security
MockMvc can verify the controller’s interaction with its service layer, but interaction checks should support an observable contract rather than replace it. If a GET /orders/123 request returns 200 with the expected representation, that is usually more valuable than asserting the service was called once with a particular internal object. Mockito verification becomes especially helpful for commands, audit events, and ensuring that invalid requests do not reach the service.
Exception handling should be tested through the same request path as successful responses. If the application uses @RestControllerAdvice, configure the relevant advice in the test context and assert the status, content type, error code, and important message fields. A service exception mapped to 404 should remain distinct from validation failures mapped to 400 and authentication failures mapped to 401.
Security configuration changes the request setup. With Spring Security’s test support, a request may need with(user("alice")), a CSRF token for state-changing operations, or an Authorization header containing a test token. Test both access decisions and application behaviour: an unauthorised request should be rejected before the service is called, while an authorised request should reach the controller with the expected principal.
This distinction matters for Australian organisations subject to strict privacy and audit expectations. A payroll or healthcare endpoint may need to demonstrate that a staff member can access only permitted records, even when the route and payload are otherwise valid. Security-focused MockMvc tests provide a repeatable record of those boundaries without requiring a full browser session.
Handling Async Requests And Full Integration Boundaries
Some controllers return Callable, DeferredResult, or CompletableFuture, and their first response is asynchronous rather than final. In that situation, assert that the request started asynchronously, then use asyncDispatch to complete the exchange:
MvcResult result = mockMvc.perform(get("/reports/daily"))
.andExpect(request().asyncStarted())
.andReturn();
mockMvc.perform(asyncDispatch(result))
.andExpect(status().isOk())
.andExpect(jsonPath("$.state").value("READY"));
The exact sequence depends on the return type and configuration, but the principle is consistent: do not treat the initial async response as the completed business response. Testing only the first phase can leave errors in completion handling, response conversion, or exception propagation undetected.
When a controller publishes events, accesses a database, or calls a remote service, decide carefully how much of that boundary belongs in the test. MockMvc is excellent for the HTTP contract, while integration tests can verify persistence, messaging, and real serialisation together. A balanced suite avoids turning every controller test into a slow end-to-end test.
Teams spread across Brisbane, Perth, and Melbourne also need to be careful with time-sensitive assertions. Use fixed clocks and explicit offsets rather than relying on the machine’s local timezone, especially when daylight saving changes affect Sydney or Melbourne but not Queensland or Western Australia. This keeps tests deterministic in local development and CI.
A Practical Controller Testing Checklist
Strong tests are specific about externally visible behaviour and restrained about implementation details. Before adding another case, identify the route’s contract: who may call it, what input is accepted, what the response means, and how failures are represented. A useful testing reference can help teams compare patterns for validation, REST APIs, mocking, and asynchronous workflows.
- Cover the successful response for each important HTTP method and representation.
- Assert validation failures, malformed input, missing resources, and meaningful error payloads.
- Verify
Content-Type,Accept, authentication, authorisation, and CSRF behaviour where relevant. - Use
@WebMvcTestfor focused MVC tests and a full context only when configuration requires it. - Keep service stubs deterministic and verify important side effects without over-specifying internals.
- Exercise asynchronous endpoints with
request().asyncStarted()andasyncDispatch. - Fix clocks, locales, timezones, and generated identifiers so tests behave consistently in CI.
A well-designed suite also keeps test data readable. Prefer small request objects that explain the scenario over giant fixtures copied from production. For an endpoint serving Australian customers, values such as a valid postcode, an AEST timestamp, or an Australian mobile number can make the contract realistic, but the test should avoid depending on irrelevant personal details.
Name tests by behaviour rather than by implementation method. Names such as rejectsOrderWhenQuantityIsZero and returnsNotFoundWhenOrderDoesNotExist tell future maintainers what the API promises. When a route changes, these tests act as executable documentation for developers, testers, and reviewers.
MockMvc is most valuable when it sits at the right level: closer to a real HTTP request than a direct unit call, yet faster and more focused than a complete deployed-system test. Remember that reliable controller tests prove the API contract, security boundary, validation rules, and response behaviour that clients actually depend on.