Testing Spring Data JPA: @DataJpaTest, Testcontainers, and Best Practices

Why Test JPA Code?

Unit tests with mocked repositories verify your service logic but nothing about the database. They won’t catch:

  • Wrong column mapping
  • Constraint violations
  • N+1 queries introduced by a new lazy association
  • Transactions that silently don’t roll back
  • Queries that fail on the real database but pass on H2

Testing against a real database — or at minimum a database-compatible in-memory store — is necessary. Spring Boot’s @DataJpaTest and Testcontainers make this practical.


@DataJpaTest

@DataJpaTest is a slice test that loads only the JPA layer: entities, repositories, EntityManager, and database configuration. It does not load controllers, services, or the full application context.

@DataJpaTest
class ProductRepositoryTest {

    @Autowired
    private ProductRepository productRepository;

    @Autowired
    private TestEntityManager em;

    @Test
    void findByActiveTrue_returnsOnlyActiveProducts() {
        em.persist(product("Laptop", true));
        em.persist(product("Phone", false));
        em.flush();

        List<Product> result = productRepository.findByActiveTrue();

        assertThat(result).hasSize(1)
                          .extracting(Product::getName)
                          .containsExactly("Laptop");
    }

    private Product product(String name, boolean active) {
        Product p = new Product();
        p.setName(name);
        p.setActive(active);
        p.setPrice(new BigDecimal("999.99"));
        return p;
    }
}

By default, @DataJpaTest uses H2 in-memory database and wraps each test in a transaction that is rolled back after the test — no leftover data between tests.

TestEntityManager is a test-friendly wrapper around EntityManager with convenience methods for persisting and flushing test data.


Testing with Real MySQL via Testcontainers

H2 is useful but not identical to MySQL. For production-parity tests, use Testcontainers to spin up a real MySQL container:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-testcontainers</artifactId>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>mysql</artifactId>
    <scope>test</scope>
</dependency>
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Testcontainers
class ProductRepositoryMysqlTest {

    @Container
    static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0")
        .withDatabaseName("testdb")
        .withUsername("test")
        .withPassword("test");

    @DynamicPropertySource
    static void configureProperties(DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.url", mysql::getJdbcUrl);
        registry.add("spring.datasource.username", mysql::getUsername);
        registry.add("spring.datasource.password", mysql::getPassword);
    }

    @Autowired
    private ProductRepository productRepository;

    @Test
    void fullTextSearch_worksOnMySQL() {
        // Test MySQL-specific features that H2 doesn't support
        productRepository.save(product("Gaming Laptop", true));
        productRepository.save(product("Business Laptop", true));
        productRepository.flush();

        List<Product> results = productRepository.fullTextSearch("+laptop +gaming");

        assertThat(results).hasSize(1)
                           .extracting(Product::getName)
                           .containsExactly("Gaming Laptop");
    }
}

@AutoConfigureTestDatabase(replace = NONE) tells Spring Boot not to replace the data source with an embedded one — use the Testcontainers MySQL instead.

Shared Container (Faster Tests)

Reuse the same container across all tests in the suite to avoid Docker startup overhead:

// src/test/java/com/example/AbstractIntegrationTest.java
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Testcontainers
public abstract class AbstractIntegrationTest {

    @Container
    static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0")
        .withDatabaseName("testdb")
        .withUsername("test")
        .withPassword("test");

    static {
        mysql.start(); // start once, reuse across test classes
    }

    @DynamicPropertySource
    static void configureProperties(DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.url", mysql::getJdbcUrl);
        registry.add("spring.datasource.username", mysql::getUsername);
        registry.add("spring.datasource.password", mysql::getPassword);
    }
}
class ProductRepositoryTest extends AbstractIntegrationTest {
    // ...
}

class OrderRepositoryTest extends AbstractIntegrationTest {
    // same container, already running
}

Testing Derived Query Methods

@DataJpaTest
class ProductDerivedQueryTest {

