Spring Boot Course · Lesson 13

Integration Tests with @SpringBootTest

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.

The win

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.

What no test has run yet

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:

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.

Step 1 — Start the whole application in a test

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:

src/test/java/com/example/library/LibraryApplicationTests.java
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 database
class LibraryApplicationTests {
@Test void contextLoads() { }
}

Run it:

Terminal window
./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.41021916S
2026-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 SUCCESS

contextLoads checks something real: the application can start. What it doesn't check is whether the application works — no request has been sent.

Step 2 — A real server and a client for it

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:

src/test/java/com/example/library/LibraryApiIntegrationTest.java
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 PostgreSQL
class 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:

And the test:

Why the tables are emptied first

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.

Step 3 — Run it

Terminal window
./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 SUCCESS

Step 4 — One change, three verdicts

Open BookRepository and delete the @EntityGraph line above findById, as Lesson 12 did for findAll:

src/main/java/com/example/library/book/BookRepository.java
8 collapsed lines
package com.example.library.book;
import java.util.Optional; // new: findById's return type
import java.util.UUID;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.EntityGraph; // new
import 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:

Terminal window
./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 FAILURE

The same one-line change, three verdicts:

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.

Why not @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.

Step 5 — The list

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:

src/test/java/com/example/library/LibraryApiIntegrationTest.java
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 PostgreSQL
class 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();
}
}

Step 6 — An error that crosses the layers

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:

src/test/java/com/example/library/LibraryApiIntegrationTest.java
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 PostgreSQL
class 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();
}
}

Step 7 — Run it

Run the whole suite:

Terminal window
./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 SUCCESS

55 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:

If it doesn't behave
  • Could not find a valid Docker environment? As in Lesson 12: the integration tests need Docker running, because they start the same PostgreSQL container.
  • The Mockito "self-attaching" warning? The same harmless warning as in Lesson 10; spring-boot-starter-webmvc-test brings Mockito into every test run.

Check yourself

Answer from memory before revealing.

What does @SpringBootTest load that @WebMvcTest does not?
Why wouldn't @Transactional clean up after a RANDOM_PORT test?
Why does a missing graph on findById make the real API answer 500?
Why does every integration test start by emptying the tables?
When do two test classes share one application context and container?

Read this next

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.

💬 Discuss