Testing REST APIs with JUnit 6: MockMvc vs WebTestClient

Testing REST APIs is one of the most common tasks in any Spring Boot project. JUnit 6 supports two primary approaches: MockMvc for servlet-based (synchronous) APIs and WebTestClient for reactive (WebFlux) APIs — though WebTestClient can also test traditional Spring MVC applications. This guide covers both tools in depth with real request/response examples, JSON path assertions, and patterns for testing authentication, error responses, and pagination.

MockMvc vs WebTestClient: When to Use Which

MockMvcWebTestClient
TransportNo real HTTP — tests the servlet layer directlyReal or mock HTTP
Best forSpring MVC (traditional Servlet)WebFlux (reactive) or both
SpeedFastest — no networking overheadSlightly slower but more realistic
Fluent APIBuilder-style with matchersReactive, fluent chain
Works in @WebMvcTest✅ Yes (auto-configured)✅ Yes (with @AutoConfigureMockMvc)

Part 1: Testing REST APIs with MockMvc

Setup and Auto-Configuration

import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.beans.factory.annotation.Autowired;
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.*;

@SpringBootTest
@AutoConfigureMockMvc // injects MockMvc with full security and filters
@DisplayName("Product API — MockMvc tests")
class ProductApiMockMvcTest {

    @Autowired
    private MockMvc mockMvc;

    @Autowired
    private ProductRepository productRepository; // used for test data setup

    @BeforeEach
    void seedTestData() {
        productRepository.deleteAll();
        productRepository.saveAll(List.of(
            new Product(null, "Laptop",     999.00, "Electronics"),
            new Product(null, "Headphones",  79.00, "Electronics"),
            new Product(null, "Notebook",    5.99,  "Stationery")
        ));
    }

    @Test
    @DisplayName("GET /api/products returns 200 with all products")
    void getAllProductsReturns200() throws Exception {
        mockMvc.perform(get("/api/products")
                .accept("application/json"))
            .andDo(print()) // prints request + response to console for debugging
            .andExpect(status().isOk())
            .andExpect(content().contentType("application/json"))
            .andExpect(jsonPath("$").isArray())
            .andExpect(jsonPath("$.length()").value(3))
            .andExpect(jsonPath("$[0].name").value("Laptop"));
    }
}

Testing POST, PUT, DELETE

import com.fasterxml.jackson.databind.ObjectMapper;

@SpringBootTest
@AutoConfigureMockMvc
class ProductCrudTest {

    @Autowired private MockMvc mockMvc;
    @Autowired private ObjectMapper objectMapper; // for serialising request body

    @Test
    @DisplayName("POST /api/products creates product and returns 201")
    void createProductReturns201() throws Exception {
        // Arrange: build request body as a Java object, serialise to JSON
        CreateProductRequest request =
            new CreateProductRequest("Keyboard", 49.99, "Electronics");
        String requestJson = objectMapper.writeValueAsString(request);

        mockMvc.perform(post("/api/products")
                .contentType("application/json")
                .content(requestJson))
            .andExpect(status().isCreated())
            .andExpect(header().exists("Location"))   // REST convention: Location header on 201
            .andExpect(jsonPath("$.id").isNotEmpty())
            .andExpect(jsonPath("$.name").value("Keyboard"))
            .andExpect(jsonPath("$.price").value(49.99));
    }

    @Test
    @DisplayName("PUT /api/products/{id} updates product and returns 200")
    void updateProductReturns200() throws Exception {
        // Seed a product first
        Product existing = productRepository.save(
            new Product(null, "OldName", 10.0, "Electronics"));

        UpdateProductRequest updateRequest =
            new UpdateProductRequest("NewName", 20.0, "Electronics");

        mockMvc.perform(put("/api/products/" + existing.getId())
                .contentType("application/json")
                .content(objectMapper.writeValueAsString(updateRequest)))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$.name").value("NewName"))
            .andExpect(jsonPath("$.price").value(20.0));
    }

    @Test
    @DisplayName("DELETE /api/products/{id} removes product and returns 204")
    void deleteProductReturns204() throws Exception {
        Product existing = productRepository.save(
            new Product(null, "ToDelete", 5.0, "Misc"));

        mockMvc.perform(delete("/api/products/" + existing.getId()))
            .andExpect(status().isNoContent());

        // Verify actually deleted
        mockMvc.perform(get("/api/products/" + existing.getId()))
            .andExpect(status().isNotFound());
    }
}

Testing Validation Errors

@Test
@DisplayName("POST /api/products returns 400 with validation errors for blank name")
void createProductWithBlankNameReturns400() throws Exception {
    // Send a product with blank name — should fail validation
    String invalidJson = "{"name":"","price":49.99,"category":"Electronics"}";

    mockMvc.perform(post("/api/products")
            .contentType("application/json")
            .content(invalidJson))
        .andExpect(status().isBadRequest())
        .andExpect(jsonPath("$.errors").isArray())
        .andExpect(jsonPath("$.errors[0].field").value("name"))
        .andExpect(jsonPath("$.errors[0].message").value("must not be blank"));
}