    @Autowired
    private ProductRepository productRepository;

    @Autowired
    private TestEntityManager em;

    @BeforeEach
    void setUp() {
        em.persist(product("Laptop", "ELECTRONICS", new BigDecimal("999"), true));
        em.persist(product("Phone",  "ELECTRONICS", new BigDecimal("599"), true));
        em.persist(product("Desk",   "FURNITURE",   new BigDecimal("299"), false));
        em.flush();
    }

    @Test
    void findByCategoryAndActiveTrue_returnsActiveInCategory() {
        List<Product> result = productRepository.findByCategoryNameAndActiveTrue("ELECTRONICS");
        assertThat(result).hasSize(2)
                          .extracting(Product::getName)
                          .containsExactlyInAnyOrder("Laptop", "Phone");
    }

    @Test
    void findByPriceLessThanEqual_returnsMatchingProducts() {
        List<Product> result = productRepository.findByPriceLessThanEqual(new BigDecimal("600"));
        assertThat(result).hasSize(1)
                          .extracting(Product::getName)
                          .containsExactly("Phone");
    }
}

Testing @Query Methods

@DataJpaTest
class ProductQueryTest {

    @Autowired
    private ProductRepository productRepository;

    @Autowired
    private TestEntityManager em;

    @Test
    void searchByKeyword_matchesNameAndDescription() {
        Product p1 = product("Wireless Keyboard", "Ergonomic wireless keyboard for home office");
        Product p2 = product("Gaming Mouse", "High DPI gaming mouse");
        em.persist(p1);
        em.persist(p2);
        em.flush();

        List<Product> result = productRepository.searchByKeyword("wireless");

        assertThat(result).hasSize(1)
                          .extracting(Product::getName)
                          .containsExactly("Wireless Keyboard");
    }

    @Test
    void searchByKeyword_withNullKeyword_returnsAll() {
        em.persist(product("A", "desc a"));
        em.persist(product("B", "desc b"));
        em.flush();

        List<Product> result = productRepository.searchByKeyword(null);

        assertThat(result).hasSize(2);
    }
}

Detecting N+1 in Tests

@DataJpaTest
class OrderNPlusOneTest {

    @Autowired
    private OrderRepository orderRepository;

    @Autowired
    private TestEntityManager em;

    @Autowired
    private EntityManagerFactory emf;

    @BeforeEach
    void setUp() {
        for (int i = 0; i < 5; i++) {
            Customer customer = em.persist(customer("user" + i));
            Order order = order(customer);
            em.persist(order);
        }
        em.flush();
        em.clear(); // ensure fresh load
    }

    @Test
    void findByStatusWithCustomer_shouldNotTriggerNPlusOne() {
        Statistics stats = emf.unwrap(SessionFactory.class).getStatistics();
        stats.setStatisticsEnabled(true);
        stats.clear();

        List<Order> orders = orderRepository.findByStatusWithCustomer(OrderStatus.PENDING);
        orders.forEach(o -> o.getCustomer().getName()); // access lazy association

        long queryCount = stats.getPrepareStatementCount();
        assertThat(queryCount)
            .as("Expected 1 query (JOIN FETCH), got %d", queryCount)
            .isEqualTo(1);
    }
}

This test enforces a performance contract — if someone removes the JOIN FETCH, the test fails.


Testing Transactions

Testing Rollback

@SpringBootTest // full context — needed to test service transactions
@Transactional
class OrderServiceTransactionTest {

    @Autowired
    private OrderService orderService;

    @Autowired
    private OrderRepository orderRepository;

    @Test
    void placeOrder_shouldRollback_whenStockInsufficient() {
        long countBefore = orderRepository.count();

        assertThatThrownBy(() ->
            orderService.placeOrder(new OrderRequest(productId, 9999))
        ).isInstanceOf(InsufficientStockException.class);

        // Count hasn't changed — transaction rolled back
        assertThat(orderRepository.count()).isEqualTo(countBefore);
    }
}

