
编写集成测试时,我们都面临一个共同的挑战:如何保证每个测试用例都在一个干净、隔离的数据库环境中运行?
传统的做法是在每个测试类的 @AfterEach
(或 @After
) 方法中,手动调用 xxxRepository.deleteAll()
来清理数据。这种方式的弊端显而易见:
-
• 代码冗余: 每个测试类都需要重复编写清理逻辑。
-
• 顺序依赖: 在处理有外键关联的表时,你必须小心翼翼地按照正确的反向顺序来删除数据,否则就会触发外键约束异常。
-
• 效率低下:
DELETE
操作会逐行删除,并记录 binlog,对于大量数据,速度很慢。
本文将带你从 0 到 1,构建一个基于注解的、对业务代码零侵入的集成测试数据清理 Starter 。你只需在测试类上添加一个 @CleanDatabase
注解,它就能在每个测试方法执行后,自动、高效地将数据库恢复到"出厂设置"。
1. 项目设计与核心思路
我们的 test-data-cleaner-starter
目标如下:
-
- 注解驱动: 提供
@CleanDatabase
注解,轻松标记需要数据清理的测试。
- 注解驱动: 提供
-
- 自动执行: 在每个被标记的
@Test
方法执行之后自动触发清理。
- 自动执行: 在每个被标记的
-
- 高效清理: 采用
TRUNCATE TABLE
策略,速度远超DELETE
。
- 高效清理: 采用
-
- 智能识别: 自动识别所有业务表,并能智能地排除掉 Flyway/Liquibase 等迁移工具的系统表。
核心实现机制:Spring TestExecutionListener
Spring TestContext Framework 提供了一个强大的扩展点 TestExecutionListener
。它允许我们在测试生命周期的各个阶段(如测试类执行前、测试方法执行后)嵌入自定义逻辑。
我们将创建一个自定义的 TestExecutionListener
,它会在 afterTestMethod
这个阶段被触发,检查测试类或方法上是否有 @CleanDatabase
注解,如果有,则执行数据库清理操作。
2. 创建 Starter 项目与核心组件
我们采用 autoconfigure
+ starter
的双模块结构。
步骤 2.1: 依赖 (autoconfigure
模块)
<dependencies>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-test</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
</dependencies>
步骤 2.2: 定义核心注解与清理服务
@CleanDatabase
(清理注解):
package com.example.testcleaner.autoconfigure.annotation;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface CleanDatabase {
}
DatabaseCleaner.java
(核心清理服务):
package com.example.testcleaner.autoconfigure.core;
import org.springframework.jdbc.core.JdbcTemplate;
import java.util.List;
import java.util.stream.Collectors;
public class DatabaseCleaner {
private final JdbcTemplate jdbcTemplate;
public DatabaseCleaner(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
public void clean() {
// 1. 关闭外键约束
jdbcTemplate.execute("SET FOREIGN_KEY_CHECKS = 0;");
// 2. 查找所有业务表并 TRUNCATE
List<String> tables = fetchAllTables();
tables.forEach(tableName -> {
jdbcTemplate.execute("TRUNCATE TABLE `" + tableName + "`;");
});
// 3. 重新开启外键约束
jdbcTemplate.execute("SET FOREIGN_KEY_CHECKS = 1;");
}
private List<String> fetchAllTables() {
return jdbcTemplate.queryForList("SHOW TABLES", String.class).stream()
// 智能排除 Flyway 和 Liquibase 的系统表
.filter(tableName -> !tableName.startsWith("flyway_schema_history"))
.filter(tableName -> !tableName.startsWith("databasechangelog"))
.collect(Collectors.toList());
}
}
步骤 2.3: 实现 TestExecutionListener
这是连接注解和清理服务的桥梁。
package com.example.testcleaner.autoconfigure.listener;
import com.example.testcleaner.autoconfigure.annotation.CleanDatabase;
import com.example.testcleaner.autoconfigure.core.DatabaseCleaner;
import org.springframework.test.context.TestContext;
import org.springframework.test.context.support.AbstractTestExecutionListener;
public class CleanDatabaseTestExecutionListener extends AbstractTestExecutionListener {
// 在所有 listener 中,我们希望它最后执行
@Override
public int getOrder() {
return Integer.MAX_VALUE;
}
@Override
public void afterTestMethod(TestContext testContext) throws Exception {
// 检查方法或类上是否有 @CleanDatabase 注解
if (testContext.getTestMethod().isAnnotationPresent(CleanDatabase.class) ||
testContext.getTestClass().isAnnotationPresent(CleanDatabase.class)) {
// 从 Spring 容器中获取我们的清理服务 Bean
DatabaseCleaner cleaner = testContext.getApplicationContext().getBean(DatabaseCleaner.class);
cleaner.clean();
}
}
}
3. 自动装配的魔法 (DataCleanerAutoConfiguration
)
package com.example.testcleaner.autoconfigure;
import com.example.testcleaner.autoconfigure.core.DatabaseCleaner;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.jdbc.core.JdbcTemplate;
@Configuration
@ConditionalOnProperty(prefix = "test.data-cleaner", name = "enabled", havingValue = "true", matchIfMissing = true)
public class DataCleanerAutoConfiguration {
@Bean
public DatabaseCleaner databaseCleaner(JdbcTemplate jdbcTemplate) {
return new DatabaseCleaner(jdbcTemplate);
}
}
步骤 3.1: 注册 TestExecutionListener
(关键一步)
为了让 Spring Test 框架能自动发现我们的 Listener,我们需要在 autoconfigure
模块的 resources/META-INF/spring.factories
文件中进行注册。
resources/META-INF/spring.factories
org.springframework.test.context.TestExecutionListener=\
com.example.testcleaner.autoconfigure.listener.CleanDatabaseTestExecutionListener
4. 如何使用我们的 Starter
步骤 4.1: 引入依赖
在你的业务项目 pom.xml
中,确保在 test
scope 下添加依赖:
<dependency>
<groupId>com.example</groupId>
<artifactId>test-data-cleaner-spring-boot-starter</artifactId>
<version>1.0.0</version>
<scope>test</scope>
</dependency>
步骤 4.2: 在测试类中使用注解
现在,你的集成测试可以变得无比清爽。
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
@SpringBootTest
@CleanDatabase // <-- 在类上添加注解,对所有测试方法生效
public class OrderServiceIntegrationTest {
@Autowired
private OrderService orderService;
@Autowired
private OrderRepository orderRepository;
@Test
void shouldCreateOrderSuccessfully() {
// Given: 一个干净的数据库
// When
Order newOrder = orderService.createOrder(...);
// Then
assert orderRepository.findById(newOrder.getId()).isPresent();
} // <-- 在此方法执行完毕后,Starter 会自动 TRUNCATE 所有表
@Test
void shouldFindNoOrdersInitially() {
// Given: 一个干净的数据库 (因为上一个测试的数据已被清理)
// When
List<Order> orders = orderRepository.findAll();
// Then
assert orders.isEmpty();
}
}
总结
通过自定义一个 Spring Boot Starter 和巧妙地利用 TestExecutionListener
,我们成功地将繁琐、易错的测试数据清理逻辑,封装成了一个声明式的 @CleanDatabase
注解。