Controllers can be tested with @WebMvcTest and the whole application with @SpringBootTest, but when it comes to “I just want to lightly verify a Repository’s query,” do you find yourself stuck? Verifying it with @SpringBootTest is too heavyweight, yet a mock won’t tell you whether the actual SQL is correct.
That’s where @DataJpaTest comes in. This time, we’ll organize how to write slice tests focused on the Repository layer, along with criteria for deciding whether to use H2 or a real database (Testcontainers) for your test DB — complete with practical examples.
@DataJpaTest starts only the Repository layer
When you add @DataJpaTest, only JPA-related beans such as the EntityManager, Spring Data repositories, and the DataSource are loaded. Services, controllers, and @Component beans are not included in the context. In other words, you can isolate and verify just the Repository layer.
It’s easier to organize your thinking if you consider that there are three layers of tests.
| Annotation | Target | Startup scope |
|---|---|---|
@WebMvcTest | Controller layer | Web layer only |
@DataJpaTest | Repository layer | JPA-related beans only |
@SpringBootTest | Whole application | All beans |
With @WebMvcTest, we replaced the service with @MockBean, but for a Repository slice, issuing actual queries to the DB is itself the whole point. Mocking the Repository here would be meaningless, so as a rule we don’t use mocks.
Writing a minimal slice test
Let’s start with a working minimal example. You simply add @DataJpaTest and inject the Repository with @Autowired.
@DataJpaTest
class UserRepositoryTest {
@Autowired
UserRepository userRepository;
@Test
void save_then_findById() {
User saved = userRepository.save(new User("alice", "[email protected]"));
Optional<User> found = userRepository.findById(saved.getId());
assertThat(found).isPresent();
assertThat(found.get().getName()).isEqualTo("alice");
}
}
This assumes JUnit 5 and AssertJ. If an embedded DB such as H2 is on the classpath, it is used automatically with no configuration (we’ll look at this automatic replacement in detail later).
Automatic rollback keeps tests from being polluted
@DataJpaTest runs each test method inside a @Transactional boundary and automatically rolls back when it finishes. Since DB state doesn’t carry over between tests, you can write independent tests without worrying about execution order.
Convenient as it is, there’s also a pitfall. Because rollback is assumed, after a save() the data may only be sitting in JPA’s persistence context (the first-level cache) and the actual SQL may not have been sent to the DB. There are cases where findById() returns a value from the cache and you miss a mapping or query bug. The flush/clear in the next section is what prevents this.
Note that if you want to stop the rollback and actually commit, you can use @Rollback(false) or @Commit. This is handy when you want to peek at the DB contents during debugging.
Preparing data with TestEntityManager and reading it back
Use TestEntityManager to prepare test data. By using it purely for data insertion rather than going through the Repository, the setup and the Repository method under test don’t get mixed together, making the test more readable.
@DataJpaTest
class UserRepositoryQueryTest {
@Autowired
TestEntityManager em;
@Autowired
UserRepository userRepository;
@Test
void findByEmail_reads_from_db() {
em.persistAndFlush(new User("bob", "[email protected]"));
em.clear(); // empty the first-level cache
Optional<User> found = userRepository.findByEmail("[email protected]");
assertThat(found).isPresent();
assertThat(found.get().getName()).isEqualTo("bob");
}
}
The key points are using persistAndFlush to push the SQL to the DB and clear to empty the cache. This way, findByEmail actually re-reads from the DB, so it can properly detect mistakes in entity mappings or queries. Helper methods such as getId to obtain the ID and refresh to reload are also provided.
Switching between H2 and a real DB with @AutoConfigureTestDatabase
@DataJpaTest internally carries @AutoConfigureTestDatabase, and by default (Replace.ANY) it replaces the DataSource with embedded H2. Even if you’ve configured PostgreSQL in application.yml, it gets swapped out for H2 during testing.
If you want to verify against the same DB as production, disable the replacement.
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
class UserRepositoryWithRealDbTest {
// use the DataSource (e.g. PostgreSQL) from the application config as-is
}
The essence of this choice is the difference between the H2 dialect and the production DB dialect. Function names, data types, and SQL syntax differ subtly, so a query that passes on H2 can fail in production. We’ll dig into this trade-off in the next section.
Verifying query methods and @Query
For a derived query (findByXxx), insert several rows and then verify by count or order.
@Test
void findByActiveTrueOrderByName() {
em.persist(new User("carol", "[email protected]", true));
em.persist(new User("alice", "[email protected]", true));
em.persist(new User("dave", "[email protected]", false));
em.flush();
em.clear();
List<User> active = userRepository.findByActiveTrueOrderByNameAsc();
assertThat(active).extracting(User::getName)
.containsExactly("alice", "carol");
}
You can verify @Query the same way. JPQL is translated by the JPA implementation into each DB’s dialect, but a native query with nativeQuery = true depends directly on the DB dialect. This is where differences between H2 and a real DB tend to surface.
@Query(value = "SELECT * FROM users WHERE email ILIKE %:keyword%", nativeQuery = true)
List<User> searchByEmail(@Param("keyword") String keyword);
ILIKE is PostgreSQL-specific, so it won’t work on H2. For queries like this, you should consider testing against a real DB the moment they go native. Also note that for paging with Pageable or update queries involving @Modifying, if you don’t flush/clear after the update and then re-read, you’ll end up looking at a stale cache. For how to write derived queries themselves, see Introduction to Query Methods as well.
How to choose between H2 and Testcontainers
The deciding axis is simple: do you prioritize speed or production fidelity?
- H2 in-memory: starts up fast and is lightweight even in CI. However, due to differences in SQL dialect, functions, and types, there’s a risk of missing queries that won’t work in production.
- Testcontainers (real DB): since it uses a DB equivalent to production, it can even detect dialect differences. In exchange, there’s the overhead of starting up a Docker container.
As a rough guideline, H2 is sufficient if you’re mostly using standard derived queries. If you use native queries or DB-specific features (upsert, window functions, particular types, etc.), or if production fidelity is important, use a real DB with Testcontainers. Using both together — H2 for lightweight tests and a real DB only for dialect-dependent tests — is also a realistic approach.
Combining @DataJpaTest with Testcontainers
To connect to a real DB while keeping the slice lightweight, the key is to first stop the H2 replacement with Replace.NONE and have it use the container’s DataSource.
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Testcontainers
class UserRepositoryContainerTest {
@Container
static PostgreSQLContainer<?> postgres =
new PostgreSQLContainer<>("postgres:16-alpine");
@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 userRepository;
// verify all the way down to native queries against the same PostgreSQL as production
}
The basic pattern is to inject the container’s JDBC URL and credentials with @DynamicPropertySource. Starting a container for every test lengthens CI time, so there’s also the option of reusing it with the singleton container pattern. Introducing Testcontainers itself is covered in detail in the PostgreSQL/Kafka/Redis article, so please refer to that.
Common pitfalls
Here are the ones you’ll often run into in practice. When an assertion after save passes but no SQL appears in the logs, it’s often just that you’re looking at the first-level cache due to a missing flush/clear. If something passes on H2 but fails on Testcontainers or in production, suspect dialect differences such as function names, types, or upsert syntax. Conversely, by forgetting to add Replace.NONE, you may unknowingly be running on H2 when you meant to use a real DB.
Summary
With @DataJpaTest, you can isolate and verify just the Repository layer in a lightweight way. The reliable basics of verification were to prepare data with TestEntityManager and re-read it from the DB using flush/clear.
Decide on the DB choice based on the trade-off between H2 (speed) and Testcontainers (production fidelity), and especially consider a real DB when using native queries or DB-specific features. Combine it with the web-layer @WebMvcTest and the JUnit/Mockito basics to build out your overall testing strategy.