数据库测试数据管理——DbUnit、Liquibase Test、测试数据隔离策略
数据库测试数据管理——DbUnit、Liquibase Test、测试数据隔离策略
适读人群:Java 后端开发者、数据库测试工程师 | 阅读时长:约 16 分钟 | 核心价值:系统掌握集成测试中的数据准备、隔离和清理策略,彻底解决测试数据管理的混乱问题
做集成测试最让人头疼的事情之一,是测试数据管理。
我在一个项目里接手了一套"运行了两年的集成测试",状况惨不忍睹:数据库里有 2000 多条"遗留测试数据",没人知道哪些是有用的哪些是垃圾。某些测试依赖这些遗留数据才能通过,一旦清理就失败。新人加入团队后,本地跑测试需要先执行一个 "初始化数据库" 的 SQL 脚本,脚本维护者两年前离职了,文档里也没有说明这个脚本是做什么用的。
每次有人清理数据库、或者数据库重建,就会有一批测试莫名其妙失败,然后大家开始追查哪些测试依赖了数据库里的什么数据。这种状态,是测试体系最不健康的表现之一。
测试数据管理不是一个小问题,它直接决定了测试套件是否可信赖、可重复。今天这篇,把我们团队的完整解决方案写出来。
一、测试数据管理的四个基本原则
原则一:测试数据自给自足 每个测试准备自己需要的数据,不依赖"数据库里已经有什么"。
原则二:测试之间完全隔离 A 测试运行不影响 B 测试的结果,无论顺序如何。
原则三:测试结束后数据清理 测试产生的数据不能遗留在数据库里,也不能影响下一次运行。
原则四:数据准备可读性 看一眼测试代码,就能知道这个测试依赖什么数据,数据准备代码应该清晰可读。
二、策略一:@Transactional 自动回滚(最简单)
@SpringBootTest
@Transactional // 每个测试方法用事务包裹,结束后自动回滚
class UserServiceTransactionalTest extends AbstractIntegrationTest {
@Autowired
private UserService userService;
@Autowired
private UserRepository userRepository;
@Test
void 创建用户_写入数据库_方法结束后回滚() {
// given & when
User user = userService.createUser("test@example.com", "testuser");
// then
assertThat(userRepository.findByEmail("test@example.com")).isPresent();
// 方法结束后,Spring 自动回滚事务
// 下次跑这个测试时,数据库是干净的
}
@Test
void 测试方法之间互不影响() {
// 这个测试方法也用事务包裹
// 上一个测试插入的数据已经回滚,这里看不到
assertThat(userRepository.findByEmail("test@example.com")).isEmpty();
}
}适用场景: 只涉及数据库操作的测试,没有异步操作、没有消息队列、没有多线程。
不适用场景:
- 测试异步操作(事务在当前线程,异步线程看不到数据)
- 测试 Kafka 消费者(消费者在不同线程)
- 测试需要跨事务的场景
三、策略二:@BeforeEach / @AfterEach 手动清理
@SpringBootTest
@Testcontainers
class OrderServiceManualCleanTest extends AbstractIntegrationTest {
@Autowired
private OrderRepository orderRepository;
@Autowired
private OrderItemRepository orderItemRepository;
@Autowired
private UserRepository userRepository;
@BeforeEach
void cleanDatabase() {
// 按照外键依赖顺序删除(先删子表,再删父表)
orderItemRepository.deleteAll();
orderRepository.deleteAll();
userRepository.deleteAll();
}
@Test
void 创建订单_包含多个商品_所有明细保存正确() {
// 测试方法内部创建所需数据
User user = userRepository.save(User.builder()
.email("test@example.com")
.username("测试用户")
.build());
Order order = orderRepository.save(Order.builder()
.userId(user.getId())
.status(OrderStatus.PENDING)
.build());
// ... 测试逻辑
}
}适用场景: 复杂的多表测试,异步操作测试。
注意: @BeforeEach 清库要小心外键约束,必须按依赖顺序删除。
四、策略三:测试数据构建器(Builder 模式)
把测试数据的创建集中管理,避免在每个测试里重复写构建代码:
// 测试数据工厂
@Component
@RequiredArgsConstructor
public class TestDataFactory {
private final UserRepository userRepository;
private final ProductRepository productRepository;
private final OrderRepository orderRepository;
private final OrderItemRepository orderItemRepository;
// 创建一个有效用户
public User createUser() {
return userRepository.save(User.builder()
.email("user-" + UUID.randomUUID() + "@test.com")
.username("测试用户")
.status(UserStatus.ACTIVE)
.createdAt(LocalDateTime.now())
.build());
}
// 创建一个有库存的商品
public Product createProduct() {
return productRepository.save(Product.builder()
.name("测试商品-" + UUID.randomUUID())
.price(new BigDecimal("100.00"))
.stock(100)
.status(ProductStatus.ACTIVE)
.build());
}
// 创建一个待支付订单
public Order createPendingOrder(User user) {
return createPendingOrder(user, createProduct(), 1);
}
public Order createPendingOrder(User user, Product product, int quantity) {
Order order = orderRepository.save(Order.builder()
.userId(user.getId())
.status(OrderStatus.PENDING_PAYMENT)
.totalAmount(product.getPrice().multiply(BigDecimal.valueOf(quantity)))
.createdAt(LocalDateTime.now())
.build());
orderItemRepository.save(OrderItem.builder()
.orderId(order.getId())
.productId(product.getId())
.quantity(quantity)
.unitPrice(product.getPrice())
.build());
return order;
}
}
// 测试中使用
@SpringBootTest
class OrderPaymentTest extends AbstractIntegrationTest {
@Autowired
private TestDataFactory testDataFactory;
@Test
void 支付订单_状态变为已支付() {
// 一行代码准备完整的测试场景
Order pendingOrder = testDataFactory.createPendingOrder(
testDataFactory.createUser());
// when
orderService.pay(pendingOrder.getId(), "PAY-001");
// then
Order paid = orderRepository.findById(pendingOrder.getId()).orElseThrow();
assertThat(paid.getStatus()).isEqualTo(OrderStatus.PAID);
}
}五、Liquibase 与测试集成
对于用 Liquibase 管理数据库 Schema 的项目:
# application-test.yml
spring:
liquibase:
enabled: true
change-log: classpath:db/changelog/db.changelog-master.yaml
# 测试环境也跑完整迁移,确保 Schema 与生产一致
contexts: test # 只执行带 test context 的 changeset测试专用 Changeset(只在测试环境跑):
# db/changelog/test-data.yaml
databaseChangeLog:
- changeSet:
id: test-data-001
author: system
context: test # 只在 test context 运行
changes:
- insert:
tableName: categories
columns:
- column:
name: id
value: 1
- column:
name: name
value: "手机数码"
- column:
name: status
value: "ACTIVE"六、三个踩坑实录
坑 1:@Transactional 测试里触发 Lazy 加载失败
现象: 用 @Transactional 测试,查出一个实体后,访问它的懒加载关联时报 LazyInitializationException。
原因: Spring Test 的 @Transactional 测试在测试方法结束时回滚,但某些情况下,Session 在访问懒加载关联之前已经关闭。
解法:
// 方式一:在测试里手动 JOIN FETCH
@Query("SELECT o FROM Order o JOIN FETCH o.items WHERE o.id = :id")
Optional<Order> findByIdWithItems(@Param("id") Long id);
// 方式二:使用 @Transactional(propagation = REQUIRES_NEW) 的 Service 方法
// 方式三:改用 Eager 加载(仅针对测试场景,不推荐在生产代码中用)坑 2:外键约束导致 @BeforeEach 清库失败
现象: @BeforeEach 执行 deleteAll() 时报 Foreign key constraint violation。
原因: 删除父表数据时,子表还有依赖记录。
解法:
@BeforeEach
void cleanDatabase(@Autowired JdbcTemplate jdbcTemplate) {
// 方式一:先删子表,再删父表
orderItemRepository.deleteAll();
orderRepository.deleteAll();
// 方式二:临时禁用外键约束(MySQL)
jdbcTemplate.execute("SET FOREIGN_KEY_CHECKS = 0");
orderItemRepository.deleteAll();
orderRepository.deleteAll();
userRepository.deleteAll();
jdbcTemplate.execute("SET FOREIGN_KEY_CHECKS = 1");
// 方式三:TRUNCATE TABLE(比 DELETE 快,自动处理外键)
jdbcTemplate.execute("TRUNCATE TABLE order_items");
jdbcTemplate.execute("TRUNCATE TABLE orders");
}坑 3:测试数据 ID 冲突
现象: 测试里硬编码了 id=1 的数据,运行顺序不同时,有时发现 id=1 已经被其他测试插入了,导致主键冲突。
原因: 多个测试都往同一个表里插 id=1 的数据,且清库不彻底。
解法: 不要在测试里硬编码 ID,让数据库自增生成:
// 错误:硬编码 ID
User user = User.builder().id(1L).email("test@test.com").build();
// 正确:不指定 ID,让数据库自增
User user = User.builder().email("test-" + UUID.randomUUID() + "@test.com").build();
User saved = userRepository.save(user);
Long generatedId = saved.getId(); // 使用生成的 ID七、测试数据文件(SQL 脚本)管理
对于需要大量基础数据的测试,维护 SQL 脚本:
src/test/resources/
├── sql/
│ ├── schema.sql # 建表 DDL(可以是 Liquibase 生成的)
│ ├── base-data.sql # 所有测试共用的基础数据(字典、分类等)
│ └── scenarios/
│ ├── order-flow.sql # 特定场景的测试数据
│ └── payment.sql// 在基类里加载基础数据
@Sql(scripts = "/sql/base-data.sql",
executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD)
@Sql(scripts = "/sql/clean-data.sql",
executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD)
public abstract class AbstractIntegrationTest {
// ...
}
// 特定测试加载特定数据
@Test
@Sql("/sql/scenarios/order-flow.sql")
void 订单流程_完整场景_测试() {
// SQL 会在这个测试方法前执行
}测试数据管理没有银弹,但有两个底线:数据要自给自足,测试要能独立重复运行。满足这两个底线,其他的可以根据项目情况灵活选择。
