Testing the Data Layer
Reviewed & published by Brayan K
@DataJpaTest slices your context down to the persistence layer, runs each test in a rolled-back transaction against an embedded H2 database, and — when fidelity matters — Testcontainers swaps in a real database in Docker.
Part of the free Java course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
Before You Start
You should have met JUnit 5 and slice tests in Testing with @SpringBootTest & MockMvc, and understand Spring Data repositories and JPA entities. Comfort with collections helps for the demos.
What You'll Learn
- What the @DataJpaTest slice loads
- Why each test rolls back its transaction
- Using TestEntityManager to arrange data
- Testing derived and custom query methods
- H2 in-memory database trade-offs
- Higher-fidelity testing with Testcontainers
The Big Idea — A Whiteboard You Wipe After Every Test
💡 Analogy: Think of @DataJpaTest as a small whiteboard next to your desk rather than the company's permanent records. For each test you scribble some rows on it (persist data), run your query, read the answer — then it's wiped clean (the transaction rolls back) before the next test. The whiteboard (H2) is fast and disposable. But sometimes the whiteboard marker behaves differently from the real archival ink, so for the cases that must match production exactly you bring in the real filing cabinet — a genuine PostgreSQL spun up by Testcontainers — used once and thrown away.
Fast and disposable for most tests; real and high-fidelity when correctness against production matters.
1️⃣ The Pattern — Save, Query, Assert
A repository test arranges known rows, runs a query method, and asserts the results. @DataJpaTest does this against H2; the plain-Java model below uses an in-memory map so the rhythm is clear.
import java.util.*;
import java.util.stream.*;
public class Main {
// @DataJpaTest tests a REPOSITORY against a fast in-memory DB (H2). We model
// the same idea with an in-memory repository so the testing pattern — save
// known rows, run a query, assert results — is concrete without JPA.
record User(Long id, String name, String city) {}
static class UserRepo {
private final Map<Long, User> rows = new LinkedHashMap<>();
void save(User u) { rows.put(u.id(), u); }
// A "derived query" like findByCity(...) in Spring Data.
List<User> findByCity(String city) {
return rows.values().stream()
.filter(u -> u.city().equals(city))
.collect(Collectors.toList());
}
}
public static void main(String[] args) {
UserRepo repo = new UserRepo(); // the slice-under-test
repo.save(new User(1L, "Ada", "London")); // arrange known data
repo.save(new User(2L, "Bo", "Paris"));
repo.save(new User(3L, "Cy", "London"));
List<User> londoners = repo.findByCity("London"); // act
System.out.println("London count: " + londoners.size()); // assert
System.out.println("names: " + londoners.stream().map(User::name).toList());
}
}2️⃣ Isolation — Rolled-Back Transactions
@DataJpaTest wraps each test in a transaction and rolls it back afterward, so tests never see each other's writes. The demo simulates that with a snapshot/restore.
import java.util.*;
public class Main {
// @DataJpaTest is TRANSACTIONAL: each test's writes are rolled back, so tests
// don't pollute each other. We simulate that isolation with a snapshot that
// we restore after the "test" — the same guarantee, hand-rolled.
static Map<Long, String> table = new HashMap<>(Map.of(1L, "Ada"));
static void runTestWithRollback(Runnable test) {
Map<Long, String> snapshot = new HashMap<>(table); // begin "transaction"
try {
test.run();
} finally {
table = snapshot; // ROLLBACK after test
}
}
public static void main(String[] args) {
System.out.println("before: " + table);
runTestWithRollback(() -> {
table.put(2L, "Bo"); // a write inside the test
System.out.println("inside test: " + new TreeMap<>(table));
});
// After rollback, the inserted row is gone — tests stay isolated.
System.out.println("after: " + table);
}
}3️⃣ The Real Thing — @DataJpaTest
In Spring you annotate the class with @DataJpaTest, inject TestEntityManager and your repository, persist data, and assert. It loads only the data layer and rolls back automatically.
@DataJpaTest
class UserRepositoryTest {
@Autowired TestEntityManager em;
@Autowired UserRepository repository; // slice loads these only
}// SPRING DATA JPA slice test (needs spring-boot-starter-data-jpa + a test DB).
// @DataJpaTest loads ONLY JPA bits: repositories, EntityManager, and an
// embedded H2 datasource. It is transactional and rolls back after each test.
@DataJpaTest
class UserRepositoryTest {
@Autowired
private TestEntityManager em; // helper to persist/flush test data
@Autowired
private UserRepository repository; // the repository under test
@Test
void findsUsersByCity() {
// Arrange: persist known rows directly.
em.persist(new User("Ada", "London"));
em.persist(new User("Bo", "Paris"));
em.persist(new User("Cy", "London"));
em.flush();
// Act: run the derived query method.
List<User> londoners = repository.findByCity("London");
// Assert.
assertThat(londoners).hasSize(2)
.extracting(User::getName)
.containsExactlyInAnyOrder("Ada", "Cy");
}
// No cleanup needed: the transaction rolls back automatically.
}4️⃣ Higher Fidelity — Testcontainers
When H2's dialect isn't enough, Testcontainers runs a real database in Docker for the test, so you exercise the same engine as production. You disable the H2 swap and point the datasource at the container.
// HIGHER FIDELITY: run a REAL PostgreSQL in Docker via Testcontainers, so tests
// use the same engine as production (needs the Testcontainers + JUnit 5 deps
// and a running Docker). @DataJpaTest with @AutoConfigureTestDatabase(NONE)
// keeps the real container datasource instead of swapping in H2.
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Testcontainers
class UserRepositoryPgTest {
@Container
static PostgreSQLContainer<?> postgres =
new PostgreSQLContainer<>("postgres:16"); // throwaway real DB
@DynamicPropertySource
static void props(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
}
@Autowired UserRepository repository;
@Test
void worksAgainstRealPostgres() {
repository.save(new User("Ada", "London"));
assertThat(repository.findByCity("London")).hasSize(1);
}
}🧠 Quick Recall
Predict before you peek.
Answer: an embedded in-memory database such as H2, swapped in for speed and isolation.
Answer: the surrounding transaction is rolled back, so changes are discarded and tests stay isolated.
Answer: to test against the real production database engine and avoid H2 dialect differences.
🎯 YOUR TURN — A Derived Query
Implement findByActive(boolean) on the in-memory repo and assert the active count.
import java.util.*;
import java.util.stream.*;
public class Main {
// YOUR TURN: add a derived-query method to this in-memory repository.
// Implement findByActive(boolean active) returning matching users, then
// assert the count of active users.
//
// Expected output:
// active users: 2
record User(Long id, String name, boolean active) {}
static class UserRepo {
final List<User> rows = new ArrayList<>();
void save(User u) { rows.add(u); }
// List<User> findByActive(boolean active) { ... }
}
public static void main(String[] args) {
UserRepo repo = new UserRepo();
repo.save(new User(1L, "Ada", true));
repo.save(new User(2L, "Bo", false));
repo.save(new User(3L, "Cy", true));
// System.out.println("active users: " + repo.findByActive(true).size());
}
}🧩 MINI-CHALLENGE — Prove Test Isolation
Run two tests through a rollback wrapper so the second can't see the first's inserts.
import java.util.*;
public class Main {
// MINI-CHALLENGE: prove test isolation. Run TWO tests against a shared table
// using a rollback wrapper (snapshot/restore). The SECOND test must NOT see
// rows inserted by the FIRST — that is the @DataJpaTest guarantee.
//
// Expected output:
// test1 sees rows: 1 -> inserts 1
// test2 sees rows: 1 (isolated!)
static List<String> table = new ArrayList<>(List.of("seed"));
static void withRollback(Runnable test) {
// 1. snapshot 'table'
// 2. run test
// 3. restore the snapshot (rollback)
}
public static void main(String[] args) {
// withRollback(() -> { print size; table.add("t1row"); });
// withRollback(() -> { print size; }); // should still be 1
}
}Common Errors
- ❌ Expecting @DataJpaTest to load services/controllers. The slice excludes them. Fix: use @SpringBootTest if you need other layers.
- ❌ Relying on data from a previous test. Rollback wipes it. Fix: arrange data inside each test.
- ❌ Assuming H2 == PostgreSQL. Dialects differ. Fix: use Testcontainers for native/dialect features.
- ❌ Forgetting to flush. Queries may not see un-flushed inserts. Fix: call em.flush() after persisting.
- ❌ Disabling rollback unnecessarily. @Commit leaks state across tests. Fix: keep the default rollback.
- ❌ No Docker for Testcontainers. Tests fail to start. Fix: ensure Docker is running in the test environment.
Pro Tips
- 💡 Use TestEntityManager to set up data — it persists and flushes cleanly for repository tests.
- 💡 Default to H2 for speed; reach for Testcontainers on the queries that must match production.
- 💡 Assert with AssertJ collections matchers like extracting(...) and containsExactlyInAnyOrder(...).
- 💡 Reuse one Testcontainers instance across tests (static container) to avoid repeated startup cost.
📋 Quick Reference
| Concept | Syntax / idea | Note |
|---|---|---|
| data slice | @DataJpaTest | JPA layer only |
| default DB | embedded H2 | Fast, in-memory |
| isolation | transaction rolled back | Per test |
| arrange data | TestEntityManager | persist + flush |
| query test | repository.findByCity(...) | Assert results |
| assertions | assertThat(list).hasSize(...) | AssertJ |
| real DB | @Testcontainers | Docker container |
| keep real DB | @AutoConfigureTestDatabase(NONE) | No H2 swap |
| container | PostgreSQLContainer | Real engine |
| wire props | @DynamicPropertySource | datasource URL |
📚 Resources / Keep Learning
- 📘 Auto-configured tests — Spring Boot reference
- 📗 Testcontainers for Java — official docs
- 📗 Accessing Data with JPA — Spring guide
🎉 Lesson Complete!
You now know how @DataJpaTest slices the context to the data layer, why each test rolls back for isolation, how to arrange data with TestEntityManager and assert query results, and when to trade H2's speed for Testcontainers' production fidelity.
Practice quiz
What does @DataJpaTest configure?
- A slice with JPA repositories, an EntityManager, and a test database
- The full application context
- Only the web layer
- A message broker
Answer: A slice with JPA repositories, an EntityManager, and a test database. @DataJpaTest is a test slice that loads JPA-related components (repositories, EntityManager) against a test datasource, not the whole app.
By default, what happens to changes in a @DataJpaTest method?
- They are committed permanently
- Each test runs in a transaction that is rolled back afterward
- They are written to production
- They are ignored entirely
Answer: Each test runs in a transaction that is rolled back afterward. @DataJpaTest is transactional by default and rolls back after each test, keeping tests isolated.
Which embedded database does @DataJpaTest use by default if available?
- Oracle
- MongoDB
- An embedded in-memory database such as H2
- Redis
Answer: An embedded in-memory database such as H2. By default it replaces the datasource with an embedded in-memory DB like H2 for fast, isolated runs.
Why are test 'slices' useful?
- They encrypt the data
- They run on the GPU
- They remove the need for assertions
- They load only the components relevant to one concern, so tests start faster
Answer: They load only the components relevant to one concern, so tests start faster. Slices narrow the loaded context to one layer, making tests faster and more focused than booting the full app.
What is TestEntityManager provided by @DataJpaTest for?
- Sending HTTP requests
- Persisting and flushing test data directly to set up repository tests
- Mocking controllers
- Logging only
Answer: Persisting and flushing test data directly to set up repository tests. TestEntityManager is a test-focused EntityManager helper to persist, flush, and find entities when arranging repository tests.
What problem do Testcontainers solve?
- They compile code faster
- They replace JUnit
- They run real dependencies (e.g. a real PostgreSQL) in Docker containers for tests
- They generate UML
Answer: They run real dependencies (e.g. a real PostgreSQL) in Docker containers for tests. Testcontainers spin up real services in throwaway Docker containers, so tests run against the same database type as production.
Compared with H2, a Testcontainers PostgreSQL gives you...
- Higher fidelity — the same database engine as production
- Lower fidelity to production
- No need for Docker
- Faster startup always
Answer: Higher fidelity — the same database engine as production. H2 is fast but can differ subtly from production SQL; a real PostgreSQL container matches production behaviour closely.
A drawback of an in-memory H2 database in tests is...
- It is too slow to start
- It cannot store data
- It requires Docker
- Its SQL dialect can differ from your production database
Answer: Its SQL dialect can differ from your production database. H2's dialect and behaviour aren't identical to, say, PostgreSQL, so some bugs only surface against the real engine.
Which annotation marks a repository test class for the JPA slice?
- @WebMvcTest
- @DataJpaTest
- @RestController
- @Configuration
Answer: @DataJpaTest. @DataJpaTest is the slice annotation for testing the persistence layer.
Why does @DataJpaTest NOT load your @Service and @Controller beans?
- Because they are deprecated
- Because they cannot be tested
- Because the slice intentionally limits the context to the data layer
- Because Java forbids it
Answer: Because the slice intentionally limits the context to the data layer. The slice deliberately excludes other layers to stay focused on persistence; load them with @SpringBootTest if needed.
Continue this course
- Previous: Testing with @SpringBootTest & MockMvc
- Next: Monitoring with Actuator — /health, /metrics, /info endpoints and Micrometer
- Quick reference: Java cheat sheet
Frequently asked questions
What does @DataJpaTest set up for me?
@DataJpaTest is a Spring Boot test slice for the persistence layer. Instead of loading the whole application, it configures only JPA-related components: your Spring Data repositories, the EntityManager, the JPA configuration, and — by default — an embedded in-memory database such as H2. It also provides a TestEntityManager helper for persisting and flushing test data. Because it loads so little, repository tests start quickly and stay focused on data-access behaviour rather than the entire app.
Why are @DataJpaTest tests rolled back automatically?
Each @DataJpaTest method runs inside a transaction that is rolled back when the test finishes. This gives you isolation for free: writes made in one test never leak into another, so tests don't depend on order and you don't need manual cleanup. You simply persist the data your test needs, exercise the repository, assert on the results, and trust that the database returns to its prior state afterward. If you genuinely need a commit, you can override this with @Rollback(false) or @Commit, but rollback is the safe default.
When should I use Testcontainers instead of H2?
Use H2 when you want maximum speed and your queries use only portable SQL — it's an excellent default for fast feedback. Reach for Testcontainers when fidelity to production matters: it launches a real database (for example a genuine PostgreSQL) inside a throwaway Docker container, so your tests run against the exact engine, dialect, and features you deploy with. This catches dialect-specific bugs, native queries, and database features that H2 emulates imperfectly. The trade-off is that Testcontainers needs Docker and starts more slowly than an embedded database.
What is a 'test slice' and why does it matter?
A test slice is a Spring Boot annotation that loads only the part of the application context relevant to one concern — @DataJpaTest for persistence, @WebMvcTest for controllers, @JsonTest for serialization, and so on. Slices matter because loading the full context with @SpringBootTest is comparatively slow; by narrowing what's loaded, slice tests start faster and pinpoint failures to a single layer. The result is a quicker, more diagnostic test suite where most tests are small slices and only a few boot the whole app.
How do I test a custom Spring Data repository query?
Inside a @DataJpaTest, arrange known data using TestEntityManager (persist a few entities and flush), then call your repository method — whether it's a derived query like findByCity, an @Query JPQL method, or a native query — and assert that the returned entities match what you expect. Because the data is controlled and the transaction rolls back afterward, the test is deterministic. This is the reliable way to confirm a query method actually selects, filters, and orders rows the way its name or JPQL implies.