Part 2: Testing REST APIs with WebTestClient

import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.web.reactive.server.WebTestClient;
import org.springframework.beans.factory.annotation.Autowired;

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@DisplayName("Product API — WebTestClient integration tests")
class ProductApiWebTestClientTest {

    @Autowired
    private WebTestClient webTestClient;

    @Test
    @DisplayName("GET /api/products returns 200 and a list of products")
    void getAllProductsReturns200() {
        webTestClient.get()
            .uri("/api/products")
            .accept(MediaType.APPLICATION_JSON)
            .exchange()                          // execute the request
            .expectStatus().isOk()
            .expectHeader().contentType(MediaType.APPLICATION_JSON)
            .expectBodyList(ProductDto.class)    // deserialise response body to List
            .hasSize(3)
            .contains(new ProductDto("Laptop", 999.00));
    }

    @Test
    @DisplayName("POST /api/products creates a product and returns 201")
    void createProductReturns201() {
        CreateProductRequest request =
            new CreateProductRequest("Mouse", 29.99, "Electronics");

        webTestClient.post()
            .uri("/api/products")
            .contentType(MediaType.APPLICATION_JSON)
            .bodyValue(request)                  // automatically serialised to JSON
            .exchange()
            .expectStatus().isCreated()
            .expectBody(ProductDto.class)
            .value(product -> {
                // Use AssertJ or standard assertions on the response body
                assertNotNull(product.getId());
                assertEquals("Mouse", product.getName());
                assertEquals(29.99,   product.getPrice(), 0.001);
            });
    }

    @Test
    @DisplayName("GET /api/products/{id} returns 404 for non-existent product")
    void getNonExistentProductReturns404() {
        webTestClient.get()
            .uri("/api/products/99999")
            .exchange()
            .expectStatus().isNotFound();
    }
}

MockMvc Result Matchers Quick Reference

MatcherWhat it checks
status().isOk()HTTP 200
status().isCreated()HTTP 201
status().isBadRequest()HTTP 400
status().isNotFound()HTTP 404
status().is(403)Specific status code
content().contentType(...)Content-Type header
jsonPath("$.field").value(x)JSON field equals value
jsonPath("$").isArray()Root is a JSON array
jsonPath("$.length()").value(n)Array has n elements
header().exists("Location")Response header exists
header().string("X-Req-Id", "123")Response header value

Frequently Asked Questions (FAQs)

Q1: Should I use MockMvc or WebTestClient for Spring MVC tests?

Both work for Spring MVC. MockMvc is the traditional, well-established choice with excellent Spring Test integration and no networking overhead. WebTestClient offers a more fluent, modern API and can be used for both Spring MVC and WebFlux. For new projects, WebTestClient is increasingly preferred because it supports both paradigms with one API. For existing projects with heavy MockMvc investment, continue using it — migration is optional.

Q2: How do I test secured endpoints with MockMvc?

Add the spring-security-test dependency and use @WithMockUser or SecurityMockMvcRequestPostProcessors.jwt() on your mock request. For example: mockMvc.perform(get("/api/admin").with(user("admin").roles("ADMIN"))). This simulates an authenticated user without requiring a real authentication flow.

Q3: What is the difference between andDo(print()) and andReturn()?

andDo(print()) prints the full request and response to the console — extremely useful during debugging but should be removed from production test code. andReturn() returns an MvcResult object that lets you access the response body, headers, and status for custom assertions beyond what the built-in matchers support.

Q4: How do I test file upload endpoints with MockMvc?

Use MockMvcRequestBuilders.multipart() combined with MockMultipartFile:

MockMultipartFile file = new MockMultipartFile(
    "file", "test.csv", "text/csv", "name,pricenLaptop,999".getBytes());

mockMvc.perform(multipart("/api/products/import").file(file))
    .andExpect(status().isOk())
    .andExpect(jsonPath("$.imported").value(1));

Q5: How do I assert response body as a plain String in MockMvc?

Use content().string("expected string") for exact match, or content().string(containsString("partial")) with a Hamcrest matcher for partial matching. For large JSON responses where jsonPath assertions become tedious, consider deserializing with andReturn().getResponse().getContentAsString() and then using Jackson’s ObjectMapper for full object comparison.

See Also

Conclusion

MockMvc and WebTestClient are both excellent tools for REST API testing in Spring Boot. MockMvc is fast, battle-tested, and tightly integrated with Spring’s servlet layer. WebTestClient offers a modern fluent API that works across both Spring MVC and WebFlux. Use MockMvc in @WebMvcTest for fast, isolated controller slice tests, and WebTestClient in @SpringBootTest for end-to-end HTTP integration tests against a running server.

Next: JUnit 6 with Mockito — master mocking, stubbing, spying, and verification to isolate your units completely in any test scenario.

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.