@DataJpaTest: Testing Spring Data JPA Repositories

@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 (@Entity classes)
  • 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, @Controller beans
  • @ConfigurationProperties beans (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.

Read more

Start now free