Testing REST APIs with MockMvc and MockMvcTester

Introduction

This tutorial explains how to test a Spring MVC REST API with MockMvc, and with its newer AssertJ-based companion, MockMvcTester. We will see what a MockMvc request runs and what it skips, and test a small book API in both styles: reading and creating books, checking status codes, headers and JSON, turning a JSON body back into an object, and checking the ProblemDetail error responses for a missing book and an invalid request. Finally, we will make an assertion fail on purpose, to see what a failure report looks like.

What MockMvc is

MockMvc sends requests to Spring MVC without starting a server. The test builds a mock HTTP request, MockMvc passes it through the servlet filters and the DispatcherServlet to the controller, exactly as a real request would go, and hands back a mock response to check. All of it happens in the test's own JVM and thread, so it is fast, and a failing test points at the line in the controller that failed - A MockMvc request - the test sends a mock request through the filters and the DispatcherServlet to the real controller, and the response comes back through Jackson as a mock response, with no server and no network MockMvc is available in any test that has a Spring MVC context: a @WebMvcTest slice (see slice testsClick to read tutorial: Spring Boot Slice Tests: @WebMvcTest, @DataJpaTest and More), or @SpringBootTest with @AutoConfigureMockMvc. Since Spring Framework 6.2 there are two ways to use it. The classic MockMvc, with perform() and chains of andExpect() matchers, and MockMvcTester, which wraps the same engine in AssertJ assertions. Spring Boot creates both beans; a test injects whichever it uses.

The application

A REST controller reads and creates books, kept in an in-memory store. A missing book is answered with a ProblemDetail - the standard JSON error format of RFC 9457 - and a book that fails validation is answered with Spring's built-in ProblemDetail for a bad request -
src/main/java/.../Book.java
//Book.java
package com.javabrahman.testing.mockmvc;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Positive;

public record Book(Long id, @NotBlank String title, @NotBlank String author, @Positive int price) {
}
src/main/java/.../BookStore.java - an in-memory store
//BookStore.java
package com.javabrahman.testing.mockmvc;

import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
import org.springframework.stereotype.Component;

@Component
public class BookStore {
  private final Map<Long, Book> books = new ConcurrentHashMap<>(Map.of(1L, new Book(1L, "Effective Java", "Joshua Bloch", 450)));
  private final AtomicLong ids = new AtomicLong(1);

  public Optional<Book> find(long id) {
    return Optional.ofNullable(books.get(id));
  }

  public Book add(Book book) {
    Book saved = new Book(ids.incrementAndGet(), book.title(), book.author(), book.price());
    books.put(saved.id(), saved);
    return saved;
  }
}
src/main/java/.../BookApi.java - the REST controller
//BookApi.java
package com.javabrahman.testing.mockmvc;

import jakarta.validation.Valid;
import java.net.URI;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/books")
public class BookApi {
  private final BookStore store;

  public BookApi(BookStore store) {
    this.store = store;
  }

  @GetMapping("/{id}")
  public Book get(@PathVariable long id) {
    return store.find(id).orElseThrow(() -> new BookNotFound(id));
  }

  @PostMapping
  public ResponseEntity<Book> add(@Valid @RequestBody Book book) {
    Book saved = store.add(book);
    return ResponseEntity.created(URI.create("/api/books/" + saved.id())).body(saved);
  }

  @ExceptionHandler
  ProblemDetail notFound(BookNotFound e) {
    ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, "No book with id " + e.id);
    problem.setTitle("Book not found");
    return problem;
  }

  static class BookNotFound extends RuntimeException {
    final long id;

    BookNotFound(long id) {
      this.id = id;
    }
  }
}
src/main/java/.../ApiApp.java
//ApiApp.java
package com.javabrahman.testing.mockmvc;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class ApiApp {

  public static void main(String[] args) {
    SpringApplication.run(ApiApp.class, args);
  }
}
src/main/resources/application.properties
spring.mvc.problemdetails.enabled=true
spring.mvc.problemdetails.enabled=true makes Spring answer its own errors, such as a failed validation, as ProblemDetail JSON with the content type application/problem+json.

The classic style: perform() and andExpect()

The test class is a @WebMvcTest of BookApi. The store is so simple and fast that the test imports the real one, instead of mocking it. perform() sends a request, built with the static methods get() and post(), and each andExpect() checks one part of the response. jsonPath() reads a value out of the JSON body with a JsonPath expression, $ standing for the whole document. andDo(print()) prints the request and the response, which helps while writing a test -
src/test/java/.../BookApiMockMvcTest.java - the classic MockMvc style
//BookApiMockMvcTest.java
package com.javabrahman.testing.mockmvc;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.context.annotation.Import;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;

@WebMvcTest(BookApi.class)
@Import(BookStore.class)                       // the real, in-memory store instead of a mock
class BookApiMockMvcTest {

  @Autowired
  MockMvc mvc;

  @Test
  void getsABook() throws Exception {
    mvc.perform(get("/api/books/1"))
        .andDo(print())                        // prints the request and response
        .andExpect(status().isOk())
        .andExpect(jsonPath("$.title").value("Effective Java"))
        .andExpect(jsonPath("$.price").value(450));
  }

  @Test
  void createsABook() throws Exception {
    mvc.perform(post("/api/books").contentType(MediaType.APPLICATION_JSON)
            .content("""
                {"title": "Clean Code", "author": "Robert Martin", "price": 380}"""))
        .andExpect(status().isCreated())
        .andExpect(header().string("Location", "/api/books/2"))
        .andExpect(jsonPath("$.id").value(2));
  }
}
 OUTPUT - MockMvc, with print() for the GET
MockHttpServletRequest:
      HTTP Method = GET
      Request URI = /api/books/1
       Parameters = {}
          Headers = []
             Body = null
    Session Attrs = {}

Handler:
             Type = com.javabrahman.testing.mockmvc.BookApi
           Method = com.javabrahman.testing.mockmvc.BookApi#get(long)

Async:
    Async started = false
     Async result = null

Resolved Exception:
             Type = null

ModelAndView:
        View name = null
             View = null
            Model = null

FlashMap:
       Attributes = null

MockHttpServletResponse:
           Status = 200
    Error message = null
          Headers = [Content-Type:"application/json"]
     Content type = application/json
             Body = {"id":1,"title":"Effective Java","author":"Joshua Bloch","price":450}
    Forwarded URL = null
   Redirected URL = null
          Cookies = []
╷
└─ JUnit Jupiter ✔
   └─ BookApiMockMvcTest ✔
      ├─ createsABook() ✔
      └─ getsABook() ✔

[         2 containers found      ]
[         0 containers skipped    ]
[         2 containers started    ]
[         0 containers aborted    ]
[         2 containers successful ]
[         0 containers failed     ]
[         2 tests found           ]
[         0 tests skipped         ]
[         2 tests started         ]
[         0 tests aborted         ]
[         2 tests successful      ]
[         0 tests failed          ]

Explanation of the output

  • What print() shows: the request (method, URI, headers, body), the handler that served it - BookApi#get(long) - any exception that was resolved, and the response: status 200, the content type application/json and the body that Jackson wrote for the Book record. When a MockMvc test fails for an unclear reason, this is the first thing to look at.
  • Reading a book: jsonPath("$.title").value("Effective Java") and jsonPath("$.price").value(450) passed. JsonPath compares numbers by value, so the int 450 matches the JSON number.
  • Creating a book: the POST sent a JSON body, and the test checked the 201 status, the Location header, and the id that the store assigned. The two tests share the store, so the id depends on the order of the tests; the test that creates books is the only one that relies on it here.

The AssertJ style: MockMvcTester

MockMvcTester builds the request fluently - mvc.get().uri(...) - and is checked with AssertJ's assertThat(), like any other value. It needs no static imports of matchers and no throws Exception, and the assertions read as a sentence. bodyJson() offers JSON-specific checks: comparing the body with an expected document, extracting a JsonPath value, or converting the body into an object. Where a test needs the raw response, exchange() performs the request and returns an MvcTestResult, which can both be inspected and passed to assertThat() -
src/test/java/.../BookApiMockMvcTesterTest.java - the same API with MockMvcTester
//BookApiMockMvcTesterTest.java
package com.javabrahman.testing.mockmvc;

import static org.assertj.core.api.Assertions.assertThat;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.context.annotation.Import;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.assertj.MockMvcTester;
import org.springframework.test.web.servlet.assertj.MvcTestResult;

@WebMvcTest(BookApi.class)
@Import(BookStore.class)
class BookApiMockMvcTesterTest {

  @Autowired
  MockMvcTester mvc;

  @Test
  void getsABook() {
    assertThat(mvc.get().uri("/api/books/{id}", 1))
        .hasStatusOk()
        .hasContentType(MediaType.APPLICATION_JSON)
        .bodyJson().isLenientlyEqualTo("""
            {"title": "Effective Java", "author": "Joshua Bloch"}""");
  }

  @Test
  void convertsTheBodyToAnObject() {
    assertThat(mvc.get().uri("/api/books/1"))
        .bodyJson().convertTo(Book.class)
        .satisfies(book -> assertThat(book.price()).isEqualTo(450));
  }

  @Test
  void unknownBookIsAProblem() throws Exception {
    MvcTestResult result = mvc.get().uri("/api/books/99").exchange();
    System.out.println("   404 body: " + result.getResponse().getContentAsString());
    assertThat(result)
        .hasStatus(404)
        .hasContentType(MediaType.APPLICATION_PROBLEM_JSON)
        .bodyJson().extractingPath("$.detail").isEqualTo("No book with id 99");
  }

