Spring Boot Course · Lesson 12

Testing the Data Layer

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.

The win

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.

What only the database can check

Look at what BookRepository actually contains:

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.

Why not an in-memory database?

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.

Step 1 — The test dependencies

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).

pom.xml
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>

Step 2 — One database for every test that needs one

The container is declared once, in a test configuration class in the base test package:

src/test/java/com/example/library/TestcontainersConfiguration.java
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 them
public 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
}
}

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.

Step 3 — The test class and the derived query

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:

src/test/java/com/example/library/book/BookRepositoryTest.java
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 PostgreSQL
class 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);
}
}
@AutoConfigureTestDatabase is not needed

Out 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.

Step 4 — The existence check

Two more tests, below the first: existsByAuthorsId for an author with a book, and for one without.

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

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

Read it top to bottom:

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.

Step 5 — Count the statements

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:

src/test/java/com/example/library/book/BookRepositoryTest.java
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 PostgreSQL
class 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 statistics
8 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);
}
}

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

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: 9L

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

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

Four 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.

Step 6 — Make it fail

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:

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

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

It 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 persistence context remembers

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:

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

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

Now 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.

Step 7 — The other two entity graphs

findById and findByAuthorsId carry the same annotation and get the same kind of test. Add both below the findAll test:

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

Step 8 — What a write leaves in the tables

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:

src/test/java/com/example/library/book/BookRepositoryTest.java
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 PostgreSQL
class 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 tables
72 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);
}
}

Run the class:

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

Nine green. The three blocks are the writes, one per test, after each test's fixtures:

Step 9 — Run it

Run the whole test suite:

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

BookRepositoryTest: 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.

If it doesn't behave
  • 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.
  • The first run sits at 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.
  • The Mockito "self-attaching" warning? The same harmless warning as in Lesson 10.

Check yourself

Answer from memory before revealing.

Which beans does @DataJpaTest load?
What does @ServiceConnection on the container bean do?
Why did the count test pass without @EntityGraph before clear()?
Why does a missing @EntityGraph throw no LazyInitializationException here?
Why does save_newBook call flush() before reading with JdbcTemplate?
Why doesn't Martin Fowler from one test appear in the next?

Read this next

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.

💬 Discuss