Spring Boot Course · Lesson 11
Lesson 10's tests prove the service makes the right decisions. They say nothing about what a client actually receives: the URL that routes to a method, the UUID parsed from the path, @Valid rejecting a bad body, the JSON shape, the 201 and its Location header, the ProblemDetail for a 404. All of that is Spring MVC's work, wrapped around controller methods that are mostly one line long. This lesson tests that layer the way a client sees it — HTTP request in, HTTP response out — with the service mocked, no database, and no server.
Twenty-two tests that pin down the API's contract: BookControllerTest covers every book endpoint, success and failure — the JSON body, 201 with Location, 204, the 400 field-error map, the 404 problem, a malformed id, and the Pageable the list endpoint hands the service — and AuthorControllerTest does the same for every author endpoint, plus the Lesson 7 409. You'll use a test slice (@WebMvcTest), a mocked bean (@MockitoBean), and MockMvcTester, which sends requests through Spring MVC and asserts on the responses in AssertJ style — all with no database running.
Look at BookController.byId: one line, return BookResponse.from(books.getById(id));. Calling it directly from a test, controller.byId(id), would prove almost nothing, because nearly everything the client experiences happens around that line:
GET /api/books/{id} has to route to this method, and the {id} text has to convert to a UUID — or fail as a 400.NotFoundException from the service has to become a 404 application/problem+json body, through ApiExceptionHandler (Lesson 7).POST, @Valid has to reject a bad body before the method runs, and the success has to come back as 201 with a Location header. To test that, Spring MVC has to be running — but not the whole application. A @WebMvcTest starts only the web layer, for the one controller under test, and the service it calls is a Mockito mock. That is a test slice: the middle of the Lesson 10 pyramid. It loads @Controller and @ControllerAdvice beans, converters, filters and Spring MVC's configuration, and leaves out ordinary @Components [docs] — so no repositories, no JPA, no DataSeeder, no database. ApiExceptionHandler is a @RestControllerAdvice, so it is in the slice, and the error bodies are tested for real.
Nothing to add to pom.xml: spring-boot-starter-webmvc-test, which spring init put there in Lesson 1, brings @WebMvcTest along with JUnit, Mockito and AssertJ.
The test lives next to the controller it covers, in src/test/java/com/example/library/book/. Here is the class with its first test, GET /api/books/{id} for a book that exists:
package com.example.library.book;
import java.util.Set;import com.example.library.author.Author;import org.junit.jupiter.api.DisplayName;import org.junit.jupiter.api.Test;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;import org.springframework.http.MediaType;import org.springframework.test.context.bean.override.mockito.MockitoBean;import org.springframework.test.web.servlet.assertj.MockMvcTester;
import static org.assertj.core.api.Assertions.assertThat;import static org.mockito.Mockito.when;
@WebMvcTest(BookController.class) // only the web layer, and only this controllerclass BookControllerTest {
@Autowired MockMvcTester mvc; // sends requests through Spring MVC, no server @MockitoBean BookService books; // a Mockito mock, registered as the BookService bean
Author hunt = new Author("Andrew Hunt"); Author thomas = new Author("David Thomas"); Book book = new Book("The Pragmatic Programmer", 1999, Set.of(thomas, hunt));
@Test @DisplayName("GET /api/books/{id} returns the book as JSON, authors sorted by name") void byId_found() { when(books.getById(book.getId())).thenReturn(book);
assertThat(mvc.get().uri("/api/books/{id}", book.getId())) .hasStatusOk() .hasContentType(MediaType.APPLICATION_JSON) .bodyJson() // strict: every field, nothing extra, arrays in order .isStrictlyEqualTo(""" { "id": "%s", "title": "The Pragmatic Programmer", "year": 1999, "authors": [ { "id": "%s", "name": "Andrew Hunt" }, { "id": "%s", "name": "David Thomas" } ] } """.formatted(book.getId(), hunt.getId(), thomas.getId())); }}Read it top-down:
@WebMvcTest(BookController.class) builds the slice for this one controller. Without the argument it would load every controller, and each one's service would need a mock.@MockitoBean BookService books is the Spring counterpart of Lesson 10's @Mock. There, the test built the service itself and handed it the mocks with new. Here Spring builds BookController, and it takes the constructor argument from the application context — so that's where the mock has to be. @MockitoBean creates a Mockito mock of BookService and registers it in the context as the BookService bean, replacing the real one if the context has one and creating it if not [docs] (the slice doesn't load @Service beans, so here it's created). Spring then injects it into the controller. The object is the same kind of mock as @Mock: unstubbed methods return defaults — null for a Book or a Page — when(...) scripts it, and verify(...) interrogates it. Spring resets it after each test method [docs], clearing its stubs and recorded calls, so no test sees another's.@Autowired MockMvcTester mvc is the client. mvc.get().uri(...) builds a request; wrapping it in assertThat(...) sends it through Spring MVC's DispatcherServlet — the same routing, conversion, validation and exception handling as the real app — and gives back a response to assert on [docs]. No port is opened; the request and response are in-memory objects.hunt, thomas and book are fixtures. JUnit creates a new instance of the test class for every test method, so each test gets fresh objects with fresh UUIDv7 ids, and no test can disturb another's data.The book is built with its authors in the "wrong" order, Set.of(thomas, hunt) — and a Set has no order anyway. The expected JSON lists Hunt before Thomas, so this test also proves BookResponse.from sorts the authors by name.
@Mock or @MockitoBean?Both annotations make the same kind of Mockito mock. They differ in where the mock ends up, and that depends on one question: who calls the constructor of the class under test?
@Mock | @MockitoBean | |
|---|---|---|
| Who builds the class under test | the test, with new BookService(books, authors) | Spring, when it starts the test's context |
| Where the mock goes | into the constructor call you write | into the application context, as a bean Spring injects |
| What sets it up | @ExtendWith(MockitoExtension.class) | a Spring test annotation, here @WebMvcTest |
| Spring context | none — the test runs in milliseconds | one is started — the test costs seconds |
| In this course | BookServiceTest, AuthorServiceTest | BookControllerTest, AuthorControllerTest |
So: if you can build the class yourself and that tests what you want, use @Mock — it's the cheaper test. Use @MockitoBean only when the test needs Spring to build the class, because the behaviour under test lives in Spring. That is exactly the controller's case. You could write new BookController(mockService) and call byId(id) directly, but then no routing, no @Valid, no JSON and no ApiExceptionHandler would run — the very things this lesson tests. Spring has to build the controller, so its service has to be a bean.
Mixing them up fails at once, in different ways:
@Mock in a @WebMvcTest: nothing puts the mock into the context, so Spring can't build the controller. The test fails before it starts: Failed to load ApplicationContext … No qualifying bean of type 'com.example.library.book.BookService' available.@MockitoBean in a plain Mockito test: no Spring context is started, so nothing reads the annotation and the field stays null. new BookService(books, authors) gets nulls, and the first call fails with NullPointerException: Cannot invoke "com.example.library.book.BookRepository.findById(java.util.UUID)" because "this.books" is null.MockMvcTester, and what MockMvc looks likeThe course asserts web responses with MockMvcTester, which Spring Framework 6.2 added on top of MockMvc. @WebMvcTest auto-configures it whenever AssertJ is on the classpath [docs], so the controller tests read like Lesson 10's: assertThat(...) and a fluent chain. Much existing code uses the older API on MockMvc itself, and you will meet it. The same test looks like this there:
mockMvc.perform(get("/api/books/{id}", book.getId())) .andExpect(status().isOk()) .andExpect(jsonPath("$.title").value("The Pragmatic Programmer"));Same engine, same request — perform sends it, and each andExpect takes a Hamcrest-style matcher from a static import. Reading one style is enough to read the other.
@MockitoBean, not @MockBeanIn code written for Spring Boot 3 you'll see @MockBean doing this job. Spring Boot 4.0 removed it in favour of Spring Framework's @MockitoBean [docs], so on this project's Spring Boot it doesn't exist.
bodyJson().isStrictlyEqualTo(...) compares the response with the expected JSON in strict mode: every value must match, a field the expected JSON doesn't list fails the test, and arrays must come in the same order [docs].
Every test in this lesson writes out the whole response body and compares it strictly, so reading a test shows exactly what the client receives — every field, nothing left to guess. It is also how the test catches the changes a client would notice: a field added to BookResponse — say, an internal flag that should never leave the server — fails here, and so do authors out of name order.
You'll meet two looser checks in other code. isLenientlyEqualTo(...) lets unlisted fields through and ignores array order; extractingPath("$.title") checks one value, picked out with a JSONPath expression ($ is the document root). Both leave part of the response unwritten: the test no longer shows it, and a change there passes unnoticed. They are for a value a test can't know in advance, such as a timestamp or an id the server generates. These responses have none: the ids come from the fixtures, and everything else is fixed.
A client gets error responses as often as successes, and Lesson 7 made them a deliberate format. Two tests: a missing book, and an id that isn't a UUID at all.
3 collapsed lines
package com.example.library.book;
import java.util.Set;import java.util.UUID;import com.example.library.author.Author;import com.example.library.error.NotFoundException;4 collapsed lines
import org.junit.jupiter.api.DisplayName;import org.junit.jupiter.api.Test;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;import org.springframework.http.HttpStatus;5 collapsed lines
import org.springframework.http.MediaType;import org.springframework.test.context.bean.override.mockito.MockitoBean;import org.springframework.test.web.servlet.assertj.MockMvcTester;
import static org.assertj.core.api.Assertions.assertThat;import static org.mockito.Mockito.verifyNoInteractions;34 collapsed lines
import static org.mockito.Mockito.when;
@WebMvcTest(BookController.class) // only the web layer, and only this controllerclass BookControllerTest {
@Autowired MockMvcTester mvc; // sends requests through Spring MVC, no server @MockitoBean BookService books; // a Mockito mock, registered as the BookService bean
Author hunt = new Author("Andrew Hunt"); Author thomas = new Author("David Thomas"); Book book = new Book("The Pragmatic Programmer", 1999, Set.of(thomas, hunt));
@Test @DisplayName("GET /api/books/{id} returns the book as JSON, authors sorted by name") void byId_found() { when(books.getById(book.getId())).thenReturn(book);
assertThat(mvc.get().uri("/api/books/{id}", book.getId())) .hasStatusOk() .hasContentType(MediaType.APPLICATION_JSON) .bodyJson() // strict: every field, nothing extra, arrays in order .isStrictlyEqualTo(""" { "id": "%s", "title": "The Pragmatic Programmer", "year": 1999, "authors": [ { "id": "%s", "name": "Andrew Hunt" }, { "id": "%s", "name": "David Thomas" } ] } """.formatted(book.getId(), hunt.getId(), thomas.getId())); }
@Test @DisplayName("GET /api/books/{id} for a missing book is a 404 problem") void byId_missing() { UUID id = UUID.randomUUID(); when(books.getById(id)).thenThrow(new NotFoundException("No book with id " + id));
assertThat(mvc.get().uri("/api/books/{id}", id)) .hasStatus(HttpStatus.NOT_FOUND) .hasContentType(MediaType.APPLICATION_PROBLEM_JSON) .bodyJson() .isStrictlyEqualTo(""" { "status": 404, "title": "Resource not found", "detail": "No book with id %s", "instance": "/api/books/%s" } """.formatted(id, id)); }
@Test @DisplayName("GET /api/books/{id} with a malformed id is a 400 and never reaches the service") void byId_malformedId() { assertThat(mvc.get().uri("/api/books/{id}", "not-a-uuid")) .hasStatus(HttpStatus.BAD_REQUEST) .hasContentType(MediaType.APPLICATION_PROBLEM_JSON) .bodyJson() .isStrictlyEqualTo(""" { "status": 400, "title": "Bad Request", "detail": "Failed to convert 'id' with value: 'not-a-uuid'", "instance": "/api/books/not-a-uuid" } """);
verifyNoInteractions(books); // the request never got past Spring MVC }}byId_missing scripts the mock to throw: thenThrow is thenReturn's twin for exceptions. The service's real "no such book" logic was proven in Lesson 10; this test proves what the web layer does with the exception — ApiExceptionHandler turns it into a 404 with the application/problem+json media type, the title Resource not found, and the exception's message as the detail. That's where Lesson 10 said the exact wording belongs in a test: here it is the contract, the text the client reads.
The body has one field the handler doesn't set: instance, which Spring fills in with the request path. The client receives it, so the test lists it too.
byId_malformedId stubs nothing. not-a-uuid can't convert to a UUID, so Spring MVC answers 400 before byId is ever called — the ResponseEntityExceptionHandler that ApiExceptionHandler extends turns the conversion failure into a problem body titled Bad Request, with a detail naming the parameter and the value. That wording is Spring's, not ours, but it is what the client reads: if a Spring upgrade ever rewords it, this test fails and tells you the API's response changed. Then verifyNoInteractions(books) asserts the mock received no calls at all: the bad request was stopped at the door.
PageableGET /api/books does more in the controller than any other endpoint: @PageableDefault fills in page 0, size 20, sort by title; Lesson 8's Pageables.withIdTiebreaker appends id; and serialization-mode=via_dto decides the JSON shape. None of that is in the service, so none of it was tested yet.
package com.example.library.book;
import java.util.List;6 collapsed lines
import java.util.Set;import java.util.UUID;import com.example.library.author.Author;import com.example.library.error.NotFoundException;import org.junit.jupiter.api.DisplayName;import org.junit.jupiter.api.Test;import org.mockito.ArgumentCaptor;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;import org.springframework.data.domain.PageImpl;import org.springframework.data.domain.PageRequest;import org.springframework.data.domain.Pageable;import org.springframework.data.domain.Sort;6 collapsed lines
import org.springframework.http.HttpStatus;import org.springframework.http.MediaType;import org.springframework.test.context.bean.override.mockito.MockitoBean;import org.springframework.test.web.servlet.assertj.MockMvcTester;
import static org.assertj.core.api.Assertions.assertThat;import static org.mockito.ArgumentMatchers.any;import static org.mockito.ArgumentMatchers.eq;import static org.mockito.ArgumentMatchers.isNull;import static org.mockito.Mockito.verify;74 collapsed lines
import static org.mockito.Mockito.verifyNoInteractions;import static org.mockito.Mockito.when;
@WebMvcTest(BookController.class) // only the web layer, and only this controllerclass BookControllerTest {
@Autowired MockMvcTester mvc; // sends requests through Spring MVC, no server @MockitoBean BookService books; // a Mockito mock, registered as the BookService bean
Author hunt = new Author("Andrew Hunt"); Author thomas = new Author("David Thomas"); Book book = new Book("The Pragmatic Programmer", 1999, Set.of(thomas, hunt));
@Test @DisplayName("GET /api/books/{id} returns the book as JSON, authors sorted by name") void byId_found() { when(books.getById(book.getId())).thenReturn(book);
assertThat(mvc.get().uri("/api/books/{id}", book.getId())) .hasStatusOk() .hasContentType(MediaType.APPLICATION_JSON) .bodyJson() // strict: every field, nothing extra, arrays in order .isStrictlyEqualTo(""" { "id": "%s", "title": "The Pragmatic Programmer", "year": 1999, "authors": [ { "id": "%s", "name": "Andrew Hunt" }, { "id": "%s", "name": "David Thomas" } ] } """.formatted(book.getId(), hunt.getId(), thomas.getId())); }
@Test @DisplayName("GET /api/books/{id} for a missing book is a 404 problem") void byId_missing() { UUID id = UUID.randomUUID(); when(books.getById(id)).thenThrow(new NotFoundException("No book with id " + id));
assertThat(mvc.get().uri("/api/books/{id}", id)) .hasStatus(HttpStatus.NOT_FOUND) .hasContentType(MediaType.APPLICATION_PROBLEM_JSON) .bodyJson() .isStrictlyEqualTo(""" { "status": 404, "title": "Resource not found", "detail": "No book with id %s", "instance": "/api/books/%s" } """.formatted(id, id)); }
@Test @DisplayName("GET /api/books/{id} with a malformed id is a 400 and never reaches the service") void byId_malformedId() { assertThat(mvc.get().uri("/api/books/{id}", "not-a-uuid")) .hasStatus(HttpStatus.BAD_REQUEST) .hasContentType(MediaType.APPLICATION_PROBLEM_JSON) .bodyJson() .isStrictlyEqualTo(""" { "status": 400, "title": "Bad Request", "detail": "Failed to convert 'id' with value: 'not-a-uuid'", "instance": "/api/books/not-a-uuid" } """);
verifyNoInteractions(books); // the request never got past Spring MVC }
@Test @DisplayName("GET /api/books asks for page 0 of 20 by title then id, and returns the stable page shape") void all_defaults() { Pageable expected = PageRequest.of(0, 20, Sort.by("title", "id")); // what the controller should ask for when(books.findAll(isNull(), any(Pageable.class))) // stub loosely: any page request... .thenReturn(new PageImpl<>(List.of(book), expected, 1)); // ...gets the page answering `expected`
assertThat(mvc.get().uri("/api/books")) .hasStatusOk() .bodyJson() .isStrictlyEqualTo(""" { "content": [ { "id": "%s", "title": "The Pragmatic Programmer", "year": 1999, "authors": [ { "id": "%s", "name": "Andrew Hunt" }, { "id": "%s", "name": "David Thomas" } ] } ], "page": { "size": 20, "number": 0, "totalElements": 1, "totalPages": 1 } } """.formatted(book.getId(), hunt.getId(), thomas.getId()));
ArgumentCaptor<Pageable> pageable = ArgumentCaptor.forClass(Pageable.class); verify(books).findAll(isNull(), pageable.capture()); // ...verify exactly assertThat(pageable.getValue()).isEqualTo(expected); }
@Test @DisplayName("GET /api/books passes the author filter and the client's sort, id still last") void all_filteredAndSorted() { UUID authorId = UUID.randomUUID(); Pageable expected = PageRequest.of(1, 5, Sort.by(Sort.Order.desc("year"), Sort.Order.asc("id"))); when(books.findAll(eq(authorId), any(Pageable.class))) .thenReturn(new PageImpl<>(List.of(), expected, 1)); // one match: page 1 is past the end
assertThat(mvc.get().uri("/api/books?authorId={a}&page=1&size=5&sort=year,desc", authorId)) .hasStatusOk() .bodyJson() .isStrictlyEqualTo(""" { "content": [], "page": { "size": 5, "number": 1, "totalElements": 1, "totalPages": 1 } } """);
ArgumentCaptor<Pageable> pageable = ArgumentCaptor.forClass(Pageable.class); verify(books).findAll(eq(authorId), pageable.capture()); assertThat(pageable.getValue()).isEqualTo(expected); }}Each test names the page request the controller should make, expected, and then does three things with it.
The stub answers any request. any(Pageable.class) matches whatever Pageable the controller passes, and the mock always returns the same page: a PageImpl — the Page implementation Spring Data itself returns — holding the books, built for expected, with the total row count. It is the page a database would return for that request. So the "size": 20 and "number": 0 in the response come from the stub, not from the request: a controller that asked for size 10 would still get this page back.
What went out. The expected body is the { content, page } shape from Lesson 8: the books in content, in the same JSON as Step 1's, then the page's size, number and totals. That proves the serialization — if someone deletes serialization-mode=via_dto from application.properties, the body falls back to the raw PageImpl layout, with no page object, and this test fails; the slice reads application.properties like the real app does. In the second test the client asks for page 1 of an author's books, and the author has only one: the page is past the end, so content is empty while totalElements still says 1.
What came in. This is what proves the controller's paging. The Pageable is built inside the controller, so — exactly as with the saved Book in Lesson 10 — an ArgumentCaptor reaches it after the fact, and isEqualTo(expected) compares it in one line: a PageRequest is equal to another with the same page number, size and sort. Without query parameters that is page 0, size 20, sorted by title then id — the @PageableDefault plus the tiebreaker. In the second test the client asks for its own page, size and sort, and the tiebreaker still comes last. Change the default to size = 10 and only this line notices — the body still says 20, because the stub's page does:
expected: Page request [number: 0, size 20, sort: title: ASC,id: ASC] but was: Page request [number: 0, size 10, sort: title: ASC,id: ASC]isNull() and eq(authorId) are argument matchers like any(); Mockito requires that once one argument uses a matcher, every argument does, which is why the plain authorId is wrapped in eq(...).
Why not stub the exact request — when(books.findAll(null, expected)) — and skip the captor? Because a stub written for exact arguments simply doesn't match when the controller passes something else, and an unmatched call on a Mockito mock returns null. BookController then calls .map(...) on that null, the catch-all handler turns the NullPointerException into a 500, and the test fails with expected 200 but was 500 — true, but pointing nowhere near the bug. So the stub accepts any arguments, and the exact ones are checked afterwards — with a captor here, with verify in Step 4. The failure then lands on the argument itself, expected and actual side by side, as above.
201, Location, and what reaches the service23 collapsed lines
package com.example.library.book;
import java.util.List;import java.util.Set;import java.util.UUID;import com.example.library.author.Author;import com.example.library.error.NotFoundException;import org.junit.jupiter.api.DisplayName;import org.junit.jupiter.api.Test;import org.mockito.ArgumentCaptor;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;import org.springframework.data.domain.PageImpl;import org.springframework.data.domain.PageRequest;import org.springframework.data.domain.Pageable;import org.springframework.data.domain.Sort;import org.springframework.http.HttpStatus;import org.springframework.http.MediaType;import org.springframework.test.context.bean.override.mockito.MockitoBean;import org.springframework.test.web.servlet.assertj.MockMvcTester;
import static org.assertj.core.api.Assertions.assertThat;import static org.mockito.ArgumentMatchers.any;import static org.mockito.ArgumentMatchers.anyInt;132 collapsed lines
import static org.mockito.ArgumentMatchers.eq;import static org.mockito.ArgumentMatchers.isNull;import static org.mockito.Mockito.verify;import static org.mockito.Mockito.verifyNoInteractions;import static org.mockito.Mockito.when;
@WebMvcTest(BookController.class) // only the web layer, and only this controllerclass BookControllerTest {
@Autowired MockMvcTester mvc; // sends requests through Spring MVC, no server @MockitoBean BookService books; // a Mockito mock, registered as the BookService bean
Author hunt = new Author("Andrew Hunt"); Author thomas = new Author("David Thomas"); Book book = new Book("The Pragmatic Programmer", 1999, Set.of(thomas, hunt));
@Test @DisplayName("GET /api/books/{id} returns the book as JSON, authors sorted by name") void byId_found() { when(books.getById(book.getId())).thenReturn(book);
assertThat(mvc.get().uri("/api/books/{id}", book.getId())) .hasStatusOk() .hasContentType(MediaType.APPLICATION_JSON) .bodyJson() // strict: every field, nothing extra, arrays in order .isStrictlyEqualTo(""" { "id": "%s", "title": "The Pragmatic Programmer", "year": 1999, "authors": [ { "id": "%s", "name": "Andrew Hunt" }, { "id": "%s", "name": "David Thomas" } ] } """.formatted(book.getId(), hunt.getId(), thomas.getId())); }
@Test @DisplayName("GET /api/books/{id} for a missing book is a 404 problem") void byId_missing() { UUID id = UUID.randomUUID(); when(books.getById(id)).thenThrow(new NotFoundException("No book with id " + id));
assertThat(mvc.get().uri("/api/books/{id}", id)) .hasStatus(HttpStatus.NOT_FOUND) .hasContentType(MediaType.APPLICATION_PROBLEM_JSON) .bodyJson() .isStrictlyEqualTo(""" { "status": 404, "title": "Resource not found", "detail": "No book with id %s", "instance": "/api/books/%s" } """.formatted(id, id)); }
@Test @DisplayName("GET /api/books/{id} with a malformed id is a 400 and never reaches the service") void byId_malformedId() { assertThat(mvc.get().uri("/api/books/{id}", "not-a-uuid")) .hasStatus(HttpStatus.BAD_REQUEST) .hasContentType(MediaType.APPLICATION_PROBLEM_JSON) .bodyJson() .isStrictlyEqualTo(""" { "status": 400, "title": "Bad Request", "detail": "Failed to convert 'id' with value: 'not-a-uuid'", "instance": "/api/books/not-a-uuid" } """);
verifyNoInteractions(books); // the request never got past Spring MVC }
@Test @DisplayName("GET /api/books asks for page 0 of 20 by title then id, and returns the stable page shape") void all_defaults() { Pageable expected = PageRequest.of(0, 20, Sort.by("title", "id")); // what the controller should ask for when(books.findAll(isNull(), any(Pageable.class))) // stub loosely: any page request... .thenReturn(new PageImpl<>(List.of(book), expected, 1)); // ...gets the page answering `expected`
assertThat(mvc.get().uri("/api/books")) .hasStatusOk() .bodyJson() .isStrictlyEqualTo(""" { "content": [ { "id": "%s", "title": "The Pragmatic Programmer", "year": 1999, "authors": [ { "id": "%s", "name": "Andrew Hunt" }, { "id": "%s", "name": "David Thomas" } ] } ], "page": { "size": 20, "number": 0, "totalElements": 1, "totalPages": 1 } } """.formatted(book.getId(), hunt.getId(), thomas.getId()));
ArgumentCaptor<Pageable> pageable = ArgumentCaptor.forClass(Pageable.class); verify(books).findAll(isNull(), pageable.capture()); // ...verify exactly assertThat(pageable.getValue()).isEqualTo(expected); }
@Test @DisplayName("GET /api/books passes the author filter and the client's sort, id still last") void all_filteredAndSorted() { UUID authorId = UUID.randomUUID(); Pageable expected = PageRequest.of(1, 5, Sort.by(Sort.Order.desc("year"), Sort.Order.asc("id"))); when(books.findAll(eq(authorId), any(Pageable.class))) .thenReturn(new PageImpl<>(List.of(), expected, 1)); // one match: page 1 is past the end
assertThat(mvc.get().uri("/api/books?authorId={a}&page=1&size=5&sort=year,desc", authorId)) .hasStatusOk() .bodyJson() .isStrictlyEqualTo(""" { "content": [], "page": { "size": 5, "number": 1, "totalElements": 1, "totalPages": 1 } } """);
ArgumentCaptor<Pageable> pageable = ArgumentCaptor.forClass(Pageable.class); verify(books).findAll(eq(authorId), pageable.capture()); assertThat(pageable.getValue()).isEqualTo(expected); }
@Test @DisplayName("POST /api/books creates the book: 201, Location header, body") void create_valid() { when(books.create(any(), anyInt(), any())).thenReturn(book); // stub loosely...
assertThat(mvc.post().uri("/api/books") .contentType(MediaType.APPLICATION_JSON) .content(""" { "title": "The Pragmatic Programmer", "year": 1999, "authorIds": ["%s", "%s"] } """.formatted(hunt.getId(), thomas.getId()))) .hasStatus(HttpStatus.CREATED) .hasHeader("Location", "/api/books/" + book.getId()) .bodyJson() .isStrictlyEqualTo(""" { "id": "%s", "title": "The Pragmatic Programmer", "year": 1999, "authors": [ { "id": "%s", "name": "Andrew Hunt" }, { "id": "%s", "name": "David Thomas" } ] } """.formatted(book.getId(), hunt.getId(), thomas.getId()));
verify(books).create("The Pragmatic Programmer", 1999, Set.of(hunt.getId(), thomas.getId())); // ...verify exactly }} The request is built the way a client builds it: a Content-Type header and a body. The body is written out as JSON text, not produced by serializing a CreateBookRequest. JSON is the contract; the record is an implementation detail. If someone renamed authorIds in the record, a test that serialized the record would rename it in the request too and keep passing — while every real client broke. With the JSON written out, the test sends what clients send and fails.
On the way out, the test checks the 201, the Location header the controller builds from the saved book's id, and the body: that same book, in full.
The service call follows Step 3's rule, stub loosely and verify exactly. The stub accepts any arguments, any(), anyInt(), any(), so the request always gets a book back; the controller's own job here is unpacking the request into create(title, year, authorIds), and verify(books).create(...) checks exactly that, afterwards. The arguments are all plain values, so verify compares them directly — no captor needed.
400 that names each field188 collapsed lines
package com.example.library.book;
import java.util.List;import java.util.Set;import java.util.UUID;import com.example.library.author.Author;import com.example.library.error.NotFoundException;import org.junit.jupiter.api.DisplayName;import org.junit.jupiter.api.Test;import org.mockito.ArgumentCaptor;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;import org.springframework.data.domain.PageImpl;import org.springframework.data.domain.PageRequest;import org.springframework.data.domain.Pageable;import org.springframework.data.domain.Sort;import org.springframework.http.HttpStatus;import org.springframework.http.MediaType;import org.springframework.test.context.bean.override.mockito.MockitoBean;import org.springframework.test.web.servlet.assertj.MockMvcTester;
import static org.assertj.core.api.Assertions.assertThat;import static org.mockito.ArgumentMatchers.any;import static org.mockito.ArgumentMatchers.anyInt;import static org.mockito.ArgumentMatchers.eq;import static org.mockito.ArgumentMatchers.isNull;import static org.mockito.Mockito.verify;import static org.mockito.Mockito.verifyNoInteractions;import static org.mockito.Mockito.when;
@WebMvcTest(BookController.class) // only the web layer, and only this controllerclass BookControllerTest {
@Autowired MockMvcTester mvc; // sends requests through Spring MVC, no server @MockitoBean BookService books; // a Mockito mock, registered as the BookService bean
Author hunt = new Author("Andrew Hunt"); Author thomas = new Author("David Thomas"); Book book = new Book("The Pragmatic Programmer", 1999, Set.of(thomas, hunt));
@Test @DisplayName("GET /api/books/{id} returns the book as JSON, authors sorted by name") void byId_found() { when(books.getById(book.getId())).thenReturn(book);
assertThat(mvc.get().uri("/api/books/{id}", book.getId())) .hasStatusOk() .hasContentType(MediaType.APPLICATION_JSON) .bodyJson() // strict: every field, nothing extra, arrays in order .isStrictlyEqualTo(""" { "id": "%s", "title": "The Pragmatic Programmer", "year": 1999, "authors": [ { "id": "%s", "name": "Andrew Hunt" }, { "id": "%s", "name": "David Thomas" } ] } """.formatted(book.getId(), hunt.getId(), thomas.getId())); }
@Test @DisplayName("GET /api/books/{id} for a missing book is a 404 problem") void byId_missing() { UUID id = UUID.randomUUID(); when(books.getById(id)).thenThrow(new NotFoundException("No book with id " + id));
assertThat(mvc.get().uri("/api/books/{id}", id)) .hasStatus(HttpStatus.NOT_FOUND) .hasContentType(MediaType.APPLICATION_PROBLEM_JSON) .bodyJson() .isStrictlyEqualTo(""" { "status": 404, "title": "Resource not found", "detail": "No book with id %s", "instance": "/api/books/%s" } """.formatted(id, id)); }
@Test @DisplayName("GET /api/books/{id} with a malformed id is a 400 and never reaches the service") void byId_malformedId() { assertThat(mvc.get().uri("/api/books/{id}", "not-a-uuid")) .hasStatus(HttpStatus.BAD_REQUEST) .hasContentType(MediaType.APPLICATION_PROBLEM_JSON) .bodyJson() .isStrictlyEqualTo(""" { "status": 400, "title": "Bad Request", "detail": "Failed to convert 'id' with value: 'not-a-uuid'", "instance": "/api/books/not-a-uuid" } """);
verifyNoInteractions(books); // the request never got past Spring MVC }
@Test @DisplayName("GET /api/books asks for page 0 of 20 by title then id, and returns the stable page shape") void all_defaults() { Pageable expected = PageRequest.of(0, 20, Sort.by("title", "id")); // what the controller should ask for when(books.findAll(isNull(), any(Pageable.class))) // stub loosely: any page request... .thenReturn(new PageImpl<>(List.of(book), expected, 1)); // ...gets the page answering `expected`
assertThat(mvc.get().uri("/api/books")) .hasStatusOk() .bodyJson() .isStrictlyEqualTo(""" { "content": [ { "id": "%s", "title": "The Pragmatic Programmer", "year": 1999, "authors": [ { "id": "%s", "name": "Andrew Hunt" }, { "id": "%s", "name": "David Thomas" } ] } ], "page": { "size": 20, "number": 0, "totalElements": 1, "totalPages": 1 } } """.formatted(book.getId(), hunt.getId(), thomas.getId()));
ArgumentCaptor<Pageable> pageable = ArgumentCaptor.forClass(Pageable.class); verify(books).findAll(isNull(), pageable.capture()); // ...verify exactly assertThat(pageable.getValue()).isEqualTo(expected); }
@Test @DisplayName("GET /api/books passes the author filter and the client's sort, id still last") void all_filteredAndSorted() { UUID authorId = UUID.randomUUID(); Pageable expected = PageRequest.of(1, 5, Sort.by(Sort.Order.desc("year"), Sort.Order.asc("id"))); when(books.findAll(eq(authorId), any(Pageable.class))) .thenReturn(new PageImpl<>(List.of(), expected, 1)); // one match: page 1 is past the end
assertThat(mvc.get().uri("/api/books?authorId={a}&page=1&size=5&sort=year,desc", authorId)) .hasStatusOk() .bodyJson() .isStrictlyEqualTo(""" { "content": [], "page": { "size": 5, "number": 1, "totalElements": 1, "totalPages": 1 } } """);
ArgumentCaptor<Pageable> pageable = ArgumentCaptor.forClass(Pageable.class); verify(books).findAll(eq(authorId), pageable.capture()); assertThat(pageable.getValue()).isEqualTo(expected); }
@Test @DisplayName("POST /api/books creates the book: 201, Location header, body") void create_valid() { when(books.create(any(), anyInt(), any())).thenReturn(book); // stub loosely...
assertThat(mvc.post().uri("/api/books") .contentType(MediaType.APPLICATION_JSON) .content(""" { "title": "The Pragmatic Programmer", "year": 1999, "authorIds": ["%s", "%s"] } """.formatted(hunt.getId(), thomas.getId()))) .hasStatus(HttpStatus.CREATED) .hasHeader("Location", "/api/books/" + book.getId()) .bodyJson() .isStrictlyEqualTo(""" { "id": "%s", "title": "The Pragmatic Programmer", "year": 1999, "authors": [ { "id": "%s", "name": "Andrew Hunt" }, { "id": "%s", "name": "David Thomas" } ] } """.formatted(book.getId(), hunt.getId(), thomas.getId()));
verify(books).create("The Pragmatic Programmer", 1999, Set.of(hunt.getId(), thomas.getId())); // ...verify exactly }
@Test @DisplayName("POST /api/books with invalid fields is a 400 naming each one, and nothing is created") void create_invalid() { assertThat(mvc.post().uri("/api/books") .contentType(MediaType.APPLICATION_JSON) .content(""" { "title": " ", "year": 0, "authorIds": [] } """)) .hasStatus(HttpStatus.BAD_REQUEST) .hasContentType(MediaType.APPLICATION_PROBLEM_JSON) .bodyJson() .isStrictlyEqualTo(""" { "status": 400, "title": "Validation failed", "detail": "One or more fields are invalid.", "instance": "/api/books", "errors": { "title": "must not be blank", "year": "must be greater than 0", "authorIds": "must not be empty" } } """);
verifyNoInteractions(books); // @Valid stopped it before the controller ran }}One body breaks all three constraints from Lesson 7 at once: a title that is only a space (@NotBlank rejects whitespace), year 0 (@Positive), and an empty authorIds (@NotEmpty). The expected body is the whole problem that ApiExceptionHandler's handleMethodArgumentNotValid override builds: its title and detail, and the errors map with one entry per field, in Bean Validation's default messages. And verifyNoInteractions(books) is this layer's version of Lesson 10's verify(books, never()).save(any()): @Valid rejects the body before create runs, so the service is never asked to do anything.
The last three book endpoints: PUT returns the replaced book, DELETE returns 204 with no body — or 404 when there is nothing to delete.
26 collapsed lines
package com.example.library.book;
import java.util.List;import java.util.Set;import java.util.UUID;import com.example.library.author.Author;import com.example.library.error.NotFoundException;import org.junit.jupiter.api.DisplayName;import org.junit.jupiter.api.Test;import org.mockito.ArgumentCaptor;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;import org.springframework.data.domain.PageImpl;import org.springframework.data.domain.PageRequest;import org.springframework.data.domain.Pageable;import org.springframework.data.domain.Sort;import org.springframework.http.HttpStatus;import org.springframework.http.MediaType;import org.springframework.test.context.bean.override.mockito.MockitoBean;import org.springframework.test.web.servlet.assertj.MockMvcTester;
import static org.assertj.core.api.Assertions.assertThat;import static org.mockito.ArgumentMatchers.any;import static org.mockito.ArgumentMatchers.anyInt;import static org.mockito.ArgumentMatchers.eq;import static org.mockito.ArgumentMatchers.isNull;import static org.mockito.Mockito.doThrow;190 collapsed lines
import static org.mockito.Mockito.verify;import static org.mockito.Mockito.verifyNoInteractions;import static org.mockito.Mockito.when;
@WebMvcTest(BookController.class) // only the web layer, and only this controllerclass BookControllerTest {
@Autowired MockMvcTester mvc; // sends requests through Spring MVC, no server @MockitoBean BookService books; // a Mockito mock, registered as the BookService bean
Author hunt = new Author("Andrew Hunt"); Author thomas = new Author("David Thomas"); Book book = new Book("The Pragmatic Programmer", 1999, Set.of(thomas, hunt));
@Test @DisplayName("GET /api/books/{id} returns the book as JSON, authors sorted by name") void byId_found() { when(books.getById(book.getId())).thenReturn(book);
assertThat(mvc.get().uri("/api/books/{id}", book.getId())) .hasStatusOk() .hasContentType(MediaType.APPLICATION_JSON) .bodyJson() // strict: every field, nothing extra, arrays in order .isStrictlyEqualTo(""" { "id": "%s", "title": "The Pragmatic Programmer", "year": 1999, "authors": [ { "id": "%s", "name": "Andrew Hunt" }, { "id": "%s", "name": "David Thomas" } ] } """.formatted(book.getId(), hunt.getId(), thomas.getId())); }
@Test @DisplayName("GET /api/books/{id} for a missing book is a 404 problem") void byId_missing() { UUID id = UUID.randomUUID(); when(books.getById(id)).thenThrow(new NotFoundException("No book with id " + id));
assertThat(mvc.get().uri("/api/books/{id}", id)) .hasStatus(HttpStatus.NOT_FOUND) .hasContentType(MediaType.APPLICATION_PROBLEM_JSON) .bodyJson() .isStrictlyEqualTo(""" { "status": 404, "title": "Resource not found", "detail": "No book with id %s", "instance": "/api/books/%s" } """.formatted(id, id)); }
@Test @DisplayName("GET /api/books/{id} with a malformed id is a 400 and never reaches the service") void byId_malformedId() { assertThat(mvc.get().uri("/api/books/{id}", "not-a-uuid")) .hasStatus(HttpStatus.BAD_REQUEST) .hasContentType(MediaType.APPLICATION_PROBLEM_JSON) .bodyJson() .isStrictlyEqualTo(""" { "status": 400, "title": "Bad Request", "detail": "Failed to convert 'id' with value: 'not-a-uuid'", "instance": "/api/books/not-a-uuid" } """);
verifyNoInteractions(books); // the request never got past Spring MVC }
@Test @DisplayName("GET /api/books asks for page 0 of 20 by title then id, and returns the stable page shape") void all_defaults() { Pageable expected = PageRequest.of(0, 20, Sort.by("title", "id")); // what the controller should ask for when(books.findAll(isNull(), any(Pageable.class))) // stub loosely: any page request... .thenReturn(new PageImpl<>(List.of(book), expected, 1)); // ...gets the page answering `expected`
assertThat(mvc.get().uri("/api/books")) .hasStatusOk() .bodyJson() .isStrictlyEqualTo(""" { "content": [ { "id": "%s", "title": "The Pragmatic Programmer", "year": 1999, "authors": [ { "id": "%s", "name": "Andrew Hunt" }, { "id": "%s", "name": "David Thomas" } ] } ], "page": { "size": 20, "number": 0, "totalElements": 1, "totalPages": 1 } } """.formatted(book.getId(), hunt.getId(), thomas.getId()));
ArgumentCaptor<Pageable> pageable = ArgumentCaptor.forClass(Pageable.class); verify(books).findAll(isNull(), pageable.capture()); // ...verify exactly assertThat(pageable.getValue()).isEqualTo(expected); }
@Test @DisplayName("GET /api/books passes the author filter and the client's sort, id still last") void all_filteredAndSorted() { UUID authorId = UUID.randomUUID(); Pageable expected = PageRequest.of(1, 5, Sort.by(Sort.Order.desc("year"), Sort.Order.asc("id"))); when(books.findAll(eq(authorId), any(Pageable.class))) .thenReturn(new PageImpl<>(List.of(), expected, 1)); // one match: page 1 is past the end
assertThat(mvc.get().uri("/api/books?authorId={a}&page=1&size=5&sort=year,desc", authorId)) .hasStatusOk() .bodyJson() .isStrictlyEqualTo(""" { "content": [], "page": { "size": 5, "number": 1, "totalElements": 1, "totalPages": 1 } } """);
ArgumentCaptor<Pageable> pageable = ArgumentCaptor.forClass(Pageable.class); verify(books).findAll(eq(authorId), pageable.capture()); assertThat(pageable.getValue()).isEqualTo(expected); }
@Test @DisplayName("POST /api/books creates the book: 201, Location header, body") void create_valid() { when(books.create(any(), anyInt(), any())).thenReturn(book); // stub loosely...
assertThat(mvc.post().uri("/api/books") .contentType(MediaType.APPLICATION_JSON) .content(""" { "title": "The Pragmatic Programmer", "year": 1999, "authorIds": ["%s", "%s"] } """.formatted(hunt.getId(), thomas.getId()))) .hasStatus(HttpStatus.CREATED) .hasHeader("Location", "/api/books/" + book.getId()) .bodyJson() .isStrictlyEqualTo(""" { "id": "%s", "title": "The Pragmatic Programmer", "year": 1999, "authors": [ { "id": "%s", "name": "Andrew Hunt" }, { "id": "%s", "name": "David Thomas" } ] } """.formatted(book.getId(), hunt.getId(), thomas.getId()));
verify(books).create("The Pragmatic Programmer", 1999, Set.of(hunt.getId(), thomas.getId())); // ...verify exactly }
@Test @DisplayName("POST /api/books with invalid fields is a 400 naming each one, and nothing is created") void create_invalid() { assertThat(mvc.post().uri("/api/books") .contentType(MediaType.APPLICATION_JSON) .content(""" { "title": " ", "year": 0, "authorIds": [] } """)) .hasStatus(HttpStatus.BAD_REQUEST) .hasContentType(MediaType.APPLICATION_PROBLEM_JSON) .bodyJson() .isStrictlyEqualTo(""" { "status": 400, "title": "Validation failed", "detail": "One or more fields are invalid.", "instance": "/api/books", "errors": { "title": "must not be blank", "year": "must be greater than 0", "authorIds": "must not be empty" } } """);
verifyNoInteractions(books); // @Valid stopped it before the controller ran }
@Test @DisplayName("PUT /api/books/{id} replaces the book and returns it") void replace_valid() { when(books.replace(any(), any(), anyInt(), any())).thenReturn(book);
assertThat(mvc.put().uri("/api/books/{id}", book.getId()) .contentType(MediaType.APPLICATION_JSON) .content(""" { "title": "The Pragmatic Programmer", "year": 1999, "authorIds": ["%s", "%s"] } """.formatted(hunt.getId(), thomas.getId()))) .hasStatusOk() .bodyJson() .isStrictlyEqualTo(""" { "id": "%s", "title": "The Pragmatic Programmer", "year": 1999, "authors": [ { "id": "%s", "name": "Andrew Hunt" }, { "id": "%s", "name": "David Thomas" } ] } """.formatted(book.getId(), hunt.getId(), thomas.getId()));
verify(books).replace(book.getId(), "The Pragmatic Programmer", 1999, Set.of(hunt.getId(), thomas.getId())); }
@Test @DisplayName("DELETE /api/books/{id} is a 204 with no body") void delete_found() { assertThat(mvc.delete().uri("/api/books/{id}", book.getId())) .hasStatus(HttpStatus.NO_CONTENT) .body().isEmpty();
verify(books).delete(book.getId()); }
@Test @DisplayName("DELETE /api/books/{id} for a missing book is a 404 problem") void delete_missing() { UUID id = UUID.randomUUID(); doThrow(new NotFoundException("No book with id " + id)).when(books).delete(id); // delete is void
assertThat(mvc.delete().uri("/api/books/{id}", id)) .hasStatus(HttpStatus.NOT_FOUND) .hasContentType(MediaType.APPLICATION_PROBLEM_JSON) .bodyJson() .isStrictlyEqualTo(""" { "status": 404, "title": "Resource not found", "detail": "No book with id %s", "instance": "/api/books/%s" } """.formatted(id, id)); }}replace_valid follows create_valid's pattern, and its verify adds the one thing PUT has that POST doesn't: the id from the path has to reach the service as its first argument.
delete_found stubs nothing. BookService.delete returns void, and a mock's void method does nothing unless told otherwise — which is exactly the success case. body().isEmpty() checks that the 204 really has no body.
delete_missing needs the mock to throw from a void method, and when(...) can't express that: when(books.delete(id)) doesn't compile, because there is no return value to pass to when. Mockito's doThrow(...).when(books).delete(id) turns the sentence around: first the behaviour, then the mock, then the call it applies to.
409AuthorController gets its own slice, with an AuthorService mock, and the same coverage: every author endpoint, success and failure. Most of its tests mirror the book tests you've just written, with name in place of the book's fields; the new one is delete_stillHasBooks, the 409 Conflict from Lesson 7 — the response when an author can't be deleted because books still point at them.
package com.example.library.author;
import java.util.List;import java.util.UUID;import com.example.library.error.ConflictException;import com.example.library.error.NotFoundException;import org.junit.jupiter.api.DisplayName;import org.junit.jupiter.api.Test;import org.mockito.ArgumentCaptor;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;import org.springframework.data.domain.PageImpl;import org.springframework.data.domain.PageRequest;import org.springframework.data.domain.Pageable;import org.springframework.data.domain.Sort;import org.springframework.http.HttpStatus;import org.springframework.http.MediaType;import org.springframework.test.context.bean.override.mockito.MockitoBean;import org.springframework.test.web.servlet.assertj.MockMvcTester;
import static org.assertj.core.api.Assertions.assertThat;import static org.mockito.ArgumentMatchers.any;import static org.mockito.Mockito.doThrow;import static org.mockito.Mockito.verify;import static org.mockito.Mockito.verifyNoInteractions;import static org.mockito.Mockito.when;
@WebMvcTest(AuthorController.class)class AuthorControllerTest {
@Autowired MockMvcTester mvc; @MockitoBean AuthorService authors;
Author hunt = new Author("Andrew Hunt");
@Test @DisplayName("GET /api/authors/{id} returns the author as JSON") void byId_found() { when(authors.getById(hunt.getId())).thenReturn(hunt);
assertThat(mvc.get().uri("/api/authors/{id}", hunt.getId())) .hasStatusOk() .bodyJson() .isStrictlyEqualTo(""" { "id": "%s", "name": "Andrew Hunt" } """.formatted(hunt.getId())); }
@Test @DisplayName("GET /api/authors/{id} for a missing author is a 404 problem") void byId_missing() { UUID id = UUID.randomUUID(); when(authors.getById(id)).thenThrow(new NotFoundException("No author with id " + id));
assertThat(mvc.get().uri("/api/authors/{id}", id)) .hasStatus(HttpStatus.NOT_FOUND) .hasContentType(MediaType.APPLICATION_PROBLEM_JSON) .bodyJson() .isStrictlyEqualTo(""" { "status": 404, "title": "Resource not found", "detail": "No author with id %s", "instance": "/api/authors/%s" } """.formatted(id, id)); }
@Test @DisplayName("GET /api/authors/{id} with a malformed id is a 400 and never reaches the service") void byId_malformedId() { assertThat(mvc.get().uri("/api/authors/{id}", "not-a-uuid")) .hasStatus(HttpStatus.BAD_REQUEST) .hasContentType(MediaType.APPLICATION_PROBLEM_JSON) .bodyJson() .isStrictlyEqualTo(""" { "status": 400, "title": "Bad Request", "detail": "Failed to convert 'id' with value: 'not-a-uuid'", "instance": "/api/authors/not-a-uuid" } """);
verifyNoInteractions(authors); }
@Test @DisplayName("GET /api/authors asks for page 0 of 20 by name then id, and returns the stable page shape") void all_defaults() { Pageable expected = PageRequest.of(0, 20, Sort.by("name", "id")); when(authors.findAll(any(Pageable.class))) .thenReturn(new PageImpl<>(List.of(hunt), expected, 1));
assertThat(mvc.get().uri("/api/authors")) .hasStatusOk() .bodyJson() .isStrictlyEqualTo(""" { "content": [ { "id": "%s", "name": "Andrew Hunt" } ], "page": { "size": 20, "number": 0, "totalElements": 1, "totalPages": 1 } } """.formatted(hunt.getId()));
ArgumentCaptor<Pageable> pageable = ArgumentCaptor.forClass(Pageable.class); verify(authors).findAll(pageable.capture()); assertThat(pageable.getValue()).isEqualTo(expected); }
@Test @DisplayName("POST /api/authors creates the author: 201, Location header, body") void create_valid() { when(authors.create(any())).thenReturn(hunt);
assertThat(mvc.post().uri("/api/authors") .contentType(MediaType.APPLICATION_JSON) .content(""" { "name": "Andrew Hunt" } """)) .hasStatus(HttpStatus.CREATED) .hasHeader("Location", "/api/authors/" + hunt.getId()) .bodyJson() .isStrictlyEqualTo(""" { "id": "%s", "name": "Andrew Hunt" } """.formatted(hunt.getId()));
verify(authors).create("Andrew Hunt"); }
@Test @DisplayName("POST /api/authors with a blank name is a 400, and nothing is created") void create_blankName() { assertThat(mvc.post().uri("/api/authors") .contentType(MediaType.APPLICATION_JSON) .content(""" { "name": "" } """)) .hasStatus(HttpStatus.BAD_REQUEST) .hasContentType(MediaType.APPLICATION_PROBLEM_JSON) .bodyJson() .isStrictlyEqualTo(""" { "status": 400, "title": "Validation failed", "detail": "One or more fields are invalid.", "instance": "/api/authors", "errors": { "name": "must not be blank" } } """);
verifyNoInteractions(authors); }
@Test @DisplayName("PUT /api/authors/{id} replaces the author and returns it") void replace_valid() { when(authors.replace(any(), any())).thenReturn(hunt);
assertThat(mvc.put().uri("/api/authors/{id}", hunt.getId()) .contentType(MediaType.APPLICATION_JSON) .content(""" { "name": "Andrew Hunt" } """)) .hasStatusOk() .bodyJson() .isStrictlyEqualTo(""" { "id": "%s", "name": "Andrew Hunt" } """.formatted(hunt.getId()));
verify(authors).replace(hunt.getId(), "Andrew Hunt"); }
@Test @DisplayName("PUT /api/authors/{id} with a blank name is a 400, and nothing is replaced") void replace_blankName() { assertThat(mvc.put().uri("/api/authors/{id}", hunt.getId()) .contentType(MediaType.APPLICATION_JSON) .content(""" { "name": "" } """)) .hasStatus(HttpStatus.BAD_REQUEST) .hasContentType(MediaType.APPLICATION_PROBLEM_JSON) .bodyJson() .isStrictlyEqualTo(""" { "status": 400, "title": "Validation failed", "detail": "One or more fields are invalid.", "instance": "/api/authors/%s", "errors": { "name": "must not be blank" } } """.formatted(hunt.getId()));
verifyNoInteractions(authors); }
@Test @DisplayName("PUT /api/authors/{id} for a missing author is a 404 problem") void replace_missing() { UUID id = UUID.randomUUID(); when(authors.replace(id, "Andrew Hunt")).thenThrow(new NotFoundException("No author with id " + id));
assertThat(mvc.put().uri("/api/authors/{id}", id) .contentType(MediaType.APPLICATION_JSON) .content(""" { "name": "Andrew Hunt" } """)) .hasStatus(HttpStatus.NOT_FOUND) .hasContentType(MediaType.APPLICATION_PROBLEM_JSON) .bodyJson() .isStrictlyEqualTo(""" { "status": 404, "title": "Resource not found", "detail": "No author with id %s", "instance": "/api/authors/%s" } """.formatted(id, id)); }
@Test @DisplayName("DELETE /api/authors/{id} is a 204 with no body") void delete_found() { assertThat(mvc.delete().uri("/api/authors/{id}", hunt.getId())) .hasStatus(HttpStatus.NO_CONTENT) .body().isEmpty();
verify(authors).delete(hunt.getId()); }
@Test @DisplayName("DELETE /api/authors/{id} for a missing author is a 404 problem") void delete_missing() { UUID id = UUID.randomUUID(); doThrow(new NotFoundException("No author with id " + id)).when(authors).delete(id);
assertThat(mvc.delete().uri("/api/authors/{id}", id)) .hasStatus(HttpStatus.NOT_FOUND) .hasContentType(MediaType.APPLICATION_PROBLEM_JSON) .bodyJson() .isStrictlyEqualTo(""" { "status": 404, "title": "Resource not found", "detail": "No author with id %s", "instance": "/api/authors/%s" } """.formatted(id, id)); }
@Test @DisplayName("DELETE /api/authors/{id} for an author who still has books is a 409 problem") void delete_stillHasBooks() { UUID id = UUID.randomUUID(); doThrow(new ConflictException("Author " + id + " still has books; delete or re-author them first")) .when(authors).delete(id);
assertThat(mvc.delete().uri("/api/authors/{id}", id)) .hasStatus(HttpStatus.CONFLICT) .hasContentType(MediaType.APPLICATION_PROBLEM_JSON) .bodyJson() .isStrictlyEqualTo(""" { "status": 409, "title": "Conflict", "detail": "Author %s still has books; delete or re-author them first", "instance": "/api/authors/%s" } """.formatted(id, id)); }}The service decides whether an author still has books — Lesson 10 tested that with existsByAuthorsId. This test takes the decision as given (doThrow a ConflictException) and checks the part the client sees: 409, a problem body titled Conflict, and the message telling them what to do about it. Between the two lessons, the whole path from the database check to the HTTP response is covered, and neither test needs a database.
./mvnw test[INFO] T E S T S[INFO] Running com.example.library.author.AuthorControllerTest…2026-10-07T09:24:22.171Z INFO 321 --- [ main] c.e.library.author.AuthorControllerTest : Started AuthorControllerTest in 4.364 seconds (process running for 7.15)[INFO] Tests run: 12, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 6.750 s -- in com.example.library.author.AuthorControllerTest[INFO] Running com.example.library.author.AuthorServiceTest[INFO] Tests run: 9, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.852 s -- in com.example.library.author.AuthorServiceTest[INFO] Running com.example.library.book.BookControllerTest…2026-10-07T09:24:26.203Z INFO 321 --- [ main] c.e.library.book.BookControllerTest : Started BookControllerTest in 1.364 seconds (process running for 11.182)[INFO] Tests run: 10, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 2.426 s -- in com.example.library.book.BookControllerTest[INFO] Running com.example.library.book.BookServiceTest[INFO] Tests run: 11, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.130 s -- in com.example.library.book.BookServiceTest[INFO] Running com.example.library.LibraryApplicationTests[WARNING] Tests run: 1, Failures: 0, Errors: 0, Skipped: 1, Time elapsed: 0 s -- in com.example.library.LibraryApplicationTests…[INFO] Tests run: 43, Failures: 0, Errors: 0, Skipped: 1…[INFO] BUILD SUCCESSThe two controller classes: 12 and 10 tests, all green. The service-test counts, 9 and 11, include Lesson 10's exercises; if you wrote only its four listed tests, you'll see Tests run: 4 for BookServiceTest, no AuthorServiceTest, and a smaller total.
Unlike the service tests, each @WebMvcTest class starts a Spring context — the Spring Boot banner, and the Started … in … seconds line. The first one also pays for loading Spring's classes into the JVM (4.4 seconds here); the second starts in 1.4. That is more than a plain unit test costs, and far less than the whole application with a database. Still no PostgreSQL, no Docker, no open port.
A test you've only seen pass hasn't shown you it can catch anything. Open BookController and delete @Valid from the create method's parameter, so it reads create(@RequestBody CreateBookRequest req). Run only this class:
./mvnw test -Dtest=BookControllerTest2026-10-06T14:19:19.239Z ERROR 11678 --- [ main] c.e.library.error.ApiExceptionHandler : Unhandled exception
java.lang.NullPointerException: Cannot invoke "com.example.library.book.Book.getId()" because "saved" is null at com.example.library.book.BookController.create(BookController.java:48) ~[classes/:na]…
MockHttpServletRequest: HTTP Method = POST Request URI = /api/books Parameters = {} Headers = [Content-Type:"application/json;charset=UTF-8", Content-Length:"45"] Body = { "title": " ", "year": 0, "authorIds": [] }…Handler: Type = com.example.library.book.BookController Method = com.example.library.book.BookController#create(CreateBookRequest)…MockHttpServletResponse: Status = 500… Body = {"detail":"An unexpected error occurred.","instance":"/api/books","status":500,"title":"Internal Server Error"}…[ERROR] Failures:[ERROR] BookControllerTest.create_invalid:198 [HTTP status code]expected: 400 but was: 500[INFO][ERROR] Tests run: 10, Failures: 1, Errors: 0, Skipped: 0create_invalid fails, and the output tells the whole story, bottom to top:
expected: 400 but was: 500, at the hasStatus line of the test.MockMvcTester printed the request, the handler that ran, and the response. The Handler section shows the bad body reached BookController#create — nothing stopped it at the door any more.create, the mock had no stub for this call, returned null, and saved.getId() threw. ApiExceptionHandler's catch-all logged it and answered 500 — the same trap Step 3's stub-loosely callout describes, met for real.Without @Valid, a bad request becomes a server error instead of a 400 that tells the client which fields to fix. Of the ten book tests, only create_invalid noticed — that one line in the controller is exactly what it exists to protect.
Put @Valid back and run the tests again: all green.
No qualifying bean of type 'com.example.library.book.BookService'? The @MockitoBean field is missing, has a different type, or is annotated @Mock instead. The slice doesn't load @Service beans, so every constructor parameter of the controller needs a mock.Unable to find a @SpringBootConfiguration? The test class is in a package outside com.example.library. @WebMvcTest searches upwards from the test's package for LibraryApplication; keep test packages the same as the class under test.500 where you expected 200 or 201, and a NullPointerException in the log? A stub didn't match the call, so the mock returned null. Stub with any() and check the arguments afterwards, with a captor or verify, as in Steps 3 and 4.Answer from memory before revealing.
@WebMvcTest(BookController.class) load?BookControllerTest use @MockitoBean, not @Mock?400 tests end with verifyNoInteractions(books)?isStrictlyEqualTo check that isLenientlyEqualTo doesn't?byId_missing list "instance" in the expected body?delete_missing use doThrow(...).when(books).delete(id)?Primary source: the Spring Framework reference on MockMvc's AssertJ integration — setting up MockMvcTester, performing requests, and every kind of assertion, including the JSON support used here. Then Spring Boot's Auto-configured Spring MVC Tests for exactly what @WebMvcTest loads and how to adjust it.
Glossary: the terms this lesson introduces are in the Glossary, under Lesson 11 — your quick-reference for every term we use.
Architecture decisions: the 5 choices this lesson makes, each with its reason, are in Architecture Decisions, under Lesson 11.
Code: the project at the end of this lesson · what changed in this lesson, exercises included.