Spring Boot Course · Lesson 12
Lesson 10 mocked the repositories and Lesson 11 mocked the services, so no test has touched a database yet. That leaves the code that only a database can check untested: the queries Spring Data derives from method names, the @EntityGraph that Lesson 9 added to stop the N+1 problem, the mapping that decides which rows a save writes, and the Liquibase migrations. This lesson tests the repository layer against the real thing — PostgreSQL 18, started in Docker for each test run — and turns Lesson 9's "one statement instead of 1 + N" into a test that fails if anyone breaks it.
BookRepositoryTest: nine tests that run against a real PostgreSQL 18, started by Testcontainers and migrated by your own Liquibase changelog. Three pin down the derived queries findByAuthorsId and existsByAuthorsId; three count the SQL statements each @EntityGraph read costs and fail if it's ever more than one; three read the tables with plain SQL after a save, a replace and a delete. You'll also see why a test like that can pass when it shouldn't, and fix it.
Look at what BookRepository actually contains:
findByAuthorsId(UUID, Pageable) has no body. Spring Data reads the method name and writes the query: join book to book_author and keep the rows whose author_id matches (Lesson 6). A typo in the name fails at startup; a name that means something other than you think doesn't fail at all.existsByAuthorsId(UUID) is the check behind Lesson 7's 409. The service tests stubbed it to return true or false; nothing has checked that it really does.@EntityGraph(attributePaths = "authors") on three methods is a performance contract: one statement per read. Delete one annotation and every test so far still passes — the mock services never ran a query, and the app still returns the same JSON, just with 1 + N statements behind it.save and deleteById are Spring Data's, but what they write is decided by your mapping on Book: the @ManyToMany and its @JoinTable say which book_author rows a save inserts, updates or deletes, and the missing cascade says that deleting a book leaves its authors alone (Lesson 6). The service tests mocked save, so none of that has run yet.AuthorRepository is an empty interface, and Author maps one plain table, so everything that repository does is Spring Data's code, already tested by Spring. The tests go where your code is: BookRepository and the Book mapping.
The tool is another slice, like Lesson 11's. @DataJpaTest starts only the persistence layer — entities, repositories, JPA, Liquibase — and the course runs it against a real PostgreSQL, the same version the app uses. Ordinary @Components, services and controllers stay out [docs], and so does DataSeeder, so the database holds only what each test puts in it.
Spring Boot's documentation points out that in-memory databases such as H2 work well for tests: fast, nothing to install [docs]. They are a different database, though. Your changeset 003 calls uuidv7(), a PostgreSQL 18 function, so it wouldn't even run on H2 — and a query that passes on one SQL dialect proves little about another. The cost of the real thing is Docker, which you've had since Lesson 5.
Three test-scoped dependencies go below spring-boot-starter-webmvc-test. None needs a version: Spring Boot's parent manages them (Testcontainers 2.0.5 on Spring Boot 4.1.1).
84 collapsed lines
<?xml version="1.0" encoding="UTF-8"?><project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>4.1.1</version> <relativePath/> <!-- lookup parent from repository --> </parent> <groupId>com.example</groupId> <artifactId>library</artifactId> <version>0.0.1-SNAPSHOT</version> <name>library</name> <description/> <url/> <licenses> <license/> </licenses> <developers> <developer/> </developers> <scm> <connection/> <developerConnection/> <tag/> <url/> </scm> <properties> <java.version>21</java.version> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-webmvc</artifactId> </dependency>
<!-- Bean Validation (@Valid + constraints); brings Hibernate Validator --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency>
<!-- Spring Data JPA + Hibernate --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-jpa</artifactId> </dependency>
<!-- PostgreSQL JDBC driver (used at runtime to talk to the database) --> <dependency> <groupId>org.postgresql</groupId> <artifactId>postgresql</artifactId> <scope>runtime</scope> </dependency>
<!-- Liquibase migrations. The autoconfiguration lives in this module (it brings liquibase-core transitively); the bare org.liquibase:liquibase-core won't activate it. --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-liquibase</artifactId> </dependency>
<!-- Starts/stops the Postgres container from compose.yaml when the app runs --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-docker-compose</artifactId> <optional>true</optional> </dependency>
<!-- Lombok: generates getters/constructors/equals at compile time --> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency>
<!-- uuid-creator: time-ordered UUIDv7 generation --> <dependency> <groupId>com.github.f4b6a3</groupId> <artifactId>uuid-creator</artifactId> <version>6.0.0</version> <!-- or the latest on Maven Central --> </dependency>
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-webmvc-test</artifactId> <scope>test</scope> </dependency>
<!-- @DataJpaTest and TestEntityManager: the persistence-layer test slice --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-jpa-test</artifactId> <scope>test</scope> </dependency>
<!-- @ServiceConnection: points Spring Boot at a database Testcontainers started --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-testcontainers</artifactId> <scope>test</scope> </dependency>
<!-- Testcontainers' PostgreSQL module: runs postgres in Docker for the tests --> <dependency> <groupId>org.testcontainers</groupId> <artifactId>testcontainers-postgresql</artifactId> <scope>test</scope> </dependency> </dependencies>10 collapsed lines
<build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin> </plugins> </build>
</project>spring-boot-starter-data-jpa-test brings @DataJpaTest and TestEntityManager. Spring Boot 4 has one test starter per technology, as Lesson 11's webmvc-test was for the web layer.testcontainers-postgresql is Testcontainers' PostgreSQL module: a Java object, PostgreSQLContainer, that starts a postgres Docker container when the tests need it and removes it afterwards [docs]. It brings the Testcontainers core library with it.spring-boot-testcontainers connects the two: it lets Spring Boot take the database's address, user and password from that container, so nothing has to be configured by hand.The container is declared once, in a test configuration class in the base test package:
package com.example.library;
import java.util.TimeZone;import org.springframework.boot.test.context.TestConfiguration;import org.springframework.boot.testcontainers.service.connection.ServiceConnection;import org.springframework.context.annotation.Bean;import org.testcontainers.postgresql.PostgreSQLContainer;
@TestConfiguration(proxyBeanMethods = false) // test-only beans, picked up only where a test imports thempublic class TestcontainersConfiguration {
static { // Tests don't run LibraryApplication.main(), so they set the same UTC default here. TimeZone.setDefault(TimeZone.getTimeZone("UTC")); }
@Bean @ServiceConnection // Spring Boot connects the DataSource to this container PostgreSQLContainer postgres() { return new PostgreSQLContainer("postgres:18"); // the same image as compose.yaml }}@ServiceConnection makes Spring Boot read the JDBC URL, user and password from the running container and use them for the DataSource, taking precedence over any spring.datasource.* properties [docs]. It is the test-side twin of Lesson 5's Docker Compose support, which does the same from compose.yaml when the app runs. (Tests ignore compose.yaml: Spring Boot's Docker Compose support is off while tests run [docs].)@TestConfiguration keeps it out of the application: component scanning leaves it alone, and only tests that @Import it get a container. @WebMvcTest tests don't, so they still run without Docker. The class is public because the tests that import it live in feature packages such as com.example.library.book. Its proxyBeanMethods = false is what Spring Initializr generates; Lesson 14 explains what it switches off.static block sets the JVM's default time zone to UTC, as LibraryApplication.main() does since Lesson 5. A test never calls main(), so without this the tests run in your machine's time zone, and the PostgreSQL driver sends it to the server when it connects. PostgreSQL 18 rejects some legacy zone names — on a machine set to Europe/Kiev, every test fails with FATAL: invalid value for parameter "TimeZone": "Europe/Kiev". Java runs a static block once, when the class is first used — here, before Spring creates the container bean and, after it, the database connection. Every test that needs the database imports this class, so they all get UTC, whether they run from Maven or from the IDE. The test database is declared once, as a @ServiceConnection container bean in TestcontainersConfiguration, and imported by each test that needs it. It is the same arrangement Spring Initializr generates when you pick Testcontainers, and Spring's test framework caches the application context, container included, so test classes with the same configuration share one container instead of starting their own [docs].
The image is postgres:18, the tag in compose.yaml, not postgres:latest: the tests should run on the database the app runs on, and move to a new major version only when the app does.
The test lives next to the repository, in src/test/java/com/example/library/book/. It starts with the fixtures and a test of findByAuthorsId:
package com.example.library.book;
import java.util.Set;import com.example.library.TestcontainersConfiguration;import com.example.library.author.Author;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.data.jpa.test.autoconfigure.DataJpaTest;import org.springframework.boot.jpa.test.autoconfigure.TestEntityManager;import org.springframework.context.annotation.Import;import org.springframework.data.domain.PageRequest;import org.springframework.data.domain.Pageable;import org.springframework.data.domain.Sort;
import static org.assertj.core.api.Assertions.assertThat;
@DataJpaTest // the persistence layer only@Import(TestcontainersConfiguration.class) // ...on a real PostgreSQLclass BookRepositoryTest {
@Autowired BookRepository books; // the real repository, implemented by Spring Data @Autowired TestEntityManager entityManager; // puts the fixtures into the database
Author hunt = new Author("Andrew Hunt"); Author thomas = new Author("David Thomas"); Author evans = new Author("Eric Evans"); Book pragmatic = new Book("The Pragmatic Programmer", 1999, Set.of(hunt, thomas)); Book ddd = new Book("Domain-Driven Design", 2003, Set.of(evans));
Pageable firstPage = PageRequest.of(0, 20, Sort.by("title"));
@BeforeEach void saveFixtures() { entityManager.persist(hunt); // authors first: the book's join rows point at them entityManager.persist(thomas); entityManager.persist(evans); entityManager.persist(pragmatic); entityManager.persist(ddd); }
@Test @DisplayName("findByAuthorsId returns only that author's books") void findByAuthorsId_onlyThatAuthorsBooks() { assertThat(books.findByAuthorsId(thomas.getId(), firstPage)).containsExactly(pragmatic); }}@DataJpaTest builds the persistence slice: your entities, Spring Data's repository implementations, JPA, the DataSource and Liquibase. It also turns on spring.jpa.show-sql [docs], so every statement a test runs is printed as a Hibernate: … line. Lesson 9 switched the SQL log off in application.properties once the N+1 investigation was done; for repository tests, where the SQL is the thing under test, the slice's own output is all you need. It is plainer than the logger's lines, with no timestamp or level, and it only appears while the tests run.@BeforeEach belong to the same transaction, so each test starts from an empty database plus its own fixtures, and leaves nothing behind.TestEntityManager is a small, test-friendly wrapper around JPA's EntityManager [docs]. The fixtures go in through it, not through books.save(...), so the setup doesn't depend on the repository under test. As in DataSeeder, the authors are persisted before the book that points at them — there is no cascade (Lesson 6).containsExactly(pragmatic) — that book, and nothing else — fails if the filter is ignored (Domain-Driven Design comes back too) and if it matches the wrong books (The Pragmatic Programmer is missing). Thomas is one of the book's two authors, not its only one, so the test also shows the query finds a book through a co-author.Books. It works because a Page is iterable and Book's equals compares ids (Lesson 5): the book that comes back is equal to the fixture with the same id.@AutoConfigureTestDatabase is not neededOut of the box, @DataJpaTest replaces the application's DataSource with an embedded database when it can. Older code and Spring Boot's own documentation therefore add @AutoConfigureTestDatabase(replace = Replace.NONE) to keep a real one [docs]. The default today, NON_TEST, leaves alone any connection that already points at a test database — and a @ServiceConnection container is one [docs]. The annotation adds nothing here.
Two more tests, below the first: existsByAuthorsId for an author with a book, and for one without.
18 collapsed lines
package com.example.library.book;
import java.util.Set;import com.example.library.TestcontainersConfiguration;import com.example.library.author.Author;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.data.jpa.test.autoconfigure.DataJpaTest;import org.springframework.boot.jpa.test.autoconfigure.TestEntityManager;import org.springframework.context.annotation.Import;import org.springframework.data.domain.PageRequest;import org.springframework.data.domain.Pageable;import org.springframework.data.domain.Sort;
import static org.assertj.core.api.Assertions.assertThat;
@DataJpaTest // the persistence layer only@Import(TestcontainersConfiguration.class) // ...on a real PostgreSQLclass BookRepositoryTest {27 collapsed lines
@Autowired BookRepository books; // the real repository, implemented by Spring Data @Autowired TestEntityManager entityManager; // puts the fixtures into the database
Author hunt = new Author("Andrew Hunt"); Author thomas = new Author("David Thomas"); Author evans = new Author("Eric Evans"); Book pragmatic = new Book("The Pragmatic Programmer", 1999, Set.of(hunt, thomas)); Book ddd = new Book("Domain-Driven Design", 2003, Set.of(evans));
Pageable firstPage = PageRequest.of(0, 20, Sort.by("title"));
@BeforeEach void saveFixtures() { entityManager.persist(hunt); // authors first: the book's join rows point at them entityManager.persist(thomas); entityManager.persist(evans); entityManager.persist(pragmatic); entityManager.persist(ddd); }
@Test @DisplayName("findByAuthorsId returns only that author's books") void findByAuthorsId_onlyThatAuthorsBooks() { assertThat(books.findByAuthorsId(thomas.getId(), firstPage)).containsExactly(pragmatic); }
@Test @DisplayName("existsByAuthorsId is true for an author with a book") void existsByAuthorsId_withBooks() { assertThat(books.existsByAuthorsId(thomas.getId())).isTrue(); }
@Test @DisplayName("existsByAuthorsId is false for an author with no books") void existsByAuthorsId_withoutBooks() { Author fowler = entityManager.persist(new Author("Martin Fowler"));
assertThat(books.existsByAuthorsId(fowler.getId())).isFalse(); }}The second test adds an author of its own. Martin Fowler exists, but no book_author row points at him — exactly the author Lesson 7's DELETE is allowed to remove.
Run the class. Docker has to be running, as it has been for the app since Lesson 5:
./mvnw test -Dtest=BookRepositoryTest[INFO] Running com.example.library.book.BookRepositoryTest…2026-10-08T21:42:31.897Z INFO 12355 --- [ main] org.testcontainers.DockerClientFactory : Testcontainers version: 2.0.5…2026-10-08T21:42:32.576Z INFO 12355 --- [ main] o.t.d.DockerClientProviderStrategy : Found Docker environment with local Unix socket (unix:///var/run/docker.sock)…2026-10-08T21:42:32.832Z INFO 12355 --- [ main] tc.testcontainers/ryuk:0.14.0 : Pulling docker image: testcontainers/ryuk:0.14.0. Please be patient; this may take some time but only needs to be done once.…2026-10-08T21:42:35.695Z INFO 12355 --- [ main] o.t.utility.RyukResourceReaper : Ryuk started - will monitor and terminate Testcontainers containers on JVM exit…2026-10-08T21:42:37.388Z INFO 12355 --- [ main] tc.postgres:18 : Container postgres:18 started in PT1.690874951S2026-10-08T21:42:37.390Z INFO 12355 --- [ main] tc.postgres:18 : Container is started (JDBC URL: jdbc:postgresql://172.18.0.1:32805/test?loggerLevel=OFF)…2026-10-08T21:42:41.352Z INFO 12355 --- [ main] liquibase.ui : Running Changeset: db/changelog/changes/001-create-book-table.yaml::001-create-book-table::roman…2026-10-08T21:42:41.404Z INFO 12355 --- [ main] liquibase.ui : Running Changeset: db/changelog/changes/002-create-author-table.yaml::002-create-author-table::roman…2026-10-08T21:42:41.554Z INFO 12355 --- [ main] liquibase.ui : Running Changeset: db/changelog/changes/003-link-books-to-authors.yaml::003-link-books-to-authors::roman…2026-10-08T21:42:41.981Z INFO 12355 --- [ main] liquibase.ui : Liquibase: Update has been successful. Rows affected: 0…2026-10-08T21:42:45.294Z INFO 12355 --- [ main] c.e.library.book.BookRepositoryTest : Started BookRepositoryTest in 14.545 seconds (process running for 16.23)…Hibernate: insert into author (name,id) values (?,?)Hibernate: insert into author (name,id) values (?,?)Hibernate: insert into author (name,id) values (?,?)Hibernate: insert into book (title,year,id) values (?,?,?)Hibernate: insert into book (title,year,id) values (?,?,?)Hibernate: insert into book_author (book_id,author_id) values (?,?)Hibernate: insert into book_author (book_id,author_id) values (?,?)Hibernate: insert into book_author (book_id,author_id) values (?,?)Hibernate: select b1_0.id from book b1_0 left join book_author a1_0 on b1_0.id=a1_0.book_id where a1_0.author_id=? fetch first ? rows only…[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 16.48 s -- in com.example.library.book.BookRepositoryTest…[INFO] BUILD SUCCESSRead it top to bottom:
postgres:18 was already on the machine from Lesson 5; otherwise Docker downloads it first. The JDBC URL shows a database named test on a random free port, so the tests never collide with the app's own database on 5432. @ServiceConnection hands exactly this URL to Spring Boot.Rows affected: 0 — changeset 003's data copy found no books to migrate. After it, Hibernate's ddl-auto=validate (Lesson 5) checks the entities against the tables that were just created. The context starting at all is a test of migrations and mappings together.existsByAuthorsId: Spring Data asks for at most one matching row (fetch first ? rows only), because one is enough to answer "exists".Starting took 15 seconds here, more than a third of it finding Docker and starting the two containers. The three tests themselves take a fraction of a second each.
Lesson 9 measured the N+1 problem by reading the SQL log: 1 + 6 statements for a page of books before @EntityGraph, one after. A test can't read a log, but it can ask Hibernate. With hibernate.generate_statistics switched on, Hibernate counts what every session does, and getPrepareStatementCount() is the number of SQL statements it has prepared [docs].
There is a second reason the count matters. In the running app, Lesson 9 turned off Open Session in View, so a lazy load outside the repository call throws LazyInitializationException. A @DataJpaTest is different: the whole test runs in one transaction, so the session stays open and lazy loads simply work, one extra statement at a time. A missing @EntityGraph throws nothing here. Counting is how the test notices it.
The test loads a page of books, reads every book's authors — which is what makes a lazy collection load — and then checks the count:
5 collapsed lines
package com.example.library.book;
import java.util.Set;import com.example.library.TestcontainersConfiguration;import com.example.library.author.Author;import jakarta.persistence.EntityManagerFactory;import org.hibernate.SessionFactory;import org.hibernate.stat.Statistics;13 collapsed lines
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.data.jpa.test.autoconfigure.DataJpaTest;import org.springframework.boot.jpa.test.autoconfigure.TestEntityManager;import org.springframework.context.annotation.Import;import org.springframework.data.domain.PageRequest;import org.springframework.data.domain.Pageable;import org.springframework.data.domain.Sort;
import static org.assertj.core.api.Assertions.assertThat;
@DataJpaTest( // the persistence layer only properties = "spring.jpa.properties.hibernate.generate_statistics=true") // Hibernate counts statements@Import(TestcontainersConfiguration.class) // ...on a real PostgreSQLclass BookRepositoryTest {3 collapsed lines
@Autowired BookRepository books; // the real repository, implemented by Spring Data @Autowired TestEntityManager entityManager; // puts the fixtures into the database @Autowired EntityManagerFactory entityManagerFactory; // where Hibernate keeps its statistics8 collapsed lines
Author hunt = new Author("Andrew Hunt"); Author thomas = new Author("David Thomas"); Author evans = new Author("Eric Evans"); Book pragmatic = new Book("The Pragmatic Programmer", 1999, Set.of(hunt, thomas)); Book ddd = new Book("Domain-Driven Design", 2003, Set.of(evans));
Pageable firstPage = PageRequest.of(0, 20, Sort.by("title")); Statistics statistics;
@BeforeEach void saveFixtures() { entityManager.persist(hunt); // authors first: the book's join rows point at them entityManager.persist(thomas); entityManager.persist(evans); entityManager.persist(pragmatic); entityManager.persist(ddd); entityManager.flush(); // send the inserts now, so the reset below wipes them
statistics = entityManagerFactory.unwrap(SessionFactory.class).getStatistics(); statistics.clear(); // reset the count: only what the test runs is counted }21 collapsed lines
@Test @DisplayName("findByAuthorsId returns only that author's books") void findByAuthorsId_onlyThatAuthorsBooks() { assertThat(books.findByAuthorsId(thomas.getId(), firstPage)).containsExactly(pragmatic); }
@Test @DisplayName("existsByAuthorsId is true for an author with a book") void existsByAuthorsId_withBooks() { assertThat(books.existsByAuthorsId(thomas.getId())).isTrue(); }
@Test @DisplayName("existsByAuthorsId is false for an author with no books") void existsByAuthorsId_withoutBooks() { Author fowler = entityManager.persist(new Author("Martin Fowler"));
assertThat(books.existsByAuthorsId(fowler.getId())).isFalse(); }
@Test @DisplayName("findAll loads a page of books and their authors in one statement") void findAll_oneStatement() { assertThat(books.findAll(firstPage)) .containsExactly(ddd, pragmatic) // the books, in title order .flatExtracting(Book::getAuthors) // reads every book's authors .containsExactlyInAnyOrder(evans, hunt, thomas); assertThat(statistics.getPrepareStatementCount()).as("SQL statements").isEqualTo(1); }}flush() sends the fixtures' inserts to the database before the counting starts — see below for why the order matters.unwrap(SessionFactory.class) reaches from JPA's standard EntityManagerFactory to Hibernate's own SessionFactory, which is where the statistics live. They add up across every session and every test [docs], so statistics.clear() resets them after the fixtures are in.containsExactly(ddd, pragmatic) wants exactly those two books, in title order. flatExtracting(Book::getAuthors) then collects every book's authors into one list — reading each collection, as the code that maps books for the API does — and containsExactlyInAnyOrder wants each book's full author list, nothing missing and nothing extra. Both compare by id, through the equals from Lesson 5. The test stays at the persistence layer: entities in, entities out.Page also needs a count(*) query, but as Lesson 9 showed, Spring Data skips it when the first page comes back shorter than the page size: two books, page size 20..as("SQL statements") names the number in the failure message, so a failure reads as a statement count rather than a bare expected: 1L.flush() comes before statistics.clear()persist doesn't write anything. entityManager.persist(hunt) only tells Hibernate to remember the author and insert it later. After the five persist calls, the database is still empty: Hibernate is holding eight inserts — three authors, two books and three book_author rows. It sends what it holds when it has to, at the latest just before a query that reads those tables, so the query sees current data.
Without the flush(), that moment falls inside the test:
saveFixtures persists the fixtures — no SQL is sent.statistics.clear() sets the count to 0.books.findAll(...) makes Hibernate send the eight held inserts, then the select.findAll ran one statement.Remove the flush() line and run the class, and that's what you get:
Hibernate: insert into author (name,id) values (?,?)Hibernate: insert into author (name,id) values (?,?)Hibernate: insert into author (name,id) values (?,?)Hibernate: insert into book (title,year,id) values (?,?,?)Hibernate: insert into book (title,year,id) values (?,?,?)Hibernate: insert into book_author (book_id,author_id) values (?,?)Hibernate: insert into book_author (book_id,author_id) values (?,?)Hibernate: insert into book_author (book_id,author_id) values (?,?)Hibernate: select b1_0.id,a1_0.book_id,a1_1.id,a1_1.name,b1_0.title,b1_0.year from (select ……[SQL statements]expected: 1L but was: 9LThe entity graph is fine; the test counted the fixtures' inserts as if findAll had run them. With flush() first, the inserts go out during the setup, statistics.clear() then forgets them, and findAll finds nothing held back: it sends only its select, and the count is 1. Put the line back before you go on.
Run the class again:
./mvnw test -Dtest=BookRepositoryTest…Hibernate: insert into book_author (book_id,author_id) values (?,?)Hibernate: select b1_0.id,a1_0.book_id,a1_1.id,a1_1.name,b1_0.title,b1_0.year from (select b1_0.id,b1_0.title,b1_0.year from book b1_0 order by b1_0.title offset ? rows fetch first ? rows only) b1_0(id,title,year) left join book_author a1_0 on b1_0.id=a1_0.book_id left join author a1_1 on a1_1.id=a1_0.author_id order by b1_0.title[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 16.09 s -- in com.example.library.book.BookRepositoryTest…[INFO] BUILD SUCCESSFour green. The last statement in the log is findAll_oneStatement's: Lesson 9's single query, the page of books picked in a subquery and the authors joined onto it.
Every repository read with an @EntityGraph gets a test that reads the associations the graph fetches and asserts Hibernate's statement count. The annotation's whole job is the number of statements, and nothing else in the test suite would notice it going missing.
A test you have only seen pass hasn't shown it can catch anything. Open BookRepository and delete the @EntityGraph line above findAll, leaving the @Override:
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 Page<Book> findAll(Pageable pageable);
@Override @EntityGraph(attributePaths = "authors") 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 tests:
./mvnw test -Dtest=BookRepositoryTest…Hibernate: insert into book_author (book_id,author_id) values (?,?)Hibernate: select b1_0.id,b1_0.title,b1_0.year from book b1_0 order by b1_0.title offset ? rows fetch first ? rows only[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 13.45 s -- in com.example.library.book.BookRepositoryTest…[INFO] BUILD SUCCESSIt passes. The select no longer joins the authors — the graph really is gone — and yet no second statement follows it. The test that exists to catch this exact change didn't notice it.
The test read every book's authors, so where did the lazy loads go? They never happened. JPA keeps a persistence context, also called the first-level cache: the set of entities the current session has already loaded or saved, at most one object per id. The fixtures were persisted in this same session, so they are in it, authors and all. When findAll runs, Hibernate still sends the select for the page — that is the one statement counted — but for each row it finds the book already in the persistence context and hands back that object, with its authors already in memory. The test never measured a load from the database.
The fix is to empty the persistence context after the fixtures are saved, so the code under test has to read everything from the database, as it would in a fresh request. TestEntityManager.clear() detaches every managed entity [docs]. One line in saveFixtures:
39 collapsed lines
package com.example.library.book;
import java.util.Set;import com.example.library.TestcontainersConfiguration;import com.example.library.author.Author;import jakarta.persistence.EntityManagerFactory;import org.hibernate.SessionFactory;import org.hibernate.stat.Statistics;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.data.jpa.test.autoconfigure.DataJpaTest;import org.springframework.boot.jpa.test.autoconfigure.TestEntityManager;import org.springframework.context.annotation.Import;import org.springframework.data.domain.PageRequest;import org.springframework.data.domain.Pageable;import org.springframework.data.domain.Sort;
import static org.assertj.core.api.Assertions.assertThat;
@DataJpaTest( // the persistence layer only properties = "spring.jpa.properties.hibernate.generate_statistics=true") // Hibernate counts statements@Import(TestcontainersConfiguration.class) // ...on a real PostgreSQLclass BookRepositoryTest {
@Autowired BookRepository books; // the real repository, implemented by Spring Data @Autowired TestEntityManager entityManager; // puts the fixtures into the database @Autowired EntityManagerFactory entityManagerFactory; // where Hibernate keeps its statistics
Author hunt = new Author("Andrew Hunt"); Author thomas = new Author("David Thomas"); Author evans = new Author("Eric Evans"); Book pragmatic = new Book("The Pragmatic Programmer", 1999, Set.of(hunt, thomas)); Book ddd = new Book("Domain-Driven Design", 2003, Set.of(evans));
Pageable firstPage = PageRequest.of(0, 20, Sort.by("title")); Statistics statistics;
@BeforeEach void saveFixtures() { entityManager.persist(hunt); // authors first: the book's join rows point at them entityManager.persist(thomas); entityManager.persist(evans); entityManager.persist(pragmatic); entityManager.persist(ddd); entityManager.flush(); // send the inserts now, so the reset below wipes them entityManager.clear(); // forget them: every read below must come from the database
statistics = entityManagerFactory.unwrap(SessionFactory.class).getStatistics(); statistics.clear(); // reset the count: only what the test runs is counted }30 collapsed lines
@Test @DisplayName("findByAuthorsId returns only that author's books") void findByAuthorsId_onlyThatAuthorsBooks() { assertThat(books.findByAuthorsId(thomas.getId(), firstPage)).containsExactly(pragmatic); }
@Test @DisplayName("existsByAuthorsId is true for an author with a book") void existsByAuthorsId_withBooks() { assertThat(books.existsByAuthorsId(thomas.getId())).isTrue(); }
@Test @DisplayName("existsByAuthorsId is false for an author with no books") void existsByAuthorsId_withoutBooks() { Author fowler = entityManager.persist(new Author("Martin Fowler"));
assertThat(books.existsByAuthorsId(fowler.getId())).isFalse(); }
@Test @DisplayName("findAll loads a page of books and their authors in one statement") void findAll_oneStatement() { assertThat(books.findAll(firstPage)) .containsExactly(ddd, pragmatic) // the books, in title order .flatExtracting(Book::getAuthors) // reads every book's authors .containsExactlyInAnyOrder(evans, hunt, thomas); assertThat(statistics.getPrepareStatementCount()).as("SQL statements").isEqualTo(1); }}Repository tests flush and clear the persistence context after saving their fixtures, so the code under test reads from the database as it does in a real request, not from objects the test left in memory.
Run the tests again, the @EntityGraph still missing:
./mvnw test -Dtest=BookRepositoryTest…Hibernate: select b1_0.id,b1_0.title,b1_0.year from book b1_0 order by b1_0.title offset ? rows fetch first ? rows onlyHibernate: select a1_0.book_id,a1_1.id,a1_1.name from book_author a1_0 join author a1_1 on a1_1.id=a1_0.author_id where a1_0.book_id=?Hibernate: select a1_0.book_id,a1_1.id,a1_1.name from book_author a1_0 join author a1_1 on a1_1.id=a1_0.author_id where a1_0.book_id=?[ERROR] Tests run: 4, Failures: 1, Errors: 0, Skipped: 0, Time elapsed: 10.55 s <<< FAILURE! -- in com.example.library.book.BookRepositoryTest[ERROR] com.example.library.book.BookRepositoryTest.findAll_oneStatement -- Time elapsed: 0.065 s <<< FAILURE!org.opentest4j.AssertionFailedError:[SQL statements]expected: 1L but was: 3L at com.example.library.book.BookRepositoryTest.findAll_oneStatement(BookRepositoryTest.java:81)…[ERROR] Tests run: 4, Failures: 1, Errors: 0, Skipped: 0…[INFO] BUILD FAILURENow the log shows the N+1 problem again, in miniature: one select for the page, then one more per book against book_author and author as flatExtracting reads its authors — 1 + 2 = 3, the same shape as Lesson 9's 1 + 6. The failure names the count, [SQL statements], and the line of the assertion. (The L marks a long: that is the type getPrepareStatementCount() returns.)
Put the @EntityGraph(attributePaths = "authors") line back above findAll and run the tests once more: Tests run: 4, all green.
findById and findByAuthorsId carry the same annotation and get the same kind of test. Add both below the findAll test:
82 collapsed lines
package com.example.library.book;
import java.util.Set;import com.example.library.TestcontainersConfiguration;import com.example.library.author.Author;import jakarta.persistence.EntityManagerFactory;import org.hibernate.SessionFactory;import org.hibernate.stat.Statistics;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.data.jpa.test.autoconfigure.DataJpaTest;import org.springframework.boot.jpa.test.autoconfigure.TestEntityManager;import org.springframework.context.annotation.Import;import org.springframework.data.domain.PageRequest;import org.springframework.data.domain.Pageable;import org.springframework.data.domain.Sort;
import static org.assertj.core.api.Assertions.assertThat;
@DataJpaTest( // the persistence layer only properties = "spring.jpa.properties.hibernate.generate_statistics=true") // Hibernate counts statements@Import(TestcontainersConfiguration.class) // ...on a real PostgreSQLclass BookRepositoryTest {
@Autowired BookRepository books; // the real repository, implemented by Spring Data @Autowired TestEntityManager entityManager; // puts the fixtures into the database @Autowired EntityManagerFactory entityManagerFactory; // where Hibernate keeps its statistics
Author hunt = new Author("Andrew Hunt"); Author thomas = new Author("David Thomas"); Author evans = new Author("Eric Evans"); Book pragmatic = new Book("The Pragmatic Programmer", 1999, Set.of(hunt, thomas)); Book ddd = new Book("Domain-Driven Design", 2003, Set.of(evans));
Pageable firstPage = PageRequest.of(0, 20, Sort.by("title")); Statistics statistics;
@BeforeEach void saveFixtures() { entityManager.persist(hunt); // authors first: the book's join rows point at them entityManager.persist(thomas); entityManager.persist(evans); entityManager.persist(pragmatic); entityManager.persist(ddd); entityManager.flush(); // send the inserts now, so the reset below wipes them entityManager.clear(); // forget them: every read below must come from the database
statistics = entityManagerFactory.unwrap(SessionFactory.class).getStatistics(); statistics.clear(); // reset the count: only what the test runs is counted }
@Test @DisplayName("findByAuthorsId returns only that author's books") void findByAuthorsId_onlyThatAuthorsBooks() { assertThat(books.findByAuthorsId(thomas.getId(), firstPage)).containsExactly(pragmatic); }
@Test @DisplayName("existsByAuthorsId is true for an author with a book") void existsByAuthorsId_withBooks() { assertThat(books.existsByAuthorsId(thomas.getId())).isTrue(); }
@Test @DisplayName("existsByAuthorsId is false for an author with no books") void existsByAuthorsId_withoutBooks() { Author fowler = entityManager.persist(new Author("Martin Fowler"));
assertThat(books.existsByAuthorsId(fowler.getId())).isFalse(); }
@Test @DisplayName("findAll loads a page of books and their authors in one statement") void findAll_oneStatement() { assertThat(books.findAll(firstPage)) .containsExactly(ddd, pragmatic) // the books, in title order .flatExtracting(Book::getAuthors) // reads every book's authors .containsExactlyInAnyOrder(evans, hunt, thomas); assertThat(statistics.getPrepareStatementCount()).as("SQL statements").isEqualTo(1); }
@Test @DisplayName("findById loads the book and its authors in one statement") void findById_oneStatement() { Book found = books.findById(pragmatic.getId()).orElseThrow();
assertThat(found.getAuthors()).containsExactlyInAnyOrder(hunt, thomas); // reads the authors assertThat(statistics.getPrepareStatementCount()).as("SQL statements").isEqualTo(1); }
@Test @DisplayName("findByAuthorsId loads every author of the books it finds, in one statement") void findByAuthorsId_allAuthorsOneStatement() { assertThat(books.findByAuthorsId(thomas.getId(), firstPage)) .containsExactly(pragmatic) .flatExtracting(Book::getAuthors) .containsExactlyInAnyOrder(hunt, thomas); // Hunt as well as Thomas assertThat(statistics.getPrepareStatementCount()).as("SQL statements").isEqualTo(1); }}The last test checks more than the count. The book is found through David Thomas, but it must still come back with both its authors. That is easy to get wrong: a hand-written JPQL query that fetches the authors and filters on them in the same join — select b from Book b join fetch b.authors a where a.id = :authorId — returns The Pragmatic Programmer with David Thomas alone, because the where clause removes the other author's rows before Hibernate fills the collection. Spring Data's derived query with the entity graph joins book_author twice instead: once in a subquery that picks the page of books, and once more to fetch all their authors. This test keeps it that way.
The reads are covered; the writes aren't. When the service saves or deletes a book, the Book mapping decides which book and book_author rows change. To check that, a test has to look at the tables themselves. Reading back through the repository would only show what Hibernate believes is there.
A write test reads the tables back with JdbcTemplate and plain SQL. @DataJpaTest provides one [docs], on the same connection and transaction as the test, so it sees the test's own writes and they are still rolled back at the end. JdbcTemplate is Spring's thin layer over JDBC: you give it SQL and parameters, it runs them and maps the result — queryForObject for one value, queryForList for one column of many rows [docs]. No JPA is involved, so the answer is what PostgreSQL has.
Three tests, one per write the service makes, and a helper that lists a book's author ids from the join table:
package com.example.library.book;
import java.util.List;import java.util.Set;import java.util.UUID;15 collapsed lines
import com.example.library.TestcontainersConfiguration;import com.example.library.author.Author;import jakarta.persistence.EntityManagerFactory;import org.hibernate.SessionFactory;import org.hibernate.stat.Statistics;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.data.jpa.test.autoconfigure.DataJpaTest;import org.springframework.boot.jpa.test.autoconfigure.TestEntityManager;import org.springframework.context.annotation.Import;import org.springframework.data.domain.PageRequest;import org.springframework.data.domain.Pageable;import org.springframework.data.domain.Sort;import org.springframework.jdbc.core.JdbcTemplate;11 collapsed lines
import static org.assertj.core.api.Assertions.assertThat;
@DataJpaTest( // the persistence layer only properties = "spring.jpa.properties.hibernate.generate_statistics=true") // Hibernate counts statements@Import(TestcontainersConfiguration.class) // ...on a real PostgreSQLclass BookRepositoryTest {
@Autowired BookRepository books; // the real repository, implemented by Spring Data @Autowired TestEntityManager entityManager; // puts the fixtures into the database @Autowired EntityManagerFactory entityManagerFactory; // where Hibernate keeps its statistics @Autowired JdbcTemplate jdbc; // plain SQL: what is really in the tables72 collapsed lines
Author hunt = new Author("Andrew Hunt"); Author thomas = new Author("David Thomas"); Author evans = new Author("Eric Evans"); Book pragmatic = new Book("The Pragmatic Programmer", 1999, Set.of(hunt, thomas)); Book ddd = new Book("Domain-Driven Design", 2003, Set.of(evans));
Pageable firstPage = PageRequest.of(0, 20, Sort.by("title")); Statistics statistics;
@BeforeEach void saveFixtures() { entityManager.persist(hunt); // authors first: the book's join rows point at them entityManager.persist(thomas); entityManager.persist(evans); entityManager.persist(pragmatic); entityManager.persist(ddd); entityManager.flush(); // send the inserts now, so the reset below wipes them entityManager.clear(); // forget them: every read below must come from the database
statistics = entityManagerFactory.unwrap(SessionFactory.class).getStatistics(); statistics.clear(); // reset the count: only what the test runs is counted }
@Test @DisplayName("findByAuthorsId returns only that author's books") void findByAuthorsId_onlyThatAuthorsBooks() { assertThat(books.findByAuthorsId(thomas.getId(), firstPage)).containsExactly(pragmatic); }
@Test @DisplayName("existsByAuthorsId is true for an author with a book") void existsByAuthorsId_withBooks() { assertThat(books.existsByAuthorsId(thomas.getId())).isTrue(); }
@Test @DisplayName("existsByAuthorsId is false for an author with no books") void existsByAuthorsId_withoutBooks() { Author fowler = entityManager.persist(new Author("Martin Fowler"));
assertThat(books.existsByAuthorsId(fowler.getId())).isFalse(); }
@Test @DisplayName("findAll loads a page of books and their authors in one statement") void findAll_oneStatement() { assertThat(books.findAll(firstPage)) .containsExactly(ddd, pragmatic) // the books, in title order .flatExtracting(Book::getAuthors) // reads every book's authors .containsExactlyInAnyOrder(evans, hunt, thomas); assertThat(statistics.getPrepareStatementCount()).as("SQL statements").isEqualTo(1); }
@Test @DisplayName("findById loads the book and its authors in one statement") void findById_oneStatement() { Book found = books.findById(pragmatic.getId()).orElseThrow();
assertThat(found.getAuthors()).containsExactlyInAnyOrder(hunt, thomas); // reads the authors assertThat(statistics.getPrepareStatementCount()).as("SQL statements").isEqualTo(1); }
@Test @DisplayName("findByAuthorsId loads every author of the books it finds, in one statement") void findByAuthorsId_allAuthorsOneStatement() { assertThat(books.findByAuthorsId(thomas.getId(), firstPage)) .containsExactly(pragmatic) .flatExtracting(Book::getAuthors) .containsExactlyInAnyOrder(hunt, thomas); // Hunt as well as Thomas assertThat(statistics.getPrepareStatementCount()).as("SQL statements").isEqualTo(1); }
@Test @DisplayName("save writes the book and one book_author row per author") void save_newBook() { Book ruby = new Book("Programming Ruby", 2000, Set.of(thomas, hunt)); // as BookService.create builds it
books.save(ruby); entityManager.flush(); // JdbcTemplate doesn't flush: send Hibernate's queued writes first
assertThat(jdbc.queryForObject("select title from book where id = ?", String.class, ruby.getId())) .isEqualTo("Programming Ruby"); assertThat(authorIdsOf(ruby.getId())).containsExactlyInAnyOrder(hunt.getId(), thomas.getId()); }
@Test @DisplayName("save with an existing id updates the book's row and replaces its author links") void save_existingId() { Book replacement = new Book("The Pragmatic Programmer", 2019, Set.of(thomas)); // as BookService.replace does replacement.setId(pragmatic.getId());
books.save(replacement); entityManager.flush();
assertThat(jdbc.queryForObject("select year from book where id = ?", Integer.class, pragmatic.getId())) .isEqualTo(2019); assertThat(authorIdsOf(pragmatic.getId())).containsExactly(thomas.getId()); // Hunt's link is gone }
@Test @DisplayName("deleteById removes the book and its author links, and leaves the authors") void deleteById_removesLinksNotAuthors() { books.deleteById(pragmatic.getId()); entityManager.flush();
assertThat(jdbc.queryForObject("select count(*) from book where id = ?", Long.class, pragmatic.getId())) .isZero(); assertThat(authorIdsOf(pragmatic.getId())).isEmpty(); assertThat(jdbc.queryForObject("select count(*) from author", Long.class)) .isEqualTo(3); // no cascade: Hunt and Thomas are still there }
// The ids of a book's authors, read straight from the join table. List<UUID> authorIdsOf(UUID bookId) { return jdbc.queryForList("select author_id from book_author where book_id = ?", UUID.class, bookId); }}entityManager.flush() before every SQL check. save and deleteById only queue their writes in the persistence context; Hibernate sends them when the transaction commits, or before a JPA query that needs them. JdbcTemplate is not JPA, so nothing flushes for it: leave the flush() out and authorIdsOf finds no rows at all, though the save "worked". It's the same lesson as Step 6's clear(), from the other side — the persistence context and the database can disagree, and the test has to decide which one it is checking.save_newBook builds the book the way BookService.create does, from authors that already exist. The test checks the book row and both join rows; the ids come back in no particular order, so containsExactlyInAnyOrder.save_existingId repeats BookService.replace: a new Book with the old id and a different author list — here David Thomas alone, as a client's PUT would send it. The new year must be in the book row, and Andrew Hunt's link must be gone, with no stale row left behind.deleteById_removesLinksNotAuthors checks both halves of Lesson 6's "no cascade": the book's join rows go with it — otherwise the foreign key would have rejected the delete — and its authors stay. Add cascade = CascadeType.REMOVE to the @ManyToMany and deleting the book deletes Andrew Hunt and David Thomas too: the last assertion then finds one author, not three.authorIdsOf is a plain helper method, not a test: no @Test, so JUnit doesn't run it on its own. The PostgreSQL driver returns a uuid column as a Java UUID, so the list compares directly with the fixtures' ids.Run the class:
./mvnw test -Dtest=BookRepositoryTest…Hibernate: select b1_0.id,b1_0.title,b1_0.year from book b1_0 where b1_0.id=?Hibernate: select a1_0.id,a1_0.name from author a1_0 where a1_0.id=?Hibernate: select a1_0.id,a1_0.name from author a1_0 where a1_0.id=?Hibernate: insert into book (title,year,id) values (?,?,?)Hibernate: insert into book_author (book_id,author_id) values (?,?)Hibernate: insert into book_author (book_id,author_id) values (?,?)…Hibernate: select b1_0.id,b1_0.title,b1_0.year from book b1_0 where b1_0.id=?Hibernate: select a1_0.book_id,a1_1.id,a1_1.name from book_author a1_0 join author a1_1 on a1_1.id=a1_0.author_id where a1_0.book_id=?Hibernate: update book set title=?,year=? where id=?Hibernate: delete from book_author where book_id=? and author_id=?…Hibernate: select b1_0.id,b1_0.title,b1_0.year from book b1_0 where b1_0.id=?Hibernate: delete from book_author where book_id=?Hibernate: delete from book where id=?[INFO] Tests run: 9, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 11.09 s -- in com.example.library.book.BookRepositoryTest…[INFO] BUILD SUCCESSNine green. The three blocks are the writes, one per test, after each test's fixtures:
selects before the inserts. Spring Data's save calls JPA's persist for a new entity and merge for an existing one, and it decides which by the id: an entity with an id that isn't null counts as existing [docs]. Book sets its UUIDv7 in the constructor (Lesson 5), so save merges, and the merge looks the book up first, finds nothing, then inserts. The two author selects load the authors the book points at, which the test had detached with clear(). Then come the book row and one book_author row per author.book row, and deletes exactly one join row: Andrew Hunt's. Hibernate compared the old author set with the new one and changed only the difference; David Thomas's row stays as it was.author row is touched.Run the whole test suite:
./mvnw test[INFO] T E S T S[INFO] -------------------------------------------------------[INFO] Running com.example.library.author.AuthorControllerTest…[INFO] Tests run: 12, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 9.123 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.926 s -- in com.example.library.author.AuthorServiceTest[INFO] Running com.example.library.book.BookControllerTest…2026-10-09T09:16:37.158Z INFO 5433 --- [ main] c.e.library.book.BookControllerTest : Started BookControllerTest in 0.708 seconds (process running for 15.856)[INFO] Tests run: 10, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 1.257 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.079 s -- in com.example.library.book.BookServiceTest[INFO] Running com.example.library.book.BookRepositoryTest…2026-10-09T09:16:42.774Z INFO 5433 --- [ main] tc.postgres:18 : Container postgres:18 started in PT2.022365209S…2026-10-09T09:16:49.304Z INFO 5433 --- [ main] c.e.library.book.BookRepositoryTest : Started BookRepositoryTest in 11.481 seconds (process running for 28.002)…[INFO] Tests run: 9, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 12.91 s -- in com.example.library.book.BookRepositoryTest[INFO] Running com.example.library.LibraryApplicationTests[WARNING] Tests run: 1, Failures: 0, Errors: 0, Skipped: 1, Time elapsed: 0.001 s -- in com.example.library.LibraryApplicationTests[INFO][INFO] Results:[INFO][INFO] Tests run: 52, Failures: 0, Errors: 0, Skipped: 1…[INFO] BUILD SUCCESSBookRepositoryTest: 9 tests, all green, and 52 in total. As in Lesson 11, the service-test counts include Lesson 10's exercises, so your total may be smaller.
The test classes now cover three layers, and each starts only what it needs. The service tests start nothing. The controller tests start Spring MVC, with no database, so they still run without Docker. BookRepositoryTest alone starts a container, and pays for it: 11.5 seconds to start, against 0.7 for BookControllerTest. That is the price of a real database, paid once per run however many repository tests share the configuration.
LibraryApplicationTests is still skipped. It starts the whole application, so it needs a database — the @Disabled from Lesson 5 said so. TestcontainersConfiguration is now that database; Lesson 13 imports it there and tests the application end to end.
Could not find a valid Docker environment? Testcontainers can't reach Docker. Start Docker Desktop (macOS) or the Docker service (Linux), check that docker ps works in the same terminal, and run the tests again.postgres:18 for a while? Docker is downloading the image. It happens once; if ./mvnw spring-boot:run already pulled postgres:18 for the app in Lesson 5, not even then.Answer from memory before revealing.
@DataJpaTest load?@ServiceConnection on the container bean do?@EntityGraph before clear()?@EntityGraph throw no LazyInitializationException here?save_newBook call flush() before reading with JdbcTemplate?Primary source: Spring Boot's reference on Testcontainers — service connections, containers as beans and their lifecycle, and how to reuse the same configuration when running the app locally. Then Vlad Mihalcea's How to detect the N+1 query problem during testing, which takes the statement-count idea further, per statement type.
Glossary: the terms this lesson introduces are in the Glossary, under Lesson 12 — your quick-reference for every term we use.
Architecture decisions: the 6 choices this lesson makes, each with its reason, are in Architecture Decisions, under Lesson 12.
Code: the project at the end of this lesson · what changed in this lesson, exercises included.