如果你们团队的 SpringBoot 项目也在跑单元测试,我猜你迟早会遇到一个让人抓狂的场景:单个测试类单独执行,绿得发亮;整个测试套件一跑,红得发黑。更崩溃的是,报错信息通常还不是固定的,今天挂在这个方法,明天挂在另一个方法,同一段代码什么都没改,结果却不同。这种“抽奖式测试”,十有八九是数据库没隔离干净。
我第一次意识到这个问题,是在一个订单服务的集成测试里。当时一个同事写的测试用例依赖了另一个测试类留下的一条订单数据,单独跑没问题,一起跑就报唯一键冲突。彼时我才开始正视一个老话题:SpringBoot+DBunit单元测试的组合,到底能不能把数据库隔离测试这件事做透。答案是可以,但前提是你得真正理解 DBUnit 的设计思路,而不是把它当成一个“清理表数据的小工具”。
这篇文章我从踩坑出发,讲清楚为什么需要数据库隔离、怎么选型、SpringBoot 里怎么集成 DBUnit、实际项目里怎么把隔离测试落地,也会把我在真实项目里撞过的几个坑原原本本列出来。无论你是刚接触单元测试的新人,还是已经写过不少测试但一直被数据污染折磨的开发者,都可以照着这份思路去排查和落地。
1. 为什么单元测试需要数据库隔离:先搞明白“到底在隔离什么”
1.1 “单测绿但准入门随便挂”的诡异现象
大概在半年前,我们某个微服务项目出现过一次很典型的“测试污染事件”。当时一个统计报表模块新增了几条测试数据,按说加测试数据是好事,结果回归的时候,另一个跟报表完全不沾边的用户模块测试开始偶发失败。失败原因五花八门:注册用户数对不上、列表多了一行 CSV 导出多了一条记录、甚至还有一个分页测试断言总数错误。
当时没有人怀疑到测试数据身上,因为所有人跑单测都是只跑自己那个类,跑完还都是过的。后来项目里用了一条命令把整个测试套件跑起来,发现只要多个测试类同时执行,失败就开始“漂移”。这时候才有人意识到:大家在共享同一个数据库连接,测试往表里插入的数据,根本没有在测试结束后被清掉。
这就是我要讲的第一个核心问题:单元测试里的数据库隔离,首先要把“共享数据库”变成“每个测试私有或者可控的数据库现场”。如果不隔离,你测的不是“这段代码是否正确”,而是“这段代码在当前脏数据环境下是否碰巧正确”。
1.2 数据污染从哪来:四种常见渠道
我做过的项目里,数据污染的来源基本可以归纳成四类,排查的时候可以直接按这四类去查:
- 前一个测试类插入的数据没有清理,被后一个测试类读到了。
- 测试方法里开了事务,但只有部分方法回滚,另一部分方法把数据提交进了数据库。
- 应用启动时执行了初始化脚本,比如
CommandLineRunner、ApplicationRunner或者Flyway迁移脚本,这些数据干扰了测试断言。 - 多个测试并发访问同一张表,查询结果互相交叉。
还有一种不太容易被注意到的来源:某些测试里调用了第三方接口的 Mock 数据,这些 Mock 数据如果被某个真实 DAO 查询缓存到了数据库表里,也会成为污染源。只要测试用例多了,这个问题就会成倍放大。
1.3 数据库隔离的三个层级:数据、事务、状态
我在后来做技术方案时,把数据库隔离拆成了三个层级来理解,这样团队沟通起来特别省事。
第一层是数据隔离。每个测试开始前,数据库应该处于一个确定的初始状态;测试结束后,要么数据被清理掉,要么数据库整体恢复到初始状态。这里的“确定”很关键,我不要求表一定是空表,但要求所有人都能预测测试开始时表里有什么数据。
第二层是事务隔离。一个测试方法如果自己开了事务,这个事务不应该影响到其他测试。这里要特别注意 Spring 的@Transactional注解并不能解决所有问题,当你在测试里手动调用了entityManager.flush()或者某些操作强制提交事务时,数据已经进入数据库,再回滚也来不及了。
第三层是状态隔离。比如自增主键、序列、表统计信息这类“非数据状态”。即使你把数据清空了,自增 ID 也可能接着上一次的值往下走。如果你的断言里包含具体要求 ID 的值,或者直接对某个自增字段做硬编码,状态隔离没做好也会挂。
搞清楚了这三个层级,你才能理解 DBUnit 在整条链路里到底扮演什么角色。
2. 为什么最后选了 DBUnit:三条技术路线的对比与取舍
2.1 方案A:Mock DAO 层
我先说一种看上去很省事、实则有隐患的方案:把 DAO 层或者 Repository 层全部 Mock 掉,这样测试里根本不会碰数据库,序列也不需要隔离。
Mock 方案的好处是测试跑得快,类与类之间几乎没有耦合,适合纯粹验证业务分支逻辑。但它的缺点也致命:你不会知道 SQL 写得对不对、MyBatis 的 XML 映射对不对、JPA 的@GeneratedValue配置对不对。很多线上事故恰恰发生在 SQL 语法完全正确但表结构不匹配,或者数据库方言不一致这些地方。Mock 掉 DAO 之后,这些问题全部被掩盖了。
所以在需要验证真实数据库交互的项目里,我不会只依赖 Mock。更合理的定位是:Mock 适合在 service 层做纯逻辑分支测试,但涉及持久层行为的测试,必须真实连库。
2.2 方案B:H2 内存库
H2 内存库在很多 SpringBoot 项目里是默认选项,启动快、配置简单,测试用例里只要在application-test.yml里指定jdbc:h2:mem:testdb就能跑起来。它确实解决了一部分“共享数据库”问题,因为每个测试进程都有自己的内存库。
但我在实际项目中慢慢发现,H2 的兼容模式再完善,也不是线上数据库的完全替身。你用的 MySQL 字段类型、索引、存储引擎、大小写敏感规则、窗口函数、JSON 操作,在 H2 里可能行为不同。更麻烦的是,如果项目里用了 MyBatis 的typeHandler或者自定义 SQL 片段,在 H2 上过不代表在 MySQL 或者 PostgreSQL 上过。
因此,H2 适合做启动级验证,不适合作为发布前唯一的数据库验证手段。
2.3 方案C:DBUnit 加真实数据库
DBUnit 给我的第一印象是“一个擅长操作数据库现场的测试工具”。它不替代数据库,也不替代 JUnit/TestNG,而是帮你搞三件事:
- 把一组数据定义成“数据集”(DataSet),常见格式是 XML、JSON、CSV、Excel。
- 在测试执行前后,对数据库执行清洗、插入、更新、删除等动作。
- 把数据库里的当前数据和期望数据集做比对,给你明确的差异报告。
我最看重的其实不是它的插入能力,而是CLEAN_INSERT 操作。这个操作会先删除数据集涉及的所有表的数据,再把标准数据插入进去。换句话说,它在测试开始前把数据库“格式化”成一个预设现场,测试结束后,下一个测试再执行 CLEAN_INSERT,又把这个现场重新覆盖一遍。这就实现了前面说的数据隔离。
2.3 方案D:Testcontainers 这类容器方案
我也用过 Testcontainers,用 Docker 临时拉起一个真实数据库实例。这种方案和 DBUnit 并不冲突,反而经常搭配使用:用容器提供真实数据库环境,用 DBUnit 管理测试数据集和断言。只不过容器方案对本地开发环境有要求,比如必须先有 Docker,构建机上也要能拉镜像,团队协作时会多一些成本。
我最后的选型结论是:DBUnit 负责数据快照和断言,数据库本身尽量用真实库,至少保证关键模块跑在真实库上。H2 可以作为日常本地开发的快速验证,不能作为唯一标准。
3. SpringBoot 接入 DBUnit:依赖、配置与测试基类
3.1 Maven 依赖怎么选才不会版本打架
先说一个我踩过的坑:直接加org.dbunit:dbunit这个核心依赖并不够,你还需要一个能跟 Spring 测试框架衔接的适配层。最常见的是com.github.springtestdbunit:spring-test-dbunit,但这个库已经很久没更新了,如果你用的是 Spring Boot 3.x 加上 JUnit 5,要注意适配问题。
我的建议是,如果项目是 Spring Boot 2.x,可以用spring-test-dbunit;如果已经升级到 Spring Boot 3.x,要么降级使用自己封装的数据集加载工具,要么用 DBUnit 原生 API 配合 JUnit 5 扩展。我在项目的做法是:自己写了一个轻量封装,基于 DBUnit 的DatabaseOperation和IDataSet,配合 JUnit 5 的扩展机制,反而比直接用老适配库更可控,也避免它强制绑定 JUnit 4。
下面是一个可以跑通的最小化依赖配置:
<dependency> <groupId>org.dbunit</groupId> <artifactId>dbunit</artifactId> <version>2.7.3</version> <scope>test</scope> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> <dependency> <groupId>org.junit.jupiter</groupId> <artifactId>junit-jupiter</artifactId> <scope>test</scope> </dependency>如果要用 Spring 提供的事务管理,还需要补一个spring-jdbc依赖,项目里通常已经带了,不需要额外引入。
3.2 配置文件与数据源:测试环境必须和数据源解耦
在测试配置里,我强烈建议用独立的数据库实例,而不是跟本地开发库共用。配置上可以用 Spring Profiles 分隔开:
# application-test.yml spring: datasource: url: jdbc:mysql://localhost:3306/test_user_db?useSSL=false&serverTimezone=Asia/Shanghai username: test_user password: test_pass driver-class-name: com.mysql.cj.jdbc.Driver这里有一条很容易忽略的规则:测试库的表结构必须由迁移脚本统一生成,不能指望人工建表。项目里如果用了 Flyway 或 Liquibase,在测试环境执行迁移脚本时要注意,DBUnit 的清空操作可能和 Flyway 的schema_history表冲突,因为 DBUnit 默认会尝试清空数据集里出现的所有表,你必须在数据集里明确标出需要清理的表,不要让它扫描整个 schema。
3.3 编写测试基类:统一管理连接与清理动作
每一条测试类都去手写IDatabaseTester会很啰嗦,也容易漏掉清理动作。我习惯做一个抽象基类,把数据源连接、连接配置、场景还原、结果断言统一收口。
@SpringBootTest @ActiveProfiles("test") public abstract class BaseDbUnitTest { @Autowired protected DataSource dataSource; @BeforeEach public void setUp() throws Exception { IDatabaseConnection connection = new DatabaseConnection( dataSource.getConnection(), "schema_name"); IDataSet dataSet = new FlatXmlDataSetBuilder() .build(getClass().getResourceAsStream("/testdata/base-data.xml")); DatabaseOperation.CLEAN_INSERT.execute(connection, dataSet); } @AfterEach public void tearDown() throws Exception { ... } }上面代码里我故意保留了schema_name这个占位符,因为不同数据库处理 schema 的方式不一样。MySQL 里 schema 通常就是库名,PostgreSQL 里是public,Oracle 里则是用户名称。这块配置不对,DBUnit 会找不到表。
在抽象基类里,base-data.xml是整个测试上下文的基础数据。不同业务模块可以再建各自的数据集文件,在子类里覆盖或者追加。这里我建议不要图省事把所有数据都塞进一个 XML,因为数据多了以后,每次测试插入全量数据会极其浪费。
4. 数据库隔离测试的两个核心动作:现场还原与结果断言
4.1 准备标准数据集:我的 dataset XML 模板
DBUnit 最常见的数据集格式是扁平 XML。它的优点是易读、易改、适合版本管理。基本结构如下:
<?xml version="1.0" encoding="UTF-8"?> <dataset> <order id="1" user_id="20" amount="99.00" status="PAID" created_at="2024-01-01 10:00:00"/> <order id="2" user_id="20" amount="199.00" status="CREATED" created_at="2024-01-02 11:00:00"/> <order_item id="1" order_id="1" product_name="手机" price="99.00"/> </dataset>这里有三个容易踩的细节:
第一,如果你的表有自增主键,测试数据里通常要显式给主键值,并且后续断言用固定值。只要你执行的是CLEAN_INSERT,它会先清空表,插入时主键冲突不会出现,因为表已经是空的。但要注意 AUTO_INCREMENT 还在继续,所以不要依赖数据库自动生成的值去断言,否则下一个测试跑出来的主键值可能不同。
第二,日期时间字段的格式必须和数据源方言匹配。DBUnit 在解析 XML 字符串时,会根据数据类型做转换,时区不一致会导致你明明写的2024-01-01 10:00:00,数据库里存成09:00:00,断言失败。后面我会专门讲时区问题。
第三,空字符串和NULL的语义完全不同。如果你的表里某个字段允许NULL,在 XML 里别写空属性,写<order id="1" remark=""/>表示空字符串;要表示NULL就用<order id="1" remark="[NULL]"/>。DBUnit 对NULL值有特殊处理,但前提是数据集构建器开启了相关参数。
如果你不想维护 XML,DBUnit 也支持从数据库直接导出现有数据做成数据集,用这个命令可以快速生成初始数据集:
dbunit --config org.dbunit.ext.mysql.MySqlDataTypeFactory \ --operation EXPORT \ --table order --table order_item \ jdbc:mysql://localhost:3306/test_db?user=root&password=xxx \ /tmp/export.xml但导出的 XML 里会带上大量结构化格式信息,手工维护时反而看得头晕,所以我更倾向手写扁平 XML。
4.2 用 CLEAN_INSERT 还原测试现场
DatabaseOperation.CLEAN_INSERT是使用频率最高的操作,它的执行逻辑是:先删除数据集里涉及的所有表数据,再插入数据集里的记录。这样每次测试开始前,数据库都是“干净且已知”的。
我在实际项目里用这样的流程来构造场景:
- 先准备一个基础数据集
base-data.xml,放所有测试都需要的公共用户、配置、字典表数据。 - 每个测试方法单独准备一个小数据集,只放本次测试需要关注的数据。
- 测试执行前,先读取基础数据集执行
CLEAN_INSERT,再读取方法级数据集执行INSERT。 - 断言环境里写一个
compareCurrentTableData工具,只比对指定表的关键字段,而不是全表比对。
比起只执行一次CLEAN_INSERT再让测试方法自己去插入数据,我认为把“公共数据”和“场景数据”分开更利于维护。因为当测试方法数量增长到上百个时,公共数据一点微小的字段变化,会导致全部门测试全挂。你把公共数据集中在一个文件里,改起来至少能定位到具体影响范围。
4.3 用数据断言替代手写 SQL 查询断言
很多团队写的测试,都会在方法末尾通过一个select count(*) from table去判断数据是否正确。这种断言本身没什么问题,但断言粒度太粗,而且查询逻辑写在多个测试里会重复,一旦表结构改变,这堆 SQL 都要改。
DBUnit 的断言机制是把当前数据库状态和期望数据集做比较。它能告诉你:哪一行和期望不一致、哪一个字段不一致、期望值和实际值分别是什么。我在项目里用这样的工具方法:
public void assertCurrentOrderData(String expectedFile) throws Exception { IDatabaseConnection connection = new DatabaseConnection(dataSource.getConnection()); IDataSet currentData = connection.createDataSet(new String[]{"order"}); IDataSet expectedData = new FlatXmlDataSetBuilder() .build(getClass().getResourceAsStream(expectedFile)); Assertion.assertEquals(expectedData, currentData); }这个方法会把数据库里order表的所有数据和期望数据集做全字段比较。如果只是比较部分字段,可以用ITableFilter过滤,避免因为created_at这种审计字段毫秒值不同导致整条断言失败。
4.3.1 关键点:不要忽略列顺序和 null 匹配
DBUnit 在比较两列时,如果一行数据在数据库里是NULL,而期望数据集里是空字符串,这会被判定为不一致。所以要严格定义NULL与空字符串的区别。另外,如果数据库表中新增了一个非空字段且没有默认值,你手头的期望 XML 里没这个字段,DBUnit 会把“期望缺列”当成失败。这类问题经常发生在数据库字段变更后,测试文件没同步更新。
4.4 一个完整的测试方法示例
下面这段代码展示了一个典型的 Service 层测试:测试创建订单后,数据库里确实多了一条记录,并且状态正确。
@SpringBootTest @ActiveProfiles("test") class OrderServiceTest extends BaseDbUnitTest { @Autowired private OrderService orderService; @Test @DataSet("orders-empty.xml") void should_create_order_when_input_valid() { // given CreateOrderCommand command = new CreateOrderCommand(); command.setUserId(20L); command.setAmount(new BigDecimal("299.00")); // when orderService.createOrder(command); // then assertCurrentOrderData("expected-data/order-created.xml"); } }这里@DataSet是我自己写的注解,作用是在setUp阶段加载指定数据文件。这个注解背后的实现,其实就是在BaseDbUnitTest里读取注解值并替代默认数据集文件,并不复杂,但对团队规范很有帮助:它的语义一目了然,新人一看就知道测试用了哪些初始数据。
expected-data/order-created.xml的内容大致是:
<dataset> <order id="10001" user_id="20" amount="299.00" status="CREATED"/> </dataset>注意,数据库里可能还会有其他字段,比如updated_at、remark、version,只要这些字段有默认值或者被我们忽略掉,就不会进入断言逻辑。我建议在定义“断言字段范围”时,用同时包含忽略字段列表的方式去实现。为了节省篇幅这里不展开实现代码,但这个思路值得在团队里统一成工具。
5. DBUnit 落地后的公开处刑:六个常见坑与排查思路
5.1 清空顺序引发的外键级联问题
DBUnit 的CLEAN_INSERT删除数据时,会按照数据集的表定义顺序逐张删除。如果你的主表和子表都出现在同一个数据集里,而主表排在子表前面,删除主表时子表外键还在引用,数据库就会报外键约束错误。
解决方式有两个:一是调整 XML 里<table>的顺序,先删子表再删主表;二是使用数据库的SET FOREIGN_KEY_CHECKS=0这类命令。我在项目里的做法是维护一个公共的“表依赖顺序清单”,在构建数据集时按依赖顺序排序。对于单个测试的小数据集,则明确只包含必要关联表。
有一个更隐蔽的问题:外键约束被关闭后,如果测试崩溃,约束状态没恢复,后续测试会继续在约束关闭的状态中运行,导致一些通过约束保护的数据错误没有被发现。所以关闭外键检查的命令必须在try-finally里恢复。
5.2 事务回滚与@Transactional的共谋与冲突
这是 DBUnit 集成测试里最经典的一个坑。Spring 测试默认对每个测试方法开启事务并在结束时回滚,这本是一件好事。但当你同时使用 DBUnit 时,DBUnit 自己建立了一个IDatabaseConnection,它以 JDBC 方式直接操作数据库,不走 Spring 管理的事务。于是会发生这样的情况:
- DBUnit 在
@BeforeEach里执行了CLEAN_INSERT,数据提交到数据库。 - 测试方法执行时,Spring 开启一个新事务,插入一条
order。 - 测试结束时,Spring 把事务回滚,这条
order消失。 - 但 DBUnit 在
@AfterEach里要清理数据或者做断言,它会看到 DBUnit 自己插进去的数据还在,而 Spring 事务里的数据已经回滚,于是断言结果和实际结果错位。
更常见的是:Spring 事务在方法结束前已经 flush 到了数据库,但尚未提交,此时 DBUnit 的断言查询是否能看到这条未提交的数据,取决于数据库的隔离级别。MySQL InnoDB 默认隔离级别下,DBUnit 的另一个连接不把当前连接读到的未提交数据作为可见数据,于是断言必然失败。
我的建议是:做 DBUnit 数据还原和断言时,没必要再叠加 Spring 的@Transactional回滚机制,两者互相干扰大于互补。如果确实需要事务行为,由测试代码自己去开启业务事务并完成提交,让 DBUnit 每次通过CLEAN_INSERT重建现场。如果实在想用 Spring 回滚,那 DBUnit 的CLEAN_INSERT就不要放在@AfterEach阶段,这样至少能避免它把没提交的数据二次清理掉。
5.3 日期时间断言:时区、精度与数据库方言
日期字段是测试里最容易“凭实力偶发失败”的。我见过有人写断言时发现每次跑到下午就挂,查半天才发现是时区问题。DBUnit 默认比较按字符串处理,如果测试数据库的created_at存的是2024-01-01 09:00:00,而期望值是2024-01-01 10:00:00,那就对不上。
排查思路分三层:
- 设置统一的 JDBC URL 时区参数,比如 MySQL 的
serverTimezone=Asia/Shanghai。 - 在创建
DatabaseConnection时,配置 DBUnit 的相关DataTypeFactory,例如MySqlDataTypeFactory,让 DBUnit 能正确识别日期时间类型。 - 断言时忽略时间敏感字段,或者把期望数据的时间改到和数据库一致。强制统一业务时间不是一个好主意,因为一旦应用被部署在不同机房,时区差异还是会暴露出来。
5.4 大数据集拖慢整个测试套件
DBUnit 好用,但不代表你可以把上万行数据塞进一个 XML。我接过一个历史项目,里面有张配置表被灌了两万行测试数据,每次CLEAN_INSERT都要先删两万行再插两万行,单条测试光准备数据就要七八秒。两百个测试类跑下来,整个套件要二十多分钟。
优化办法是分开粒度:基础配置表可以只保留最小集;大数据量场景单独走性能测试环境;单元测试里只需要用到几条关键记录。另外,DBUnit 支持从一个数据集文件里抽取部分表执行CLEAN_INSERT,你可以只清理当前断言涉及的表,而不要每次把所有表都清理一遍。
5.5 多数据源环境下 DBUnit 连错库
微服务项目经常会配多个数据源,比如主库、读库、缓存库。如果你在测试基类里直接@Autowired DataSource,而项目里同时存在多个DataSourceBean,Spring 可能会注入到错误的数据源中。更夸张的情况是,测试插入数据时连接到了只读库,直接报“不能写入”。
处理方式有两种:一个是给DataSourceBean 加@Primary注解,明确哪个是主数据源;另一种是自己定义一个单独的 DBUnit 专用数据源,从配置文件里读独立的测试库。我个人更推荐后者,因为它在配置上把“测试数据源”和“业务数据源”彻底分开了,不会因为业务配置的改动影响测试稳定性。
5.6 断言失败后中间状态残留
DBUnit 断言失败抛出异常时,@AfterEach里的清理动作不一定会执行,因为异常会让测试方法直接结束,进入失败状态。如果不做兜底,数据库里会留下半桶水:该清理的表没清,该插入的数据没插全。下一次测试再执行时,就从一个不确定状态开始。
我建议在@AfterEach里不要只依赖DatabaseOperation.DELETE_ALL,而是基于数据集重新执行CLEAN_INSERT。因为它本身就包含了删除和插入,即使上一次失败了,下一次测试@BeforeEach也会自动清理之前的残留。这需要一个前提:清理操作绝不会失败。所以我在实践里都是把清理逻辑放在基类的setUp,用finally级别的机制去保证每个用例开始前状态是确定性的。
6. 再向上一步:测试数据集管理与执行效率优化
6.1 数据集也要做版本控制,而不是随缘创建
把数据集文件命名为testdata/v1/order-test-data.xml只是一个初级做法,真正需要控制的是“这个数据集和哪个业务版本对应”。我见过有人在迁移脚本里改了字段名,但测试数据集还是旧字段名,然后 DBUnit 在删除旧表时找不到了,直接抛异常。
一个可落地的规范是:
- 数据文件按模块分目录,例如
src/test/resources/dataset/order/、src/test/resources/dataset/user/。 - 每个数据文件顶部用注释标明“依赖的最小数据库版本”。
- 数据库迁移脚本更新时,git 提交信息必须附带测试数据文件的变更记录。
- 在 CI 流程里增加一个检查任务:当某个表在迁移脚本中被修改,自动列出哪些测试数据集可能受影响,提醒开发者同步修改。
这个规范做好以后,团队可以减少至少一半因“测试数据和表结构不一致”导致的连环失败。
6.2 用代码构建数据而不是手写超大 XML
当业务复杂到一定阶段,静态 XML 数据集会变得非常难维护。比如订单状态流转测试,一个订单的状态字段从 CREATED 到 PAID 需要过多个方法,每次都要在数据集里定义一个订单。如果字段有三十个,写完第一段就已经累得够呛。
这时候我推荐用辅助构建器,用 Java 代码直接构造数据对象,再通过 DBUnit 的CollectionDataSet转换成数据集。这样你既能用到编码的清晰性,又能复用测试数据工厂。举个例子:
public static OrderDataSet createOrder() { OrderDataSet.Row row = OrderDataSet.builder() .id(10001L) .userId(20L) .amount(new BigDecimal("299.00")) .status("CREATED") .build(); return new OrderDataSet(List.of(row)); }这种代码构建方式比手写 XML 好的地方在于:字段是强类型的,写错名字在编译期就会报红,而不是等到运行时 DBUnit 解析失败;对象能复用,比如“已支付订单”这个公共工厂方法可以在几十个测试里复用;配合 Mockito 还能做更复杂的组合,把数据库准备和业务 Mock 放在同一个流程里。
6.3 并行测试与数据库隔离的取舍
并行执行是提高测试速度的利器,但和 DBUnit 的数据库隔离天然有冲突。因为 DBUnit 是同一时间往同一张表插数据,如果并行线程都在执行CLEAN_INSERT,互相之间就会覆盖数据,断言结果基本不可信。
如果想并行,常见策略是:
- 按测试类分配独立的数据库 schema,不同 schema 之间互不干扰。比如用 MySQL 可以动态创建
test_db_1、test_db_2,每个测试类在运行时通过配置切换到自己的 schema。 - 不并行跑同一个数据源下的写库测试,只并行跑只读型测试和纯 Mock 测试。这需要在测试分组时做好规划。
- 使用测试容器方案,每个并行任务启动一个独立的容器数据库,代价是资源占用更大。
我想强调一下:并行度越高,测试隔离成本越高。不要为了追求 CI 时间好看,把整套测试都拉成无条件并行,那样数据库隔离很可能守不住,测试反而会变成新的脏数据制造机。我目前的习惯是:接口层和 Service 层写库测试归为一个串行组,性能测试、纯逻辑测试放到并行组,两者都跑。
6.4 每轮测试跑完留一份“回放记录”
还有一个很容易被忽略的小习惯:每次本地测试跑完后,把当前数据库的导出快照留存一份,命名为postrun_dump.xml。不要小看这个文件,当你在开发新功能时改了 SQL 但没改测试数据,结果测试挂了,你可以用这个快照和上一个干净基线对比,迅速看出哪个表的数据差异。这也是我后来感觉 DBUnit 真正值钱的地方:它不只是测试工具,还能帮你做数据库状态审计。
像这种“拿到当前现状”的能力,用 DBUnit 原生 API 一句就能搞定:
IDataSet current = connection.createDataSet(); CurrentDataSetWriter.write(current, new FileOutputStream("postrun_dump.xml"));没有 DBUnit 的时候,你可能得手动写一堆导出 SQL,还要处理编码、格式化问题;有了它,随手就能留档。
写在最后的个人体会
这套 SpringBoot + DBUnit 的数据库隔离测试方案,我在不同项目里反复落地过好几次。最直接的感受是:它确实能把“测试偶发失败”的概率压到极低,但前提是你愿意花时间把测试基类、数据集规范、清理策略先打好底子。DBUnit 不是银弹,它只负责把数据库现场还原成确定状态,最终你能不能在测试里获得稳定反馈,还是取决于你对数据源、事务、时区和外键这些细节有没有耐心。
如果让我给刚接触的人一个最小启动建议,那就是:先别急着封装,把官方最基础的 XML 数据集跑起来,然后一个测试类一个测试类去覆盖。当你发现某个测试需要靠“前一个测试留下的数据”才能通过时,不用怀疑,那就是数据库隔离该下场的时候了。