Spring Boot
DataJpaTest
@DataJpaTest is a Spring Boot test slice for the persistence layer. It boots only entities, Spring Data repositories, the EntityManager and the datasource, skips controllers and services, and runs every test inside a transaction that is rolled back at the end. It is the right tool for checking that your queries, mappings and database constraints do what you think they do. Pair it with Testcontainers when the production database is PostgreSQL or MySQL, because H2 will happily accept SQL your real database rejects.
What the Slice Loads, and What to Test With It
@DataJpaTest starts a much smaller application context than @SpringBootTest. It picks up @Entity classes, configures Spring Data repositories, provides a TestEntityManager, swaps your datasource for an embedded one, and wraps each test in a transaction that rolls back. Your @Service and @Component beans are not loaded, so if a repository test needs one, import it explicitly with @Import.
It is worth being deliberate about what goes in these tests. A derived query like findByEmail is generated by Spring Data and is rarely worth testing on its own; you would be testing the framework. Spend the effort on code you wrote: @Query methods (JPQL and native), Specifications, entity mappings such as cascades and orphan removal, and database constraints. Those are the places where a bug survives code review and a mocked repository can never catch it. The example below therefore tests a custom @Query, not a derived one.
One practical catch: auditing depends on where @EnableJpaAuditing lives. If it sits on your main application class, it is active in the slice. If it sits on a separate @Configuration class, the slice does not scan it, and a test that checks @CreatedDate or @LastModifiedDate will see null unless you import that class.
The examples in this entry target Spring Boot 3.1 or later.
Java
public interface UserRepository extends JpaRepository<User, Long> {
@Query("""
select u from User u
where u.role = :role
and lower(u.email) like lower(concat('%@', :domain))
""")
List<User> findByRoleAndEmailDomain(@Param("role") Role role,
@Param("domain") String domain);
}
@DataJpaTest
@Import(JpaAuditingConfig.class) // only needed if auditing lives in a separate @Configuration
class UserRepositoryTest {
@Autowired private UserRepository userRepository;
@Autowired private TestEntityManager em; // test-friendly wrapper around EntityManager
@Test
void save_assignsIdAndAuditFields() {
User saved = userRepository.save(
User.builder().name("Alice").email("alice@example.com").role(Role.USER).build());
assertThat(saved.getId()).isNotNull();
assertThat(saved.getCreatedAt()).isNotNull();
}
@Test
void findByRoleAndEmailDomain_matchesRoleAndDomainIgnoringCase() {
// Arrange through the EntityManager so the test does not depend on the code under test.
em.persist(User.builder().name("Admin").email("admin@corp.com").role(Role.ADMIN).build());
em.persist(User.builder().name("Boss").email("Boss@CORP.com").role(Role.ADMIN).build());
em.persist(User.builder().name("Other").email("x@other.com").role(Role.ADMIN).build());
em.persist(User.builder().name("Plain").email("u@corp.com").role(Role.USER).build());
em.flush();
List<User> result = userRepository.findByRoleAndEmailDomain(Role.ADMIN, "corp.com");
assertThat(result)
.extracting(User::getEmail)
.containsExactlyInAnyOrder("admin@corp.com", "Boss@CORP.com");
}
}The Flush and Clear Trap
Because the whole test runs in one transaction with one persistence context, a lot of JPA tests pass without actually touching the database. When you save an entity and then call findById, Hibernate returns the instance it already holds in its first-level cache. No SQL runs, so a broken column mapping or a missing cascade goes unnoticed.
Two habits fix this. Call flush() to push pending changes to the database, which is also the moment constraint violations are raised. Then call clear() to empty the persistence context so the next read has to go back to the database and build fresh objects. Any test that checks what was stored, as opposed to what is in memory, should do both before asserting.
Also remember that with IDENTITY ids the insert happens immediately, but with SEQUENCE ids (the default recommendation for PostgreSQL) Hibernate delays the insert until flush. A constraint test that never flushes will pass for the wrong reason.
Java
// Misleading: passes even if the items mapping is broken, because findById
// returns the very same Java object that save() was given.
@Test
void cascadeSavesItems_misleading() {
Order saved = orderRepository.save(orderWithTwoItems());
Order found = orderRepository.findById(saved.getId()).orElseThrow();
assertThat(found.getItems()).hasSize(2);
}
// Honest: forces the data through the database and back.
@Test
void cascadeSavesItems() {
Order saved = orderRepository.save(orderWithTwoItems());
em.flush(); // write pending SQL, constraint violations surface here
em.clear(); // drop cached entities so the next read hits the database
Order found = orderRepository.findById(saved.getId()).orElseThrow();
assertThat(found.getItems()).hasSize(2);
}Dependencies for Testcontainers
Add the Testcontainers support to the test scope. Spring Boot manages the versions, so you do not specify them. spring-boot-testcontainers is what provides @ServiceConnection. The artifact names below are the ones used with Spring Boot 3.x. Testcontainers 2 renamed its modules (they gain a testcontainers- prefix) and moved some packages, so check the names against the version your Boot release manages.
XML
<!-- pom.xml -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-testcontainers</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>Running Against a Real PostgreSQL with Testcontainers
By default the slice replaces your datasource with an in-memory H2. That is quick, but H2 is a different database. It accepts things PostgreSQL refuses, and it lacks things PostgreSQL has: JSONB operators, array columns, window function quirks, full-text search, ON CONFLICT, and different rules for case sensitivity and ordering. If your production database is PostgreSQL, queries that only ever ran on H2 are untested.
Tell the slice to leave your datasource alone with @AutoConfigureTestDatabase(replace = NONE), and start a container. On Spring Boot 3.1 and later, annotate the container field with @ServiceConnection and Boot wires the JDBC URL and credentials for you, replacing the older @DynamicPropertySource boilerplate.
Starting a container per test class gets slow once you have a handful of repository tests. The usual fix is a shared abstract base class that starts one container for the whole run. Make sure Flyway or Liquibase migrations run in these tests too, since validating the real schema is half the point. If you rely on ddl-auto, set it to create-drop explicitly, because it is only the default for embedded databases.
Java
// Shared by every repository test, so the container starts once per JVM.
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Import(JpaAuditingConfig.class) // only if auditing lives in a separate @Configuration
abstract class PostgresRepositoryTest {
@ServiceConnection
static final PostgreSQLContainer<?> POSTGRES =
new PostgreSQLContainer<>("postgres:16-alpine");
static {
POSTGRES.start(); // singleton container; Ryuk cleans it up when the JVM exits
}
}
// Single-class alternative, with the container managed by the JUnit extension:
//
// @DataJpaTest
// @Testcontainers
// @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
// class OrderRepositoryTest {
// @Container
// @ServiceConnection
// static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");
// }
//
// On Boot older than 3.1, expose the same values with @DynamicPropertySource:
// registry.add("spring.datasource.url", postgres::getJdbcUrl);
// registry.add("spring.datasource.username", postgres::getUsername);
// registry.add("spring.datasource.password", postgres::getPassword);
class OrderRepositoryTest extends PostgresRepositoryTest {
@Autowired private OrderRepository orderRepository;
@Autowired private TestEntityManager em;
@Test
void findByUserId_returnsRequestedPage() {
for (int i = 0; i < 15; i++) {
em.persist(Order.builder().userId(10L).status(OrderStatus.COMPLETED).build());
}
em.flush();
em.clear();
Page<Order> page = orderRepository.findByUserId(
10L, PageRequest.of(0, 10, Sort.by("createdAt").descending()));
assertThat(page.getTotalElements()).isEqualTo(15);
assertThat(page.getTotalPages()).isEqualTo(2);
assertThat(page.getContent()).hasSize(10);
}
@Test
void sumTotalAmountByUserId_addsOnlyThatUsersOrders() {
em.persist(Order.builder().userId(1L).totalAmount(new BigDecimal("50.00")).build());
em.persist(Order.builder().userId(1L).totalAmount(new BigDecimal("30.00")).build());
em.persist(Order.builder().userId(2L).totalAmount(new BigDecimal("100.00")).build());
em.flush();
assertThat(orderRepository.sumTotalAmountByUserId(1L)).isEqualByComparingTo("80.00");
}
// A PostgreSQL-only feature. This one cannot be tested on H2 at all.
// The repository method is a native query such as: where metadata ->> 'channel' = :channel
// The entity field needs @JdbcTypeCode(SqlTypes.JSON) (Hibernate 6) or PostgreSQL
// rejects a varchar written into a jsonb column.
@Test
void findByMetadataChannel_matchesJsonbField() {
em.persistAndFlush(Order.builder()
.userId(1L)
.status(OrderStatus.COMPLETED)
.metadata("{\"channel\":\"mobile\"}")
.build());
em.clear();
assertThat(orderRepository.findByMetadataChannel("mobile")).hasSize(1);
assertThat(orderRepository.findByMetadataChannel("web")).isEmpty();
}
}Constraints and Relationship Mappings
Unique and not-null constraints, foreign keys, cascades and orphan removal are all behaviour you configured, and all of them can be wrong in ways that only show up against a real schema. These tests are cheap to write and catch real regressions when someone edits an entity.
A few details decide whether the test is meaningful. Constraint violations are only raised on flush, so use saveAndFlush or call em.flush(). Spring translates exceptions thrown through a repository into DataIntegrityViolationException, but TestEntityManager does not translate them, so you will see a raw PersistenceException if the violating call goes through it; keep the failing call on the repository. If the field also carries a Bean Validation annotation such as @NotNull, Hibernate Validator fails first with jakarta.validation.ConstraintViolationException, so the not-null test below only exercises the database column when the field is declared nullable = false without @NotNull.
For collections that Hibernate manages, use mutable lists. List.of produces an immutable list, and Hibernate will fail as soon as it tries to modify it for orphan removal. With Lombok's @Builder, a field initializer such as items = new ArrayList<>() is ignored unless the field is marked @Builder.Default, so a built Order would have a null items list. Finally, on a bidirectional mapping set both sides of the relationship, ideally through a helper method like order.addItem(item) on the entity, otherwise the foreign key column ends up null.
Java
class EntityConstraintTest extends PostgresRepositoryTest {
@Autowired private UserRepository userRepository;
@Autowired private OrderRepository orderRepository;
@Autowired private TestEntityManager em;
@Test
void duplicateEmail_violatesUniqueConstraint() {
userRepository.saveAndFlush(User.builder().name("Alice").email("alice@x.com").build());
assertThatThrownBy(() ->
userRepository.saveAndFlush(User.builder().name("Impostor").email("alice@x.com").build()))
.isInstanceOf(DataIntegrityViolationException.class);
}
@Test
void missingEmail_violatesNotNullConstraint() {
assertThatThrownBy(() ->
userRepository.saveAndFlush(User.builder().name("No Email").build()))
.isInstanceOf(DataIntegrityViolationException.class);
}
@Test
void savingOrder_cascadesToItems() {
Order order = Order.builder().userId(1L).build(); // items needs @Builder.Default
order.addItem(OrderItem.builder().productId(10L).quantity(2).build()); // sets both sides
order.addItem(OrderItem.builder().productId(11L).quantity(1).build());
Long id = orderRepository.save(order).getId();
em.flush();
em.clear();
assertThat(orderRepository.findById(id).orElseThrow().getItems()).hasSize(2);
}
@Test
void removingItemFromOrder_deletesItsRow() {
Order order = Order.builder().userId(1L).build(); // items must be a mutable list
order.addItem(OrderItem.builder().productId(10L).quantity(1).build());
Long id = orderRepository.saveAndFlush(order).getId();
em.clear();
Order loaded = orderRepository.findById(id).orElseThrow();
loaded.getItems().clear(); // orphanRemoval = true on the mapping
em.flush();
em.clear();
assertThat(orderRepository.findById(id).orElseThrow().getItems()).isEmpty();
assertThat(em.getEntityManager()
.createQuery("select count(i) from OrderItem i", Long.class)
.getSingleResult()).isZero(); // the row is gone, not just detached
}
}Custom Queries and Specifications
This is where the slice earns its keep. Seed a small, deliberately varied data set, run the query, and assert on exactly which rows come back. Include rows that should be excluded, since a query that returns everything passes any test that only checks the matches. Boundaries deserve their own case: BETWEEN is inclusive at both ends, so a product priced exactly at the limit is a useful row to include.
Seeding in @BeforeEach is fine because each test is rolled back, so the data never leaks from one test to another.
For the empty-criteria case, build an always-true Specification with a lambda. Passing null to Specification.where(...) is deprecated or ambiguous in recent Spring Data JPA versions, and Specification.unrestricted() only exists from 3.5.
Java
@DataJpaTest
class ProductRepositoryQueryTest {
@Autowired private ProductRepository productRepository;
@Autowired private TestEntityManager em;
@BeforeEach
void seed() {
em.persist(product("Laptop", "Electronics", "999.99", true));
em.persist(product("Phone", "Electronics", "499.99", true));
em.persist(product("Desk", "Furniture", "299.99", false));
em.persist(product("Chair", "Furniture", "199.99", true));
em.flush();
em.clear();
}
@Test
void findByCategoryAndInStock_excludesOutOfStockAndOtherCategories() {
assertThat(productRepository.findByCategoryAndInStock("Electronics", true))
.extracting(Product::getName)
.containsExactlyInAnyOrder("Laptop", "Phone");
}
@Test
void findByPriceBetween_isInclusiveAtBothEnds() {
List<Product> result = productRepository.findByPriceBetween(
new BigDecimal("199.99"), new BigDecimal("499.99"));
assertThat(result)
.extracting(Product::getName)
.containsExactlyInAnyOrder("Phone", "Desk", "Chair");
}
@Test
void specification_combinesCriteria() {
Specification<Product> spec = ProductSpecification.hasCategory("Furniture")
.and(ProductSpecification.isInStock(true));
assertThat(productRepository.findAll(spec))
.extracting(Product::getName)
.containsExactly("Chair");
}
@Test
void specification_withNoCriteria_returnsEverything() {
Specification<Product> everything = (root, query, cb) -> cb.conjunction();
assertThat(productRepository.findAll(everything)).hasSize(4);
}
private static Product product(String name, String category, String price, boolean inStock) {
return Product.builder()
.name(name).category(category)
.price(new BigDecimal(price)).inStock(inStock)
.build();
}
}Troubleshooting and Limits
If a test passes alone but fails with others, look for static state or data inserted outside the test transaction; anything committed by a different transaction will not be rolled back. If you genuinely need data to be committed, for example to test code that uses REQUIRES_NEW, annotate the test with @Transactional(propagation = NOT_SUPPORTED) and clean up yourself, or use @Rollback(false) sparingly.
If a query behaves differently from production, the first suspect is H2. Switch to a Testcontainers database before debugging anything else. H2's MODE=PostgreSQL helps a little, but it is an approximation.
To see what Hibernate actually executes, set logging.level.org.hibernate.SQL=DEBUG for the test. This is also the quickest way to spot an N+1 problem, where loading a list triggers one extra query per row.
Finally, know where the slice stops. It does not test your service logic, transaction boundaries across services, or security. For wiring and end-to-end behaviour use @SpringBootTest, and for controllers use @WebMvcTest. Each slice answers one question, and DataJpaTest's question is: does my persistence code work against a real database?
application.properties
# src/test/resources/application.properties
# (a file named application-test.properties only loads if you add @ActiveProfiles("test"))
logging.level.org.hibernate.SQL=DEBUG
logging.level.org.hibernate.orm.jdbc.bind=TRACE # bound parameter values (Hibernate 6)
# Check schema, not just queries: run real migrations in repository tests
spring.flyway.enabled=true
spring.jpa.hibernate.ddl-auto=validate # fail if entities and migrations disagreeFAQ
Does @DataJpaTest roll back by default?
Yes. Each test runs in a transaction that is rolled back when the test ends. Use @Rollback(false) or @Commit only when you need the data to persist.
Why does my test pass but the same query fails in production?
The slice uses H2 unless you disable that. Add @AutoConfigureTestDatabase(replace = NONE) and run against a Testcontainers database that matches production.
What is the difference between @DataJpaTest and @SpringBootTest?
@DataJpaTest loads only the persistence layer and is faster. @SpringBootTest loads the whole application context, including services and controllers, and is meant for integration tests.
Why is my entity not found after save in a test?
Usually it is, but it came from the first-level cache, not the database. Call flush() and clear() before reading back to test what was actually stored.
Do I need to test derived queries like findByEmail?
Rarely. Test @Query methods, Specifications, mappings and constraints, which are the parts you wrote.
Why does my entity named Order fail to create a table?
ORDER is a reserved SQL word. Map the entity to a different table name, for example @Table(name = "orders").