How to Test Spring Boot Controllers with MockMvc and AssertJ

Controller tests verify the boundary between HTTP clients and your Spring Boot application. They check request mappings, status codes, headers, validation, serialised JSON and error responses without starting a real web server or depending on a database.

For Java teams building APIs for Australian customers, this provides fast feedback before changes reach staging. A well-designed test can catch an incorrect postcode rule, a missing authentication requirement or a response field that breaks a mobile application used in Sydney, Melbourne or Brisbane.

Why controller tests matter

A controller should translate an HTTP request into an application call and turn the result into a predictable response. Testing this boundary with MockMvc gives you confidence that Spring MVC wiring works as expected, while AssertJ makes the checks readable.

These tests are narrower than full integration tests. They usually load only the web layer, mock the service dependency and run in milliseconds. That makes them suitable for continuous integration pipelines where every pull request needs quick, useful feedback.

Set up a focused test slice

Use @WebMvcTest when the goal is to test controllers, MVC configuration, JSON conversion and validation. Dependencies such as services are replaced with Mockito mocks using @MockBean.

@WebMvcTest(OrderController.class)
class OrderControllerTest {

    @Autowired
    MockMvc mockMvc;

    @MockBean
    OrderService orderService;

    @Autowired
    ObjectMapper objectMapper;
}

With Spring Boot 3 and Spring Security enabled, you may also need security test support on the classpath. Keeping the slice focused prevents a failing repository or external payment client from obscuring a controller problem.

Perform requests with MockMvc

MockMvc can simulate HTTP methods without opening a port. The request builder lets you specify paths, query parameters, headers and JSON bodies, while andExpect checks the basic HTTP contract.

@Test
void returnsAnOrder() throws Exception {
    given(orderService.findById(42L))
        .willReturn(new OrderResponse(42L, "PAID", new BigDecimal("129.95")));

    mockMvc.perform(get("/api/orders/42")
            .accept(MediaType.APPLICATION_JSON))
        .andExpect(status().isOk())
        .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON));
}

Use post, put, patch and delete in the same way. For a POST request, serialise a DTO with ObjectMapper instead of hand-writing JSON, which reduces escaping mistakes and keeps the test aligned with the Java model.

Assert JSON with AssertJ

Spring’s JSONPath matchers are useful for individual fields, but AssertJ is particularly effective when you first capture the response and then make several related assertions.

MvcResult result = mockMvc.perform(get("/api/orders/42"))
    .andExpect(status().isOk())
    .andReturn();

OrderResponse actual = objectMapper.readValue(
    result.getResponse().getContentAsString(),
    OrderResponse.class
);

assertThat(actual.id()).isEqualTo(42L);
assertThat(actual.status()).isEqualTo("PAID");
assertThat(actual.total()).isEqualByComparingTo("129.95");

AssertJ’s fluent style gives clear failure messages and supports collections, nested objects, dates and optional values. For Australian services, it is sensible to verify monetary values as decimals and confirm that timestamps use the agreed zone, such as Australia/Sydney, rather than relying on the developer’s local machine.

Practical Java explanations and testing examples are available in the Java development articles, which can help teams standardise patterns across different controllers.

Test validation and error responses

A controller test should cover invalid input as deliberately as valid input. For example, a request DTO might require a customer name, a valid email address and an Australian postcode between four digits. The test should verify both the 400 Bad Request status and the useful structure of the error body.

@Test
void rejectsInvalidPostcode() throws Exception {
    String body = """
        {"customerName":"Alex","email":"alex@example.com","postcode":"999"}
        """;

    mockMvc.perform(post("/api/customers")
            .contentType(MediaType.APPLICATION_JSON)
            .content(body))
        .andExpect(status().isBadRequest())
        .andExpect(jsonPath("$.errors[0].field").value("postcode"));
}

Also test missing resources, conflict responses and malformed JSON. If the API uses a global @RestControllerAdvice, these tests confirm that every failure is converted into the public error format rather than exposing an internal exception.

Include security and local API behaviour

When Spring Security protects an endpoint, use @WithMockUser or request post-processors to describe the caller. Test anonymous access, insufficient roles and successful access separately. A customer profile endpoint, for instance, may allow a signed-in user while restricting administrative order changes.

Australian-facing APIs often need checks around +61 phone numbers, state abbreviations such as NSW and VIC, and addresses that include a suburb and postcode. If an endpoint handles payments, test the status returned when a gateway rejects a card, rather than contacting a live provider from the test suite.

Keep controller tests maintainable

Name tests after observable behaviour, such as returns404WhenOrderDoesNotExist, rather than implementation details. Arrange mock responses with given, perform one meaningful request, then assert the response and verify important interactions with verify(orderService).findById(42L).

Avoid asserting every incidental JSON field. Check fields that form part of the API contract, including status, identifiers, validation messages and pagination metadata. This keeps tests stable when an internal DTO gains a harmless property or when a team in Perth and a team in Melbourne evolve separate services.

Recommendations for a reliable test suite

A balanced controller test suite should be fast enough for local development and precise enough to protect production contracts. Run it with the rest of the Maven or Gradle build, and reserve full end-to-end checks for a smaller set of critical journeys.

Use these practices when adding or reviewing tests: