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 theProblemDetail 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 theDispatcherServlet 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 -
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 aProblemDetail - 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));
}
}
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 typeapplication/jsonand the body that Jackson wrote for theBookrecord. 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")andjsonPath("$.price").value(450)passed. JsonPath compares numbers by value, so theint450 matches the JSON number. - Creating a book: the POST sent a JSON body, and the test checked the 201 status, the
Locationheader, 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");
}
}
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 outidandprice. 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 aBook, using the application's own Jackson setup, and the price was then checked as a plainint. - The 404 problem: the body printed by the test is the
ProblemDetailbuilt in the controller's@ExceptionHandler: the title "Book not found", the detail, the status, andinstance, the path of the request, which Spring filled in. The test checked the 404, the content typeapplication/problem+json, and the detail. - The 400 problem: the empty title and the zero price failed
@NotBlankand@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 theMethodArgumentNotValidException; it is left out of the output above.
@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);
}
}
└─ WrongExpectationTest ✔
└─ expectsTheWrongPrice() ✘
expected: 400
but was: 450
[ 0 tests successful ]
[ 1 tests failed ]
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:
MockMvcTesteris the recommended style in current Spring. Its assertions are discoverable, it needs no checked exceptions, andbodyJson()covers JSON comparisons, paths and conversion in one place. - Existing tests:
MockMvcis 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.Spring Testing Tutorials
Mocking Beans in Spring Boot Tests with @MockitoBeanClick to read tutorial: Mocking Beans in Spring Boot Tests with @MockitoBean Testing Spring Security: @WithMockUser, CSRF and JWTClick to read tutorial: Testing Spring Security: @WithMockUser, CSRF and JWT Testing in Spring Boot: The Test Pyramid and @SpringBootTestClick to read tutorial: Testing in Spring Boot: The Test Pyramid and @SpringBootTest Spring Boot Slice Tests: @WebMvcTest, @DataJpaTest and MoreClick to read tutorial: Spring Boot Slice Tests: @WebMvcTest, @DataJpaTest and More Contract Testing with Spring Cloud ContractClick to read tutorial: Contract Testing with Spring Cloud Contract Spring Boot Testing Best Practices and Faster Test SuitesClick to read tutorial: Spring Boot Testing Best Practices and Faster Test Suites
Mocking Beans in Spring Boot Tests with @MockitoBeanClick to read tutorial: Mocking Beans in Spring Boot Tests with @MockitoBean Testing Spring Security: @WithMockUser, CSRF and JWTClick to read tutorial: Testing Spring Security: @WithMockUser, CSRF and JWT Testing in Spring Boot: The Test Pyramid and @SpringBootTestClick to read tutorial: Testing in Spring Boot: The Test Pyramid and @SpringBootTest Spring Boot Slice Tests: @WebMvcTest, @DataJpaTest and MoreClick to read tutorial: Spring Boot Slice Tests: @WebMvcTest, @DataJpaTest and More Contract Testing with Spring Cloud ContractClick to read tutorial: Contract Testing with Spring Cloud Contract Spring Boot Testing Best Practices and Faster Test SuitesClick to read tutorial: Spring Boot Testing Best Practices and Faster Test Suites