Spring Data JPA 测试实战——@DataJpaTest、事务回滚、内存数据库
Spring Data JPA 测试实战——@DataJpaTest、事务回滚、内存数据库
适读人群:用 Spring Data JPA 写了 Repository,但不知道怎么正确测试查询逻辑、自定义 SQL 的工程师 | 阅读时长:约13分钟 | 核心价值:掌握 JPA 层测试的完整方法,让数据库查询逻辑有可靠保障
那个在生产环境才暴露的查询 Bug
有一次上线后发现,订单列表接口偶发性地返回空数据。
排查了很久,最终定位到 Repository 里一个自定义查询:
@Query("SELECT o FROM Order o WHERE o.userId = :userId AND o.createTime >= :startTime AND o.createTime <= :endTime")
List<Order> findByUserIdAndDateRange(
@Param("userId") Long userId,
@Param("startTime") LocalDateTime startTime,
@Param("endTime") LocalDateTime endTime
);问题出在哪里?数据库里的时间是 UTC 时区,而传入的 LocalDateTime 没有时区概念,导致时间范围对不上,在某些时间点查询结果为空。
这个 Bug,如果我们有 @DataJpaTest 的测试,在配置时区时就应该发现。但我们没有。
教训很深刻:自定义 JPQL、原生 SQL 查询,必须有测试。
@DataJpaTest 的核心机制
@DataJpaTest 是 Spring Boot 提供的 JPA 切片测试注解,它:
- 只加载 JPA 相关组件:
@Repository、JpaRepositoryFactoryBean、JPA 配置 - 不加载 @Service、@Controller、@Component
- 默认使用 H2 内存数据库(可覆盖)
- 每个测试自动开启事务,测试结束后回滚
@DataJpaTest
class OrderRepositoryTest {
@Autowired
private OrderRepository orderRepository;
@Autowired
private TestEntityManager entityManager; // JPA 测试专用的 EntityManager
@Test
void testFindByUserId() {
// TestEntityManager 用于准备数据
Order savedOrder = entityManager.persistAndFlush(Order.builder()
.userId(1L)
.status(OrderStatus.PENDING)
.amount(new BigDecimal("100.00"))
.createTime(LocalDateTime.now())
.build());
// 清除 JPA 一级缓存,确保从数据库读取
entityManager.clear();
List<Order> orders = orderRepository.findByUserId(1L);
assertEquals(1, orders.size());
assertEquals(savedOrder.getId(), orders.get(0).getId());
}
}TestEntityManager vs Autowired Repository
新手常见问题:测试数据准备时,用 TestEntityManager 还是直接用 Repository?
用 TestEntityManager.persistAndFlush() 准备数据,用 Repository 执行被测方法,用 entityManager.clear() 清除缓存。
这是黄金三步,理由:
TestEntityManager操作直接写库,跳过 Repository 层,避免被测 Repository 影响数据准备entityManager.clear()确保之后的查询从数据库读,而不是从一级缓存读(否则测试可能虚通过)
@Test
void testFindByStatusAndUserId_correctData() {
// 第一步:用 TestEntityManager 准备测试数据
Long userId = 1L;
entityManager.persistAndFlush(Order.builder().userId(userId).status(OrderStatus.PENDING).build());
entityManager.persistAndFlush(Order.builder().userId(userId).status(OrderStatus.PAID).build());
entityManager.persistAndFlush(Order.builder().userId(2L).status(OrderStatus.PENDING).build()); // 别的用户
// 第二步:清除一级缓存
entityManager.clear();
// 第三步:调用被测方法
List<Order> pendingOrders = orderRepository.findByUserIdAndStatus(userId, OrderStatus.PENDING);
// 断言
assertEquals(1, pendingOrders.size());
assertEquals(OrderStatus.PENDING, pendingOrders.get(0).getStatus());
assertEquals(userId, pendingOrders.get(0).getUserId());
}测试自定义 JPQL 查询
@Repository
public interface OrderRepository extends JpaRepository<Order, Long> {
@Query("SELECT o FROM Order o WHERE o.userId = :userId " +
"AND o.createTime BETWEEN :startTime AND :endTime " +
"ORDER BY o.createTime DESC")
List<Order> findByUserIdAndDateRange(
@Param("userId") Long userId,
@Param("startTime") LocalDateTime startTime,
@Param("endTime") LocalDateTime endTime
);
@Query("SELECT SUM(o.amount) FROM Order o WHERE o.userId = :userId " +
"AND o.status = 'PAID'")
Optional<BigDecimal> sumPaidAmountByUserId(@Param("userId") Long userId);
@Modifying
@Query("UPDATE Order o SET o.status = :status WHERE o.id IN :ids")
int batchUpdateStatus(@Param("ids") List<Long> ids, @Param("status") OrderStatus status);
}对应的测试:
@DataJpaTest
class OrderRepositoryCustomQueryTest {
@Autowired
private OrderRepository orderRepository;
@Autowired
private TestEntityManager entityManager;
@Test
void testFindByUserIdAndDateRange_shouldReturnOrdersInRange() {
LocalDateTime base = LocalDateTime.of(2024, 6, 1, 0, 0, 0);
Long userId = 1L;
// 在范围内的订单
entityManager.persistAndFlush(Order.builder().userId(userId)
.createTime(base.plusDays(1)).amount(BigDecimal.TEN).build());
entityManager.persistAndFlush(Order.builder().userId(userId)
.createTime(base.plusDays(5)).amount(BigDecimal.TEN).build());
// 在范围外的订单
entityManager.persistAndFlush(Order.builder().userId(userId)
.createTime(base.minusDays(1)).amount(BigDecimal.TEN).build());
entityManager.persistAndFlush(Order.builder().userId(userId)
.createTime(base.plusDays(11)).amount(BigDecimal.TEN).build());
// 别的用户的订单
entityManager.persistAndFlush(Order.builder().userId(2L)
.createTime(base.plusDays(3)).amount(BigDecimal.TEN).build());
entityManager.clear();
List<Order> result = orderRepository.findByUserIdAndDateRange(
userId, base, base.plusDays(7)
);
assertEquals(2, result.size());
// 验证按时间倒序
assertTrue(result.get(0).getCreateTime().isAfter(result.get(1).getCreateTime()));
}
@Test
void testSumPaidAmountByUserId_whenNoPaidOrders_shouldReturnEmpty() {
entityManager.persistAndFlush(Order.builder().userId(1L)
.status(OrderStatus.PENDING).amount(new BigDecimal("100")).build());
entityManager.clear();
Optional<BigDecimal> sum = orderRepository.sumPaidAmountByUserId(1L);
assertTrue(sum.isEmpty() || sum.get().compareTo(BigDecimal.ZERO) == 0);
}
@Test
void testSumPaidAmountByUserId_withPaidOrders() {
entityManager.persistAndFlush(Order.builder().userId(1L)
.status(OrderStatus.PAID).amount(new BigDecimal("100")).build());
entityManager.persistAndFlush(Order.builder().userId(1L)
.status(OrderStatus.PAID).amount(new BigDecimal("200")).build());
entityManager.persistAndFlush(Order.builder().userId(1L)
.status(OrderStatus.PENDING).amount(new BigDecimal("50")).build()); // PENDING 不计
entityManager.clear();
Optional<BigDecimal> sum = orderRepository.sumPaidAmountByUserId(1L);
assertTrue(sum.isPresent());
assertEquals(0, new BigDecimal("300").compareTo(sum.get()));
}
@Test
@Transactional // @Modifying 的查询需要事务
void testBatchUpdateStatus() {
Order order1 = entityManager.persistAndFlush(
Order.builder().userId(1L).status(OrderStatus.PENDING).build());
Order order2 = entityManager.persistAndFlush(
Order.builder().userId(1L).status(OrderStatus.PENDING).build());
Order order3 = entityManager.persistAndFlush(
Order.builder().userId(1L).status(OrderStatus.PAID).build()); // 这个不在更新列表里
entityManager.clear();
int updatedCount = orderRepository.batchUpdateStatus(
List.of(order1.getId(), order2.getId()), OrderStatus.CANCELLED
);
assertEquals(2, updatedCount);
entityManager.clear();
assertEquals(OrderStatus.CANCELLED, orderRepository.findById(order1.getId()).get().getStatus());
assertEquals(OrderStatus.CANCELLED, orderRepository.findById(order2.getId()).get().getStatus());
assertEquals(OrderStatus.PAID, orderRepository.findById(order3.getId()).get().getStatus()); // 未变
}
}使用真实数据库(Testcontainers)
H2 在某些场景下和真实数据库行为不完全一致(函数支持、类型转换、索引行为等)。对于关键查询,用 Testcontainers 运行真实 PostgreSQL/MySQL:
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Testcontainers
class OrderRepositoryRealDbTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15.3")
.withDatabaseName("testdb")
.withUsername("test")
.withPassword("test");
@DynamicPropertySource
static void configureDataSource(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
registry.add("spring.jpa.database-platform", () -> "org.hibernate.dialect.PostgreSQLDialect");
}
@Autowired
private OrderRepository orderRepository;
@Test
void testPostgresSpecificQuery() {
// 使用 PostgreSQL 特有语法的查询(如 ILIKE 大小写不敏感搜索)
}
}踩坑实录三则
踩坑一:测试里没有 entityManager.clear(),查询返回了缓存数据,测试虚通过
现象:
@Test
void testSave_andFind() {
Order saved = orderRepository.save(order); // JPA 一级缓存里有这个对象
Order found = orderRepository.findById(saved.getId()).orElseThrow();
// 以下断言通过了,但 found 是从缓存拿的,不是真正从数据库读的
assertEquals(saved.getStatus(), found.getStatus());
}原因:findById 在同一个事务里,直接从 JPA 一级缓存返回了 saved 的引用,根本没有发数据库查询。这个测试验证的是"缓存里的对象能被找到",而不是"数据库里的数据是正确的"。
解法:
Order saved = entityManager.persistAndFlush(order);
entityManager.clear(); // 清除缓存
Order found = orderRepository.findById(saved.getId()).orElseThrow(); // 真正从 DB 读踩坑二:@Modifying 查询在 @DataJpaTest 里报 TransactionRequiredException
现象:调用 batchUpdateStatus 时抛 javax.persistence.TransactionRequiredException: Executing an update/delete query。
原因:@Modifying 的查询必须在事务内执行。虽然 @DataJpaTest 整个测试类有事务,但 @Modifying 方法本身没有 @Transactional。
解法一:在 Repository 方法上加 @Transactional:
@Modifying
@Transactional
@Query("UPDATE Order o SET o.status = :status WHERE o.id IN :ids")
int batchUpdateStatus(...);解法二:在测试方法上加 @Transactional。
踩坑三:H2 的自增主键策略和 MySQL 不一致,测试里 ID 从 1 开始但生产从其他值开始
现象:测试里假设 save 后第一个实体的 ID 是 1,但这个假设在生产环境不成立(可能从 100 开始,或者是雪花 ID)。
解法:永远不要在测试里硬编码 ID 值。用 savedEntity.getId() 获取实际 ID:
Order saved = entityManager.persistAndFlush(order);
// 不要 assertEquals(1L, saved.getId())
assertNotNull(saved.getId()); // 只验证 ID 被赋值了
Long actualId = saved.getId();
entityManager.clear();
Order found = orderRepository.findById(actualId).orElseThrow();
assertEquals(actualId, found.getId()); // 用实际 ID 做查询,而不是硬编码JPA 层的测试往往被忽视,但自定义查询语句是数据层最容易出 bug 的地方。@DataJpaTest 启动快、隔离好,是测试 Repository 层的最佳选择。把每一个 @Query 都覆盖到,你就不会再在凌晨因为查询 Bug 爬起来改代码了。