Testing @Transactional Propagation

@SpringBootTest
class AuditServicePropagationTest {

    @Autowired
    private OrderService orderService;

    @Autowired
    private AuditLogRepository auditLogRepository;

    @Autowired
    private OrderRepository orderRepository;

    @Test
    void auditLog_shouldPersist_evenWhenOrderFails() {
        long auditCountBefore = auditLogRepository.count();

        assertThatThrownBy(() ->
            orderService.placeOrder(invalidRequest())
        ).isInstanceOf(RuntimeException.class);

        // Order rolled back
        assertThat(orderRepository.count()).isEqualTo(0);

        // Audit log committed (REQUIRES_NEW propagation)
        assertThat(auditLogRepository.count()).isGreaterThan(auditCountBefore);
    }
}

Testing Specifications

@DataJpaTest
class ProductSpecsTest {

    @Autowired
    private ProductRepository productRepository;

    @BeforeEach
    void setUp() {
        productRepository.saveAll(List.of(
            product("Laptop", "ELECTRONICS", new BigDecimal("1200"), true),
            product("Phone",  "ELECTRONICS", new BigDecimal("600"),  true),
            product("Desk",   "FURNITURE",   new BigDecimal("400"),  false)
        ));
    }

    @Test
    void active_excludesInactiveProducts() {
        List<Product> result = productRepository.findAll(ProductSpecs.active());
        assertThat(result).hasSize(2).noneMatch(p -> !p.isActive());
    }

    @Test
    void priceAtMost_filtersCorrectly() {
        List<Product> result = productRepository.findAll(
            ProductSpecs.active().and(ProductSpecs.priceAtMost(new BigDecimal("700")))
        );
        assertThat(result).hasSize(1).extracting(Product::getName).containsExactly("Phone");
    }

    @Test
    void combined_specs_return_empty_when_no_match() {
        List<Product> result = productRepository.findAll(
            ProductSpecs.keywordMatches("nonexistent").and(ProductSpecs.active())
        );
        assertThat(result).isEmpty();
    }
}

Testing Auditing

@DataJpaTest
@Import(JpaAuditingConfig.class)
class AuditingTest {

    @Autowired
    private ProductRepository productRepository;

    @MockBean
    private AuditorAware<String> auditorAware;

    @Test
    void save_populatesCreatedAtAndUpdatedAt() {
        when(auditorAware.getCurrentAuditor()).thenReturn(Optional.of("test-user"));

        Product product = new Product();
        product.setName("Test");
        product.setPrice(new BigDecimal("100"));
        Product saved = productRepository.save(product);

        assertThat(saved.getCreatedAt()).isNotNull();
        assertThat(saved.getUpdatedAt()).isNotNull();
        assertThat(saved.getCreatedBy()).isEqualTo("test-user");
    }

    @Test
    void update_doesNotChangeCreatedAt() {
        when(auditorAware.getCurrentAuditor()).thenReturn(Optional.of("user1"));
        Product product = productRepository.save(testProduct());
        LocalDateTime createdAt = product.getCreatedAt();

        when(auditorAware.getCurrentAuditor()).thenReturn(Optional.of("user2"));
        product.setPrice(new BigDecimal("500"));
        Product updated = productRepository.save(product);

        assertThat(updated.getCreatedAt()).isEqualTo(createdAt);
        assertThat(updated.getCreatedBy()).isEqualTo("user1");
        assertThat(updated.getUpdatedBy()).isEqualTo("user2");
    }
}

Testing Optimistic Locking

@DataJpaTest
class OptimisticLockTest {

    @Autowired
    private ProductRepository productRepository;

    @Test
    void version_incrementsOnSave() {
        Product product = productRepository.save(testProduct());
        assertThat(product.getVersion()).isEqualTo(0);

        product.setPrice(new BigDecimal("800"));
        Product updated = productRepository.saveAndFlush(product);
        assertThat(updated.getVersion()).isEqualTo(1);
    }