  @Test
  void invalidBookIsAProblem() throws Exception {
    MvcTestResult result = mvc.post().uri("/api/books").contentType(MediaType.APPLICATION_JSON)
        .content("""
            {"title": "", "author": "Ann", "price": 0}""").exchange();
    System.out.println("   400 body: " + result.getResponse().getContentAsString());
    assertThat(result).hasStatus(400).bodyJson().extractingPath("$.title").isEqualTo("Bad Request");
  }
}
 OUTPUT - MockMvcTester
   400 body: {"detail":"Invalid request content.","instance":"/api/books","status":400,"title":"Bad Request"}
   404 body: {"detail":"No book with id 99","instance":"/api/books/99","status":404,"title":"Book not found"}
╷
└─ JUnit Jupiter ✔
   └─ BookApiMockMvcTesterTest ✔
      ├─ convertsTheBodyToAnObject() ✔
      ├─ invalidBookIsAProblem() ✔
      ├─ unknownBookIsAProblem() ✔
      └─ getsABook() ✔

[         2 containers found      ]
[         0 containers skipped    ]
[         2 containers started    ]
[         0 containers aborted    ]
[         2 containers successful ]
[         0 containers failed     ]
[         4 tests found           ]
[         0 tests skipped         ]
[         4 tests started         ]
[         0 tests aborted         ]
[         4 tests successful      ]
[         0 tests failed          ]

Explanation of the output

  • A lenient JSON comparison: isLenientlyEqualTo() passed although the expected document left out id and price. Lenient means extra fields in the actual body, and the order of fields, do not matter; isStrictlyEqualTo() would require the exact set of fields.
  • The body as an object: convertTo(Book.class) turned the JSON back into a Book, using the application's own Jackson setup, and the price was then checked as a plain int.
  • The 404 problem: the body printed by the test is the ProblemDetail built in the controller's @ExceptionHandler: the title "Book not found", the detail, the status, and instance, the path of the request, which Spring filled in. The test checked the 404, the content type application/problem+json, and the detail.
  • The 400 problem: the empty title and the zero price failed @NotBlank and @Positive, and the controller was never called. Spring answered with its standard problem "Bad Request", detail "Invalid request content.". Spring also logs a line saying it resolved the MethodArgumentNotValidException; it is left out of the output above.
The standard 400 problem does not say which fields were wrong. To return them, an application adds them to the problem, in a @RestControllerAdvice that handles MethodArgumentNotValidException; a test would then check them with extractingPath() as above.

When an assertion fails

A test is only useful if its failure explains itself. This test expects the wrong price -
src/test/java/.../WrongExpectationTest.java - an assertion that fails
//WrongExpectationTest.java
package com.javabrahman.testing.mockmvc;

import static org.assertj.core.api.Assertions.assertThat;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.context.annotation.Import;
import org.springframework.test.web.servlet.assertj.MockMvcTester;

@WebMvcTest(BookApi.class)
@Import(BookStore.class)
class WrongExpectationTest {

  @Autowired
  MockMvcTester mvc;

  @Test
  void expectsTheWrongPrice() {
    assertThat(mvc.get().uri("/api/books/1"))
        .hasStatusOk()
        .bodyJson().extractingPath("$.price").isEqualTo(400);
  }
}
 OUTPUT - a failing assertion (the lines about the failure)
   └─ WrongExpectationTest ✔
      └─ expectsTheWrongPrice() ✘
               expected: 400
                but was: 450
[         0 tests successful      ]
[         1 tests failed          ]
AssertJ reports the expected and the actual value, 400 and 450, in a plain message. The classic jsonPath("$.price").value(400) also reports both values when it fails. The difference shows in longer tests: an AssertJ chain names what it was checking at each step, and IDEs complete it, since it is ordinary Java methods rather than static matchers.

MockMvc or MockMvcTester?

  • New tests: MockMvcTester is the recommended style in current Spring. Its assertions are discoverable, it needs no checked exceptions, and bodyJson() covers JSON comparisons, paths and conversion in one place.
  • Existing tests: MockMvc is not deprecated, and both run on the same engine. There is no need to rewrite working tests; the two styles can live in one project, and even in one test class.
  • Neither replaces a real HTTP test: MockMvc does not start Tomcat. A few end-to-end tests with RANDOM_PORT, shown in the test pyramid tutorialClick to read tutorial: Testing in Spring Boot: The Test Pyramid and @SpringBootTest, cover what the diagram above lists as not tested.

Summary

In this tutorial we tested a REST API with MockMvc, which runs requests through the full Spring MVC stack without a server. In the classic style, perform(), andExpect() and jsonPath() checked a GET and a POST, and print() showed the whole exchange. In the AssertJ style, MockMvcTester compared JSON leniently, converted a body into a record, and checked the ProblemDetail answers for a missing book and an invalid one. A deliberately wrong expectation showed AssertJ's failure message. The next tutorial adds Spring Security to the picture: testing secured endpointsClick to read tutorial: Testing Spring Security: @WithMockUser, CSRF and JWT.