@DataJpaTest: Testing Spring Data JPA Repositories
@DataJpaTest loads only the JPA slice of the Spring context — entities, repositories, and the embedded database — making repository tests fast and isolated. This guide covers everything from basic CRUD tests to custom queries, pagination, and testing against a real database.
Key Takeaways
@DataJpaTest uses H2 in-memory by default. Each test class gets a fresh schema. No external database needed for most repository tests.
Only JPA-related beans are loaded. Services, controllers, and non-JPA components are excluded — use @Import if you need them.
Each test method runs in a transaction that rolls back. Test data doesn't leak between methods, which keeps tests independent.
@AutoConfigureTestDatabase(replace = NONE) switches to your real database. Use this with Testcontainers for testing database-specific features like JSON columns or full-text search.
Custom @Query methods need explicit tests. Derived query methods (findByName) are safe by convention; custom JPQL and native queries need coverage to catch typos and logic errors.
Repository tests are the foundation of your data layer confidence. A broken query found at the repository layer costs a minute to fix. The same bug found in production costs hours. @DataJpaTest makes writing these tests fast enough that you have no excuse to skip them.
Basic Setup
@DataJpaTest
class ProductRepositoryTest {
@Autowired
private ProductRepository productRepository;
@Autowired
private TestEntityManager entityManager;
@Test
void shouldSaveAndRetrieveProduct() {
Product product = new Product("Widget", BigDecimal.valueOf(9.99), "electronics");
Product saved = productRepository.save(product);
assertThat(saved.getId()).isNotNull();
Product found = productRepository.findById(saved.getId()).orElseThrow();
assertThat(found.getName()).isEqualTo("Widget");
assertThat(found.getPrice()).isEqualByComparingTo("9.99");
}
}TestEntityManager is a thin wrapper around JPA's EntityManager, designed for test scenarios. It's useful for persisting test data without going through the repository you're testing.
What Gets Loaded
@DataJpaTest configures:
- Spring Data JPA repositories
- JPA entities (
@Entityclasses) - An embedded H2 database (by default)
EntityManager/TestEntityManager- Flyway or Liquibase (if on the classpath)
- JPA auditing (if configured)
What it does NOT load:
@Service,@Component,@Controllerbeans@ConfigurationPropertiesbeans (unless they're JPA-specific)- Security configuration
- Web layer
If your repository has a custom implementation that depends on another bean, use @Import:
@DataJpaTest
@Import(AuditingConfiguration.class)
class AuditedProductRepositoryTest { ... }Using TestEntityManager
TestEntityManager is the right tool for setting up test data. It bypasses the repository being tested, giving you a clean separation between "given" (test data) and "when" (the repository method under test):
@Test
void shouldFindProductsByCategory() {
entityManager.persist(new Product("Widget", BigDecimal.valueOf(9.99), "electronics"));
entityManager.persist(new Product("Gadget", BigDecimal.valueOf(49.99), "electronics"));
entityManager.persist(new Product("Novel", BigDecimal.valueOf(14.99), "books"));
entityManager.flush();
List<Product> electronics = productRepository.findByCategory("electronics");
assertThat(electronics).hasSize(2)
.extracting(Product::getName)
.containsExactlyInAnyOrder("Widget", "Gadget");
}The flush() call is important — it writes the pending changes to the database so your query can find them.
Testing Custom JPQL Queries
// ProductRepository.java
@Repository
public interface ProductRepository extends JpaRepository<Product, Long> {
@Query("SELECT p FROM Product p WHERE p.price BETWEEN :min AND :max ORDER BY p.price ASC")
List<Product> findByPriceRange(@Param("min") BigDecimal min, @Param("max") BigDecimal max);
@Query("SELECT p FROM Product p WHERE LOWER(p.name) LIKE LOWER(CONCAT('%', :term, '%'))")
List<Product> searchByName(@Param("term") String term);
@Query("SELECT COUNT(p) FROM Product p WHERE p.category = :category AND p.active = true")
long countActiveByCategory(@Param("category") String category);
}@DataJpaTest
class ProductRepositoryQueryTest {
@Autowired
private ProductRepository productRepository;
@Autowired
private TestEntityManager entityManager;
@BeforeEach
void setUp() {
entityManager.persist(new Product("Budget Widget", BigDecimal.valueOf(5.00), "electronics", true));
entityManager.persist(new Product("Mid Widget", BigDecimal.valueOf(25.00), "electronics", true));
entityManager.persist(new Product("Premium Widget", BigDecimal.valueOf(99.00), "electronics", false));
entityManager.persist(new Product("Cheap Book", BigDecimal.valueOf(8.00), "books", true));
entityManager.flush();
}
@Test
void shouldFindProductsInPriceRange() {
List<Product> results = productRepository.findByPriceRange(
BigDecimal.valueOf(10.00), BigDecimal.valueOf(50.00)
);
assertThat(results).hasSize(1);
assertThat(results.get(0).getName()).isEqualTo("Mid Widget");
}
@Test
void shouldSearchByNameCaseInsensitive() {
List<Product> results = productRepository.searchByName("widget");
assertThat(results).hasSize(3)
.extracting(Product::getName)
.allMatch(name -> name.toLowerCase().contains("widget"));
}
@Test
void shouldCountOnlyActiveProducts() {
long count = productRepository.countActiveByCategory("electronics");
assertThat(count).isEqualTo(2); // Premium Widget is inactive
}
}Testing Native SQL Queries
Native queries bypass JPQL and use the database's SQL dialect directly. They need extra attention in tests because H2's SQL dialect differs from PostgreSQL or MySQL.
@Query(value = "SELECT * FROM products WHERE category = :category LIMIT :limit", nativeQuery = true)
List<Product> findTopByCategory(@Param("category") String category, @Param("limit") int limit);For native queries using database-specific syntax (PostgreSQL's ILIKE, jsonb_* functions, RETURNING clauses), H2 won't work. You need a real database — see the Testcontainers section below.
Testing Pagination
@Test
void shouldReturnPagedResults() {
for (int i = 1; i <= 15; i++) {
entityManager.persist(new Product("Product " + i, BigDecimal.valueOf(i * 10.0), "electronics"));
}
entityManager.flush();
Pageable pageable = PageRequest.of(0, 5, Sort.by("name").ascending());
Page<Product> page = productRepository.findByCategory("electronics", pageable);
assertThat(page.getTotalElements()).isEqualTo(15);
assertThat(page.getTotalPages()).isEqualTo(3);
assertThat(page.getContent()).hasSize(5);
assertThat(page.getContent().get(0).getName()).isEqualTo("Product 1");
}Testing @Modifying Queries
Bulk update and delete queries require @Modifying and run outside the normal entity lifecycle:
// In repository
@Modifying
@Query("UPDATE Product p SET p.active = false WHERE p.category = :category")
int deactivateByCategory(@Param("category") String category);@Test
void shouldDeactivateAllProductsInCategory() {
entityManager.persist(new Product("Widget A", BigDecimal.valueOf(10.0), "electronics", true));
entityManager.persist(new Product("Widget B", BigDecimal.valueOf(20.0), "electronics", true));
entityManager.persist(new Product("Book", BigDecimal.valueOf(15.0), "books", true));
entityManager.flush();
entityManager.clear(); // Important: clear first-level cache before modifying query
int updated = productRepository.deactivateByCategory("electronics");
assertThat(updated).isEqualTo(2);
// Reload from database to verify
List<Product> electronics = productRepository.findByCategory("electronics");
assertThat(electronics).allMatch(p -> !p.isActive());
}The entityManager.clear() call before a @Modifying query is critical. Without it, the first-level cache may return stale entities after the bulk update.
Testing Relationships
@Test
void shouldLoadOrderWithItems() {
Customer customer = new Customer("customer@example.com");
entityManager.persist(customer);
Order order = new Order(customer);
order.addItem(new OrderItem("Widget", 2, BigDecimal.valueOf(9.99)));
order.addItem(new OrderItem("Gadget", 1, BigDecimal.valueOf(49.99)));
entityManager.persist(order);
entityManager.flush();
entityManager.clear(); // Clear to force a real DB fetch
Order found = orderRepository.findByIdWithItems(order.getId()).orElseThrow();
assertThat(found.getItems()).hasSize(2);
assertThat(found.getItems())
.extracting(OrderItem::getProductName)
.containsExactlyInAnyOrder("Widget", "Gadget");
}Always call entityManager.clear() before fetching if you want to test that your query actually loads the relationship (vs. returning the cached entity).
Testing JPA Auditing
@DataJpaTest
@Import(JpaAuditingConfiguration.class)
class AuditedProductRepositoryTest {
@Autowired
private ProductRepository productRepository;
@Test
void shouldSetCreatedAtOnSave() {
Product product = productRepository.save(new Product("Widget", BigDecimal.valueOf(9.99), "electronics"));
assertThat(product.getCreatedAt()).isNotNull();
assertThat(product.getUpdatedAt()).isNotNull();
}
@Test
void shouldUpdateUpdatedAtOnModification() throws InterruptedException {
Product product = productRepository.save(new Product("Widget", BigDecimal.valueOf(9.99), "electronics"));
Instant createdAt = product.getCreatedAt();
Thread.sleep(10); // Ensure timestamp difference
product.setName("Updated Widget");
Product updated = productRepository.save(product);
assertThat(updated.getCreatedAt()).isEqualTo(createdAt);
assertThat(updated.getUpdatedAt()).isAfter(createdAt);
}
}Testing Against a Real Database
For PostgreSQL-specific features, switch off H2:
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Testcontainers
class ProductRepositoryPostgresTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15")
.withDatabaseName("testdb")
.withUsername("test")
.withPassword("test");
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
}
@Autowired
private ProductRepository productRepository;
@Test
void shouldSearchUsingPostgresFullTextSearch() {
// Test PostgreSQL-specific full text search
productRepository.save(new Product("Blue Widget", BigDecimal.valueOf(9.99), "electronics"));
List<Product> results = productRepository.fullTextSearch("widget");
assertThat(results).hasSize(1);
}
}Organizing Repository Tests
For larger repositories, split tests by concern:
ProductRepositoryTest.java // Basic CRUD
ProductRepositoryQueryTest.java // Custom @Query methods
ProductRepositoryPaginationTest.java // Pagination and sorting
ProductRepositoryNativeQueryTest.java // Native SQL (real DB)Use @BeforeEach for shared test data, but be careful — @DataJpaTest wraps each test in a transaction that rolls back, so setup data in @BeforeEach is safe.
Common Mistakes
Mistake 1: Testing derived query methods excessively
Methods like findByNameAndCategory(String name, String category) are generated by Spring Data — no JPQL to get wrong. Focus test coverage on methods with @Query, especially native queries.
Mistake 2: Forgetting entityManager.flush()
Without flush(), your persisted entities may only exist in the first-level cache. Queries won't find them. Always flush before running queries in tests.
Mistake 3: Not testing the sad path
Test what happens when the data doesn't exist, when a unique constraint is violated, and when required fields are null:
@Test
void shouldThrowWhenSavingDuplicateSku() {
productRepository.save(new Product("Widget", "SKU-001"));
assertThatThrownBy(() -> productRepository.saveAndFlush(new Product("Clone", "SKU-001")))
.isInstanceOf(DataIntegrityViolationException.class);
}Summary
@DataJpaTest is the right tool for testing anything that touches your JPA repositories. It's fast, isolated, and transactional by default. Focus on custom queries, complex joins, pagination behavior, and constraints. Use TestEntityManager to set up data cleanly, remember to flush before querying, and clear the cache before testing loads. Switch to a real database with Testcontainers when H2's dialect limitations get in the way.