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
@DataJpaTestloads only the JPA slice — fast, isolated, auto-rolled-back tests. UseTestEntityManagerfor test data setup.- Use
@AutoConfigureTestDatabase(replace = NONE)with Testcontainers to test against real MySQL — catches dialect differences H2 would miss. - Share one
MySQLContaineracross all test classes withstatic+ manualstart()to avoid container-per-class startup cost. - Assert query counts with
Statisticsto enforce N+1 contracts — a broken JOIN FETCH is caught immediately. - Test transactional rollback and
REQUIRES_NEWpropagation at the@SpringBootTestlevel (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:
| Part | Articles | Topics |
|---|---|---|
| 1 | 1–4 | Foundations, persistence context, entity lifecycle |
| 2 | 5–8 | Entity mappings, primary keys, embedded types, converters |
| 3 | 9–13 | All relationship types, cascades, fetch types |
| 4 | 14–15 | Inheritance strategies, @MappedSuperclass |
| 5 | 16–18 | Repository interfaces, derived queries, @Query |
| 6 | 19–21 | Pagination, projections, Specifications |
| 7 | 22–23 | @Transactional in depth, common pitfalls |
| 8 | 24–25 | Dirty checking, flush modes, second-level cache |
| 9 | 26–27 | N+1 solutions, entity graphs, batch loading |
| 10 | 28–30 | Auditing, 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.