Spring Boot Course · Lesson 13
Three lessons of tests, and each one tested a layer on its own: Lesson 10 the services with fake repositories, Lesson 11 the controllers with fake services, Lesson 12 the repositories inside a test transaction. None of them has sent a request through the whole application the way a client does. This lesson does: it starts the Library API on a real server, against a real PostgreSQL, and calls it over HTTP — and it shows a bug that only such a test reports for what it is.
LibraryApplicationTests running again after eight lessons switched off, and LibraryApiIntegrationTest: three integration tests that start the whole application on a random port and talk to it with RestTestClient. They create authors and books through the API, read them back, list them and hit a 409, every response compared in full. Along the way you'll break one annotation and watch three test classes give three different verdicts on the same change.
Follow one request, GET /api/books/{id}, through the running app. Tomcat hands it to a thread; Spring MVC calls BookController.byId; the controller calls BookService.getById, which calls BookRepository.findById. The repository's transaction starts and ends inside that call. Back in the controller, BookResponse.from(book) reads the book's authors, and Jackson writes the JSON.
That last part runs after the transaction is over. Lesson 9 turned Open Session in View off, so by the time the controller maps the book, the session is closed: whatever the repository didn't load is gone for good, and touching it throws LazyInitializationException. The @EntityGraph on findById is what keeps that from happening.
None of the tests so far runs that sequence:
Book it returns was built in memory, with its authors in a plain Set; there is nothing lazy to load.An integration test runs the parts together: the real configuration, the real beans, the real database and, here, a real HTTP server. It is slower than a slice and says less about where a failure is, so the course keeps a few of them on top of the slices, one per path through the whole stack that matters.
The integration tests start the whole application with @SpringBootTest on a random port and call it over real HTTP with RestTestClient, against the same Testcontainers PostgreSQL as Lesson 12. The request then runs on a server thread, the transactions open and close as they do in production, and the client sees exactly what a real client would.
The project has had an integration test since Lesson 1: Spring Initializr generated LibraryApplicationTests, with one empty test, contextLoads. Lesson 5 switched it off with @Disabled, because starting the application needs a database. TestcontainersConfiguration is that database now. Delete the @Disabled line and its import, and import the configuration instead:
package com.example.library;
import org.junit.jupiter.api.Test;import org.springframework.boot.test.context.SpringBootTest;import org.springframework.context.annotation.Import; // new
@SpringBootTest@Import(TestcontainersConfiguration.class) // new: the whole app needs a databaseclass LibraryApplicationTests { @Test void contextLoads() { }}@SpringBootTest builds the application's whole context, not a slice. It finds the configuration by searching up from the test's package for the @SpringBootApplication class [docs] — LibraryApplication — and loads everything it would load in production: every controller, service and repository, application.properties, Liquibase, and DataSeeder.contextLoads that's enough.contextLoads has no body, and it doesn't need one. The test fails if the context can't start: a broken migration, an entity that doesn't match its table, a bean that can't be created, a seeder that throws.Run it:
./mvnw test -Dtest=LibraryApplicationTests[INFO] Running com.example.library.LibraryApplicationTests…12:46:05.271 [main] INFO org.springframework.boot.test.context.SpringBootTestContextBootstrapper -- Found @SpringBootConfiguration com.example.library.LibraryApplication for test class com.example.library.LibraryApplicationTests…2026-10-10T12:46:10.860Z INFO 3203 --- [ main] tc.postgres:18 : Container postgres:18 started in PT1.41021916S2026-10-10T12:46:10.861Z INFO 3203 --- [ main] tc.postgres:18 : Container is started (JDBC URL: jdbc:postgresql://172.18.0.1:32778/test?loggerLevel=OFF)…2026-10-10T12:46:13.858Z INFO 3203 --- [ main] liquibase.ui : Liquibase: Update has been successful. Rows affected: 0…2026-10-10T12:46:18.627Z INFO 3203 --- [ main] c.e.library.LibraryApplicationTests : Started LibraryApplicationTests in 13.074 seconds (process running for 14.771)…[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 15.13 s -- in com.example.library.LibraryApplicationTests…[INFO] BUILD SUCCESSFound @SpringBootConfiguration com.example.library.LibraryApplication is the search from the test's package up to the application class. (@SpringBootApplication includes @SpringBootConfiguration, which is what the search looks for.)TestcontainersConfiguration, a fresh database, the whole changelog.Started LibraryApplicationTests: every bean was created, ddl-auto=validate accepted the tables, and DataSeeder ran — the whole startup the app goes through, in a test. There is no Tomcat started line: in the mock environment no server is listening.contextLoads checks something real: the application can start. What it doesn't check is whether the application works — no request has been sent.
The integration tests go in a class of their own, next to LibraryApplicationTests in the base package: they cover both features, books and authors. The first test creates two authors and a book through the API, then follows the Location header the 201 came with and reads the book back:
package com.example.library;
import java.util.UUID;import com.example.library.author.AuthorRepository;import com.example.library.author.AuthorResponse;import com.example.library.book.BookRepository;import com.example.library.book.BookResponse;import org.junit.jupiter.api.BeforeEach;import org.junit.jupiter.api.DisplayName;import org.junit.jupiter.api.Test;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureRestTestClient;import org.springframework.boot.test.context.SpringBootTest;import org.springframework.boot.test.context.SpringBootTest.WebEnvironment;import org.springframework.context.annotation.Import;import org.springframework.http.MediaType;import org.springframework.test.json.JsonCompareMode;import org.springframework.test.web.servlet.client.EntityExchangeResult;import org.springframework.test.web.servlet.client.RestTestClient;
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) // the whole app, on a real server and a free port@AutoConfigureRestTestClient // a RestTestClient pointed at that server@Import(TestcontainersConfiguration.class) // ...and a real PostgreSQLclass LibraryApiIntegrationTest {
@Autowired RestTestClient client; // sends real HTTP requests to the running app @Autowired BookRepository books; // used only to empty the tables @Autowired AuthorRepository authors;
@BeforeEach void emptyTables() { books.deleteAll(); // books first: their book_author rows point at the authors authors.deleteAll(); }
@Test @DisplayName("a book created with POST can be read back from its Location") void create_thenReadBack() { UUID hunt = createAuthor("Andrew Hunt"); UUID thomas = createAuthor("David Thomas");
EntityExchangeResult<BookResponse> created = client.post().uri("/api/books") .contentType(MediaType.APPLICATION_JSON) .body(""" { "title": "The Pragmatic Programmer", "year": 1999, "authorIds": ["%s", "%s"] } """.formatted(hunt, thomas)) .exchange() .expectStatus().isCreated() .expectBody(BookResponse.class) .returnResult(); UUID id = created.getResponseBody().id();
client.get().uri(created.getResponseHeaders().getLocation()) // follow the Location header .exchange() .expectStatus().isOk() .expectHeader().contentType(MediaType.APPLICATION_JSON) .expectBody().json(""" { "id": "%s", "title": "The Pragmatic Programmer", "year": 1999, "authors": [ { "id": "%s", "name": "Andrew Hunt" }, { "id": "%s", "name": "David Thomas" } ] } """.formatted(id, hunt, thomas), JsonCompareMode.STRICT); }
// POSTs an author and returns the id the API gave it. UUID createAuthor(String name) { return client.post().uri("/api/authors") .contentType(MediaType.APPLICATION_JSON) .body(""" { "name": "%s" } """.formatted(name)) .exchange() .expectStatus().isCreated() .expectBody(AuthorResponse.class) .returnResult() .getResponseBody() .id(); }}The three annotations on the class:
webEnvironment = WebEnvironment.RANDOM_PORT starts the embedded Tomcat, as ./mvnw spring-boot:run does, but on a random free port [docs]. The tests never collide with an app you have running on 8080, or with another test run.@AutoConfigureRestTestClient gives the test a RestTestClient already pointed at that port [docs]. RestTestClient is Spring's HTTP client for tests: build a request, exchange() it, then chain expectations on the response [docs]. Nothing to add to pom.xml: it comes with spring-boot-starter-webmvc-test, the test starter from Lesson 11.@Import(TestcontainersConfiguration.class) is Lesson 12's database, as in Step 1.And the test:
expectBody(BookResponse.class) and returnResult() leave the expectation chain with the response in hand. The test needs two things from it that it can't know in advance: the new book's id, read from the body, and its address, from the Location header. Reading the body into BookResponse is a convenience for getting the id; what the API sends is checked by the strict JSON below.json(…, JsonCompareMode.STRICT) is RestTestClient's version of Lesson 11's isStrictlyEqualTo: every field, nothing extra, arrays in order.createAuthor is a helper, not a test: it posts an author, expects a 201, and returns the id. The book test needs real authors in the database before it can post a book that points at them.Lesson 12's tests left nothing behind, because each ran in a transaction that was rolled back. That trick doesn't work here. The test and the server run on different threads, each with its own transactions, so a @Transactional test would roll back only its own: whatever the server committed stays committed [docs]. And Spring's test framework caches the application context, so every test in the class talks to the same server and the same database [docs]. Whatever one test creates is there for the next.
There is also data before the first test: DataSeeder is a bean of the application, so it ran when the context started and added its three books — Clean Code, Effective Java and Domain-Driven Design.
So every integration test starts from empty tables: @BeforeEach deletes all books, then all authors, through the repositories, and the test then creates through the API whatever it needs. The order is the one the foreign keys require: deleteAll on the books removes each book's book_author rows with it, and only then can the authors go. Each test can then assert on exact lists and totals, whatever ran before it.
./mvnw test -Dtest=LibraryApiIntegrationTest[INFO] Running com.example.library.LibraryApiIntegrationTest…2026-10-10T12:46:41.198Z INFO 3817 --- [ main] o.s.boot.tomcat.TomcatWebServer : Tomcat initialized with port 0 (http)…2026-10-10T12:46:45.174Z INFO 3817 --- [ main] tc.postgres:18 : Container postgres:18 started in PT1.588048987S…2026-10-10T12:46:51.273Z INFO 3817 --- [ main] o.s.boot.tomcat.TomcatWebServer : Tomcat started on port 43479 (http) with context path '/'2026-10-10T12:46:51.284Z INFO 3817 --- [ main] c.e.library.LibraryApiIntegrationTest : Started LibraryApiIntegrationTest in 12.474 seconds (process running for 14.126)…2026-10-10T12:46:52.805Z INFO 3817 --- [o-auto-1-exec-1] o.a.c.c.C.[Tomcat].[localhost].[/] : Initializing Spring DispatcherServlet 'dispatcherServlet'…[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 15.22 s -- in com.example.library.LibraryApiIntegrationTest…[INFO] BUILD SUCCESSport 0, then port 43479. Port 0 asks the operating system for any free port; Tomcat got 43479 this time, and RestTestClient was pointed at it. Your number will differ on every run.[o-auto-1-exec-1] is the thread name in the log. Everything before it ran on main, the test's thread; the first request arrived on one of Tomcat's worker threads, exactly as in the running app. That separation is why nothing a request writes can be rolled back by the test.Location followed, the JSON exactly as expected.Open BookRepository and delete the @EntityGraph line above findById, as Lesson 12 did for findAll:
8 collapsed lines
package com.example.library.book;
import java.util.Optional; // new: findById's return typeimport java.util.UUID;import org.springframework.data.domain.Page;import org.springframework.data.domain.Pageable;import org.springframework.data.jpa.repository.EntityGraph; // newimport org.springframework.data.jpa.repository.JpaRepository;
public interface BookRepository extends JpaRepository<Book, UUID> {
@Override @EntityGraph(attributePaths = "authors") Page<Book> findAll(Pageable pageable);
@Override Optional<Book> findById(UUID id);
@EntityGraph(attributePaths = "authors") Page<Book> findByAuthorsId(UUID authorId, Pageable pageable);
// Is any book still linked to this author? (Spring Data derives an EXISTS query.) boolean existsByAuthorsId(UUID authorId);}Run the book's controller tests, its repository tests and the new integration test together:
./mvnw test -Dtest='BookControllerTest,BookRepositoryTest,LibraryApiIntegrationTest'[INFO] Running com.example.library.LibraryApiIntegrationTest…2026-10-10T12:47:33.092Z ERROR 4459 --- [o-auto-1-exec-4] c.e.library.error.ApiExceptionHandler : Unhandled exception
org.hibernate.LazyInitializationException: Cannot lazily initialize collection of role 'com.example.library.book.Book.authors' with key '01a125da-b6c1-75b3-9577-162b6e2147c8' (no session) at org.hibernate.collection.spi.AbstractPersistentCollection.throwLazyInitializationException(AbstractPersistentCollection.java:664) ~[hibernate-core-7.4.5.Final.jar:7.4.5.Final]…2026-10-10T12:47:33.116Z ERROR 4459 --- [ main] o.s.t.web.servlet.client.ExchangeResult : Request details for assertion failure:
> GET http://localhost:37989/api/books/01a125da-b6c1-75b3-9577-162b6e2147c8> RestTestClient-Request-Id: [4]> Content-Length: [0]
0 bytes of content (unknown content-type).
< 500 INTERNAL_SERVER_ERROR Internal Server Error< connection: [close]< content-type: [application/problem+json]< date: [Sat, 10 Oct 2026 12:47:33 GMT]< transfer-encoding: [chunked]
{"detail":"An unexpected error occurred.","instance":"/api/books/01a125da-b6c1-75b3-9577-162b6e2147c8","status":500,"title":"Internal Server Error"}
[ERROR] Tests run: 1, Failures: 1, Errors: 0, Skipped: 0, Time elapsed: 18.04 s <<< FAILURE! -- in com.example.library.LibraryApiIntegrationTest[ERROR] com.example.library.LibraryApiIntegrationTest.create_thenReadBack -- Time elapsed: 1.278 s <<< FAILURE!java.lang.AssertionError: Status expected:<200 OK> but was:<500 INTERNAL_SERVER_ERROR>… at com.example.library.LibraryApiIntegrationTest.create_thenReadBack(LibraryApiIntegrationTest.java:59)
[INFO] Running com.example.library.book.BookControllerTest…[INFO] Tests run: 10, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 1.928 s -- in com.example.library.book.BookControllerTest[INFO] Running com.example.library.book.BookRepositoryTest…[ERROR] Tests run: 9, Failures: 1, Errors: 0, Skipped: 0, Time elapsed: 3.727 s <<< FAILURE! -- in com.example.library.book.BookRepositoryTest[ERROR] com.example.library.book.BookRepositoryTest.findById_oneStatement -- Time elapsed: 0.051 s <<< FAILURE!org.opentest4j.AssertionFailedError:[SQL statements]expected: 1L but was: 2L at com.example.library.book.BookRepositoryTest.findById_oneStatement(BookRepositoryTest.java:94)…[ERROR] Tests run: 20, Failures: 2, Errors: 0, Skipped: 0…[INFO] BUILD FAILUREThe same one-line change, three verdicts:
BookControllerTest: all 10 green. The service is a mock, and the Book it returns holds its authors in a plain in-memory Set. Whatever the repository does is invisible here — as it should be for a web-layer test.BookRepositoryTest: expected: 1L but was: 2L. Lesson 12's guard caught it: findById now costs a second statement to load the authors. Read on its own, that sounds like a performance regression, one extra query for one book. Someone in a hurry might even "fix" the test by changing the 1 to a 2.LibraryApiIntegrationTest: a 500. In the real app, the extra statement never runs. BookService.getById returns, the repository's session is closed, and BookResponse.from in the controller touches book.getAuthors() — so Hibernate throws LazyInitializationException (no session), Lesson 7's catch-all logs it as Unhandled exception, and the client gets An unexpected error occurred. Every request for a single book fails.That is what the integration test adds: not a different bug, but the true size of one. RestTestClient also prints the whole exchange when an expectation fails — the request, the status, the headers and the problem body — and the server's log with the cause is right above it, because the server runs in the same JVM as the test.
Put the @EntityGraph(attributePaths = "authors") line back above findById before you go on.
@Transactional, with no server?You will meet integration tests written the other way: @SpringBootTest in its default mock environment, with @Transactional on the class so every test is rolled back, like Lesson 12's. Without a server, the request runs on the test's own thread, inside the test's transaction — and that transaction keeps the session open through the controller, just as it did in Lesson 12. With findById's graph missing, BookResponse.from loads the authors lazily and the test passes, while the real app answers 500. The rollback is convenient, but it changes the code under test: the transactions the app relies on are no longer the ones that run.
The second test fills the database with two books and lists them, the way Lesson 8 taught the API to: the first page, sorted by title, with the page metadata. Add it below the first, and a createBook helper below createAuthor:
19 collapsed lines
package com.example.library;
import java.util.UUID;import com.example.library.author.AuthorRepository;import com.example.library.author.AuthorResponse;import com.example.library.book.BookRepository;import com.example.library.book.BookResponse;import org.junit.jupiter.api.BeforeEach;import org.junit.jupiter.api.DisplayName;import org.junit.jupiter.api.Test;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureRestTestClient;import org.springframework.boot.test.context.SpringBootTest;import org.springframework.boot.test.context.SpringBootTest.WebEnvironment;import org.springframework.context.annotation.Import;import org.springframework.http.MediaType;import org.springframework.test.json.JsonCompareMode;import org.springframework.test.web.servlet.client.EntityExchangeResult;import org.springframework.test.web.servlet.client.RestTestClient;
53 collapsed lines
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) // the whole app, on a real server and a free port@AutoConfigureRestTestClient // a RestTestClient pointed at that server@Import(TestcontainersConfiguration.class) // ...and a real PostgreSQLclass LibraryApiIntegrationTest {
@Autowired RestTestClient client; // sends real HTTP requests to the running app @Autowired BookRepository books; // used only to empty the tables @Autowired AuthorRepository authors;
@BeforeEach void emptyTables() { books.deleteAll(); // books first: their book_author rows point at the authors authors.deleteAll(); }
@Test @DisplayName("a book created with POST can be read back from its Location") void create_thenReadBack() { UUID hunt = createAuthor("Andrew Hunt"); UUID thomas = createAuthor("David Thomas");
EntityExchangeResult<BookResponse> created = client.post().uri("/api/books") .contentType(MediaType.APPLICATION_JSON) .body(""" { "title": "The Pragmatic Programmer", "year": 1999, "authorIds": ["%s", "%s"] } """.formatted(hunt, thomas)) .exchange() .expectStatus().isCreated() .expectBody(BookResponse.class) .returnResult(); UUID id = created.getResponseBody().id();
client.get().uri(created.getResponseHeaders().getLocation()) // follow the Location header .exchange() .expectStatus().isOk() .expectHeader().contentType(MediaType.APPLICATION_JSON) .expectBody().json(""" { "id": "%s", "title": "The Pragmatic Programmer", "year": 1999, "authors": [ { "id": "%s", "name": "Andrew Hunt" }, { "id": "%s", "name": "David Thomas" } ] } """.formatted(id, hunt, thomas), JsonCompareMode.STRICT); }
@Test @DisplayName("GET /api/books lists every book in title order, with the page metadata") void list_allBooksByTitle() { UUID hunt = createAuthor("Andrew Hunt"); UUID thomas = createAuthor("David Thomas"); UUID evans = createAuthor("Eric Evans"); UUID pragmatic = createBook(""" { "title": "The Pragmatic Programmer", "year": 1999, "authorIds": ["%s", "%s"] } """.formatted(hunt, thomas)); UUID ddd = createBook(""" { "title": "Domain-Driven Design", "year": 2003, "authorIds": ["%s"] } """.formatted(evans));
client.get().uri("/api/books") .exchange() .expectStatus().isOk() .expectBody().json(""" { "content": [ { "id": "%s", "title": "Domain-Driven Design", "year": 2003, "authors": [ { "id": "%s", "name": "Eric Evans" } ] }, { "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": 2, "totalPages": 1 } } """.formatted(ddd, evans, pragmatic, hunt, thomas), JsonCompareMode.STRICT); }
15 collapsed lines
// POSTs an author and returns the id the API gave it. UUID createAuthor(String name) { return client.post().uri("/api/authors") .contentType(MediaType.APPLICATION_JSON) .body(""" { "name": "%s" } """.formatted(name)) .exchange() .expectStatus().isCreated() .expectBody(AuthorResponse.class) .returnResult() .getResponseBody() .id(); }
// POSTs a book (the request body as JSON) and returns the id the API gave it. UUID createBook(String json) { return client.post().uri("/api/books") .contentType(MediaType.APPLICATION_JSON) .body(json) .exchange() .expectStatus().isCreated() .expectBody(BookResponse.class) .returnResult() .getResponseBody() .id(); }}page block is Lesson 8's stable shape, which application.properties switches on. Lesson 11 checked it with a mocked page; here the numbers come from PostgreSQL.totalElements: 2 holds only because the tables were emptied. Without emptyTables, the list would also hold DataSeeder's three books and the first test's book.The last test takes one error path end to end. Lesson 7's 409 needs every layer to cooperate: the controller routes the DELETE, AuthorService asks BookRepository.existsByAuthorsId against real book_author rows, and ApiExceptionHandler turns the ConflictException into a problem response. Add it below the list test, with the HttpStatus import:
15 collapsed lines
package com.example.library;
import java.util.UUID;import com.example.library.author.AuthorRepository;import com.example.library.author.AuthorResponse;import com.example.library.book.BookRepository;import com.example.library.book.BookResponse;import org.junit.jupiter.api.BeforeEach;import org.junit.jupiter.api.DisplayName;import org.junit.jupiter.api.Test;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureRestTestClient;import org.springframework.boot.test.context.SpringBootTest;import org.springframework.boot.test.context.SpringBootTest.WebEnvironment;import org.springframework.context.annotation.Import;import org.springframework.http.HttpStatus;100 collapsed lines
import org.springframework.http.MediaType;import org.springframework.test.json.JsonCompareMode;import org.springframework.test.web.servlet.client.EntityExchangeResult;import org.springframework.test.web.servlet.client.RestTestClient;
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) // the whole app, on a real server and a free port@AutoConfigureRestTestClient // a RestTestClient pointed at that server@Import(TestcontainersConfiguration.class) // ...and a real PostgreSQLclass LibraryApiIntegrationTest {
@Autowired RestTestClient client; // sends real HTTP requests to the running app @Autowired BookRepository books; // used only to empty the tables @Autowired AuthorRepository authors;
@BeforeEach void emptyTables() { books.deleteAll(); // books first: their book_author rows point at the authors authors.deleteAll(); }
@Test @DisplayName("a book created with POST can be read back from its Location") void create_thenReadBack() { UUID hunt = createAuthor("Andrew Hunt"); UUID thomas = createAuthor("David Thomas");
EntityExchangeResult<BookResponse> created = client.post().uri("/api/books") .contentType(MediaType.APPLICATION_JSON) .body(""" { "title": "The Pragmatic Programmer", "year": 1999, "authorIds": ["%s", "%s"] } """.formatted(hunt, thomas)) .exchange() .expectStatus().isCreated() .expectBody(BookResponse.class) .returnResult(); UUID id = created.getResponseBody().id();
client.get().uri(created.getResponseHeaders().getLocation()) // follow the Location header .exchange() .expectStatus().isOk() .expectHeader().contentType(MediaType.APPLICATION_JSON) .expectBody().json(""" { "id": "%s", "title": "The Pragmatic Programmer", "year": 1999, "authors": [ { "id": "%s", "name": "Andrew Hunt" }, { "id": "%s", "name": "David Thomas" } ] } """.formatted(id, hunt, thomas), JsonCompareMode.STRICT); }
@Test @DisplayName("GET /api/books lists every book in title order, with the page metadata") void list_allBooksByTitle() { UUID hunt = createAuthor("Andrew Hunt"); UUID thomas = createAuthor("David Thomas"); UUID evans = createAuthor("Eric Evans"); UUID pragmatic = createBook(""" { "title": "The Pragmatic Programmer", "year": 1999, "authorIds": ["%s", "%s"] } """.formatted(hunt, thomas)); UUID ddd = createBook(""" { "title": "Domain-Driven Design", "year": 2003, "authorIds": ["%s"] } """.formatted(evans));
client.get().uri("/api/books") .exchange() .expectStatus().isOk() .expectBody().json(""" { "content": [ { "id": "%s", "title": "Domain-Driven Design", "year": 2003, "authors": [ { "id": "%s", "name": "Eric Evans" } ] }, { "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": 2, "totalPages": 1 } } """.formatted(ddd, evans, pragmatic, hunt, thomas), JsonCompareMode.STRICT); }
@Test @DisplayName("DELETE of an author who still has books is a 409, and the author stays") void deleteAuthor_withBooks_conflict() { UUID evans = createAuthor("Eric Evans"); createBook(""" { "title": "Domain-Driven Design", "year": 2003, "authorIds": ["%s"] } """.formatted(evans));
client.delete().uri("/api/authors/{id}", evans) .exchange() .expectStatus().isEqualTo(HttpStatus.CONFLICT) .expectHeader().contentType(MediaType.APPLICATION_PROBLEM_JSON) .expectBody().json(""" { "status": 409, "title": "Conflict", "detail": "Author %s still has books; delete or re-author them first", "instance": "/api/authors/%s" } """.formatted(evans, evans), JsonCompareMode.STRICT);
client.get().uri("/api/authors/{id}", evans) // the refused delete changed nothing .exchange() .expectStatus().isOk() .expectBody().json(""" { "id": "%s", "name": "Eric Evans" } """.formatted(evans), JsonCompareMode.STRICT); }
27 collapsed lines
// POSTs an author and returns the id the API gave it. UUID createAuthor(String name) { return client.post().uri("/api/authors") .contentType(MediaType.APPLICATION_JSON) .body(""" { "name": "%s" } """.formatted(name)) .exchange() .expectStatus().isCreated() .expectBody(AuthorResponse.class) .returnResult() .getResponseBody() .id(); }
// POSTs a book (the request body as JSON) and returns the id the API gave it. UUID createBook(String json) { return client.post().uri("/api/books") .contentType(MediaType.APPLICATION_JSON) .body(json) .exchange() .expectStatus().isCreated() .expectBody(BookResponse.class) .returnResult() .getResponseBody() .id(); }}uri("/api/authors/{id}", evans) fills the {id} template variable, as MockMvcTester's uri did in Lesson 11.instance Spring fills in from the request path.409 promises: the author was not deleted. A status alone can't show that; Lesson 10 checked it with verify(..., never()) on a mock, and here the author is simply still there to read.Run the whole suite:
./mvnw test[INFO] Running com.example.library.LibraryApiIntegrationTest…2026-10-10T12:48:31.891Z INFO 5902 --- [ main] tc.postgres:18 : Container postgres:18 started in PT1.395566085S…2026-10-10T12:48:38.947Z INFO 5902 --- [ main] c.e.library.LibraryApiIntegrationTest : Started LibraryApiIntegrationTest in 13.396 seconds (process running for 15.145)…[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 16.59 s -- in com.example.library.LibraryApiIntegrationTest[INFO] Running com.example.library.author.AuthorControllerTest…[INFO] Tests run: 12, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 1.982 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.561 s -- in com.example.library.author.AuthorServiceTest[INFO] Running com.example.library.book.BookControllerTest…[INFO] Tests run: 10, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 1.065 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.058 s -- in com.example.library.book.BookServiceTest[INFO] Running com.example.library.book.BookRepositoryTest…2026-10-10T12:48:47.326Z INFO 5902 --- [ main] tc.postgres:18 : Container postgres:18 started in PT1.634578317S…2026-10-10T12:48:48.226Z INFO 5902 --- [ main] c.e.library.book.BookRepositoryTest : Started BookRepositoryTest in 2.944 seconds (process running for 24.425)…[INFO] Tests run: 9, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 3.683 s -- in com.example.library.book.BookRepositoryTest[INFO] Running com.example.library.LibraryApplicationTests…2026-10-10T12:48:50.940Z INFO 5902 --- [ main] tc.postgres:18 : Container postgres:18 started in PT1.673564397S…2026-10-10T12:48:51.592Z INFO 5902 --- [ main] c.e.library.LibraryApplicationTests : Started LibraryApplicationTests in 2.767 seconds (process running for 27.79)[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 2.843 s -- in com.example.library.LibraryApplicationTests…[INFO] Tests run: 55, Failures: 0, Errors: 0, Skipped: 0…[INFO] BUILD SUCCESS55 tests, and for the first time since Lesson 5 none skipped. As before, the service-test counts include Lesson 10's exercises, so your total may be smaller.
Count the Container postgres:18 started lines: three. Spring's test framework keeps every application context it builds and hands it to the next test class with the same configuration — context caching. The cache key is the configuration itself: the annotations, the imported classes, the properties [docs]. Three of the test classes need a database, and all three are configured differently — a RANDOM_PORT context with a RestTestClient, a @DataJpaTest slice with statistics on, a mock-environment context — so each gets a context of its own, and each context its own container bean. A second class annotated exactly like LibraryApiIntegrationTest would reuse its context and its container, and skip the start-up altogether. That is a reason to keep the integration tests' annotations identical as their number grows.
The layers now each have their tests, and the stack as a whole has its own:
Could not find a valid Docker environment? As in Lesson 12: the integration tests need Docker running, because they start the same PostgreSQL container.spring-boot-starter-webmvc-test brings Mockito into every test run.Answer from memory before revealing.
@SpringBootTest load that @WebMvcTest does not?@Transactional clean up after a RANDOM_PORT test?findById make the real API answer 500?Primary source: Spring Boot's reference on Testing Spring Boot Applications, up to and including "Testing With a Running Server" — the web environments, how the configuration is found, and the clients for a running server. Then Spring Framework's page on Context Caching, for exactly what makes two test classes share a context.
Glossary: the terms this lesson introduces are in the Glossary, under Lesson 13 — your quick-reference for every term we use.
Architecture decisions: the 2 choices this lesson makes, each with its reason, are in Architecture Decisions, under Lesson 13.
Code: the project at the end of this lesson · what changed in this lesson, exercises included.