    @Test
    void concurrentUpdate_throwsOptimisticLockException() {
        Product saved = productRepository.saveAndFlush(testProduct());

        // Simulate two loads of the same version
        Product copy1 = productRepository.findById(saved.getId()).orElseThrow();
        Product copy2 = productRepository.findById(saved.getId()).orElseThrow();

        copy1.setPrice(new BigDecimal("800"));
        productRepository.saveAndFlush(copy1); // version becomes 1

        copy2.setPrice(new BigDecimal("700")); // still has version=0
        assertThatThrownBy(() -> productRepository.saveAndFlush(copy2))
            .isInstanceOf(ObjectOptimisticLockingFailureException.class);
    }
}

Test Data Management

Using @Sql

Load test data from SQL scripts:

@DataJpaTest
@Sql("/test-data/products.sql")
class ProductSqlTest {
    // Database pre-populated from SQL file before each test
}
-- src/test/resources/test-data/products.sql
INSERT INTO categories (id, name, slug) VALUES (1, 'Electronics', 'electronics');
INSERT INTO products (id, name, price, active, category_id) VALUES
    (1, 'Laptop', 999.99, true, 1),
    (2, 'Phone',  599.99, true, 1);

Using @BeforeEach Programmatically

For programmatic setup that benefits from type safety:

@BeforeEach
void setUp() {
    Category category = categoryRepository.save(category("Electronics"));
    productRepository.saveAll(List.of(
        product("Laptop", category, new BigDecimal("999")),
        product("Phone",  category, new BigDecimal("599"))
    ));
    em.flush();
    em.clear(); // ensure clean state — entities will be loaded fresh in tests
}

Always call em.clear() after setup to ensure entities are loaded fresh — avoids the first-level cache masking real database queries.


Test Configuration Reference

src/test/resources/
  application-test.yml          ← test overrides
  logback-test.xml              ← quiet logging in tests
  test-data/
    products.sql
    orders.sql
# application-test.yml
spring:
  jpa:
    show-sql: true                          # see queries during test debugging
    properties:
      hibernate:
        format_sql: true
        generate_statistics: true           # for N+1 detection
  jpa:
    open-in-view: false                     # never enable in tests

Summary

  • @DataJpaTest loads only the JPA slice — fast, isolated, auto-rolled-back tests. Use TestEntityManager for test data setup.
  • Use @AutoConfigureTestDatabase(replace = NONE) with Testcontainers to test against real MySQL — catches dialect differences H2 would miss.
  • Share one MySQLContainer across all test classes with static + manual start() to avoid container-per-class startup cost.
  • Assert query counts with Statistics to enforce N+1 contracts — a broken JOIN FETCH is caught immediately.
  • Test transactional rollback and REQUIRES_NEW propagation at the @SpringBootTest level (not @DataJpaTest) since you need the full service layer.
  • Always call em.flush() + em.clear() after setup so test queries hit the database, not the first-level cache.

Series Complete

You have now completed the Spring Data JPA Tutorial — 30 articles covering every aspect of JPA from first entity to production-grade performance and testing:

PartArticlesTopics
11–4Foundations, persistence context, entity lifecycle
25–8Entity mappings, primary keys, embedded types, converters
39–13All relationship types, cascades, fetch types
414–15Inheritance strategies, @MappedSuperclass
516–18Repository interfaces, derived queries, @Query
619–21Pagination, projections, Specifications
722–23@Transactional in depth, common pitfalls
824–25Dirty checking, flush modes, second-level cache
926–27N+1 solutions, entity graphs, batch loading
1028–30Auditing, optimistic/pessimistic locking, testing

A junior engineer who reads this series from start to finish will understand not just how to use Spring Data JPA, but why it works the way it does — and will know how to diagnose and fix the performance and correctness issues that appear in real production systems.