1. 从“不敢改数据库代码”到放心的那一瞬间先聊聊我自己的经历。早几年维护一个老项目Service 层里但凡涉及数据库操作的逻辑我基本不敢轻易动。改一个 SQL 条件加一个查询字段心里都得打鼓“别把线上数据搞坏了。”当时的“单元测试”约等于没有顶多写几个不碰数据库的纯函数测试核心的数据操作全靠手工验证。后来接触了 DbUnit 配合 Spring Boot 做数据库隔离测试才算把这块短板补上。简单说DbUnit 是一个可以在测试过程中接管数据库数据的工具它能把测试库里的表数据导出成 XML、JSON 这类文件也能在测试前把指定数据加载进数据库。结合 Spring Boot 的测试体系我们可以在每个测试方法执行前把数据库“重置”成一份已知状态的数据测试跑完再把数据还原互不污染。这套组合拳打下来数据库相关的单元测试终于不再是摆设而是真正能跑、能重复跑、能放心跑。这篇文章适合谁看一种是刚接触 Spring Boot 测试、想给项目加上数据库层测试的开发者另一种是已经写过一些 CRUD 测试但发现测试之间互相影响、数据乱成一团的同学。我会从环境搭建、数据文件设计、核心注解用法、常见坑点这几个维度展开把我实际踩过的坑和最终落地方案都写出来。2. 为什么单元测试需要“数据库隔离”很多人会问我直接用测试环境的数据库不行吗测试用例里插入数据、查询数据、再清理数据不也能测能测但大概率测不稳定。问题出在几个地方。2.1 测试数据残留导致的结果漂移假设你有一个订单表测试 A 插入了一条订单测试 B 查询订单总数断言结果是 3。如果测试 A 跑完没清理数据测试 B 再跑就会查出 4 条直接失败。就算你在测试 B 里做了清理也难保清理动作本身不出错尤其是涉及外键关联、自增主键、软删除标记这些场景。2.2 测试执行顺序的不确定性Maven 或 Gradle 跑测试的时候方法执行顺序并不保证是你写代码的顺序。今天本地跑通过明天 CI 上换个 JDK 版本或者并发跑测试顺序变了测试就挂了。这种“偶发性失败”是最让人头疼的排查半天最后发现是测试数据互相覆盖。2.3 自增主键带来的数据污染MySQL 的自增主键一旦插入过数据即使你后面把记录删了自增计数也不会回退。第一次跑测试主键可能是 1、2、3第二次跑同样的测试主键变成 4、5、6。如果断言里写死了主键值第二次跑必挂。2.4 数据库隔离的核心思想所谓隔离不是把数据库物理隔开而是让每个测试用例在执行前后数据库都处于一种“已知、可控、可预期”的状态。DbUnit 的做法是维护一组数据集文件每个测试方法开始前把对应数据集同步到数据库测试结束后再清理或恢复。这样所有的脏数据、自增偏移、并发干扰全部被挡在测试用例之外。提示数据库隔离测试的前提是使用独立的测试数据库绝不建议连生产库或共享开发库跑测试。我们常说“测试可以随便造但不能弄脏别人的数据”指的就是这个意思。3. 技术选型为什么是 Spring Boot DbUnit选型这件事很多团队会纠结。市面上能操作测试数据的工具不算少Spring 自带的Sql注解也能执行 SQL 脚本Testcontainers 更是可以直接拉起真实数据库容器。那为什么还要用 DbUnit3.1 与 JUnit 4/5 的天然契合DbUnit 从一开始就是为单元测试设计的它提供了一套完整的 API可以在测试生命周期里轻松地执行cleanInsert、deleteAll、compare等操作。Spring Boot 的SpringBootTest加上 JUnit 的扩展机制能很好地把它融入进来。3.2 数据集文件比 SQL 脚本更直观Sql注解当然也能在测试前执行 SQL但问题在于 SQL 脚本的可读性和可维护性。你要看的是一条条 INSERT 语句字段顺序、赋值逻辑、类型转换都得靠人脑解析。DbUnit 的数据集文件则是结构化的 XML 或 JSON一眼就能看出每个表有哪些字段、哪些数据是测试前置条件。3.3 断言能力更强DbUnit 不仅能往数据库塞数据还能把数据库当前数据和预期数据集做比对。这一点非常实用。比如你要测试一个“更新用户余额”的方法跑完之后可以直接用Assertion.assertEquals(expectedDataSet, actualDataSet)来校验数据库里的最终结果比手动写 SQL 查询再挨个字段 assert 要干净得多。3.4 轻量不需要额外中间件和 Testcontainers 相比DbUnit 不需要 Docker 环境不需要拉镜像也不需要等待容器启动。它直接在现有的测试数据库上操作数据。对于中小型团队来说CI 机器上不一定有 Docker 环境DbUnit 明显更轻量、更容易落地。我个人的建议是如果团队已经用 Testcontainers 管理集成测试环境那 DbUnit 和它并不冲突完全可以搭配使用如果只是想快速把 DAO 层和 Service 层的数据库测试补起来DbUnit 的性价比更高。4. 搭建环境Spring Boot DbUnit 的最小配置这部分我直接给出可复现的配置过程。假设你的项目是 Maven 构建Spring Boot 版本 2.7.x数据库用 MySQL 5.7 或 8.0。4.1 引入 Maven 依赖dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-test/artifactId scopetest/scope /dependency dependency groupIdorg.dbunit/groupId artifactIddbunit/artifactId version2.7.3/version scopetest/scope /dependency dependency groupIdcom.github.springtestdbunit/groupId artifactIdspring-test-dbunit/artifactId version1.3.0/version scopetest/scope /dependency这里有个细节spring-test-dbunit这个库是早期 Spring 官方测试框架的社区扩展最新版本只维护到 1.3.0但它对 Spring Boot 2.x 的兼容性已经经过了大量项目的验证。如果你用的是 Spring Boot 3.x可能要关注一下spring-test-dbunit是否还支持或者改用纯 DbUnit API 手动封装。spring-boot-starter-test默认会带上 JUnit 4如果你用旧版本或 JUnit 5Spring Boot 2.2 默认是 JUnit 5。DbUnit 本身和 JUnit 版本无关它只是提供数据集操作能力生命周期控制交给 JUnit。4.2 准备测试数据库假设测试库叫test_shop连接配置放在src/test/resources/application-test.ymlspring: datasource: url: jdbc:mysql://localhost:3306/test_shop?useSSLfalseserverTimezoneAsia/ShanghaicharacterEncodingutf8 username: root password: root driver-class-name: com.mysql.cj.jdbc.Driver注意几个点测试库和开发库必须分开这是铁律。serverTimezone要设置否则高版本 MySQL 驱动会因为时区问题启动报错。测试配置不要写进主配置文件用application-test.yml并在测试类上标注ActiveProfiles(test)来激活。4.3 初始化数据库表结构DbUnit 只管数据不管表结构。你需要先在测试库里把表建好。最简单的方式是用项目里的schema.sql或 Flyway/Liquibase 迁移脚本。我这里直接用 Flyway因为项目本来就有迁移脚本测试库执行一遍即可。如果不想引入 Flyway也可以写一个schema.sql放在src/test/resources下然后在测试类上用Sql(scripts /schema.sql)执行一次。这个方法可行但不推荐作为长期方案因为表结构一多SQL 脚本就会失控。经验我见过有人用spring.sql.init.modealways让应用每次启动都执行 schema.sql。这种做法在集成测试中容易出问题因为只要表已存在脚本里的CREATE TABLE IF NOT EXISTS会跳过但后续的数据初始化和清理逻辑会变得混乱。最好还是让测试库表结构通过版本化迁移工具统一管理。5. 设计测试数据集文件DbUnit 最核心的东西就是数据集文件。文件格式支持 XML、CSV、JSON 等我推荐用 XML因为结构清晰、社区资料多、IDE 里有高亮。5.1 最小化 XML 数据集示例假设我们有两个表user和user_order。?xml version1.0 encodingUTF-8? dataset user user_id1 usernamealice age25 statusACTIVE / user user_id2 usernamebob age30 statusACTIVE / user_order order_id100 user_id1 amount199.90 statusPAID / user_order order_id101 user_id1 amount99.90 statusUNPAID / /dataset这里有个关键点dataset根元素下面每个子元素对应一张表元素名就是表名属性名就是字段名属性值就是字段值。如果某个字段允许为空不写属性即可。5.2 数据集的三种用法insert / cleanInsert / deleteAllDbUnit 提供了三个最常用的操作直接决定了测试数据的加载和清理方式。操作行为适用场景insert仅追加数据不清空已有数据你想保留数据库中已有数据再插入测试数据cleanInsert先清空表中所有数据再插入测试数据每个测试方法都需要干净的初始数据deleteAll清空表中所有数据不插入新数据测试结束后的清理动作实际项目中我几乎只用cleanInsert作为测试前置操作因为它的语义最明确跑任何一条测试用例之前数据库就是数据集文件描述的那个状态。至于测试结束后的清理则取决于你的设计。5.3 通过 spring-test-dbunit 的注解加载数据集RunWith(SpringRunner.class) SpringBootTest ActiveProfiles(test) TestExecutionListeners({ DependencyInjectionTestExecutionListener.class, TransactionDbUnitTestExecutionListener.class }) DatabaseSetup(/datasets/user.xml) DatabaseTearDown(type DatabaseOperation.DELETE_ALL, value /datasets/user.xml) public class UserServiceTest { Autowired private UserService userService; Test public void testFindById() { User user userService.findById(1L); Assertions.assertEquals(alice, user.getUsername()); } }几个注解的含义拆开解释DatabaseSetup在测试方法执行前加载数据集默认使用CLEAN_INSERT操作。DatabaseTearDown在测试方法执行后清理数据集DELETE_ALL表示把文件涉及的所有表清空。有人会问我每个测试类都写这两个注解太繁琐了。没错所以更常见的做法是抽一个公共基类把通用配置放在基类上子类只需要提供数据文件路径。5.4 针对不同测试方法使用不同数据集如果同一个测试类里不同方法需要不同数据可以在方法级别再加注解Test DatabaseSetup(/datasets/user-with-order.xml) public void testFindUserWithOrders() { // ... }方法级别的注解会覆盖类级别的注解。这一点和 Spring 事务传播机制中的覆盖逻辑有点类似提供了一种按需定制的能力。注意类级别和方法级别的DatabaseSetup如果同时存在以方法级别为准。但如果你没写方法级别的则使用类级别的。这个“就近覆盖”规则很容易被忽略出问题时第一反应往往想到的是缓存或事务问题而不会想到数据集覆盖逻辑。6. 实操一个完整的用户服务测试示例配置讲完了接下来写一个完整的实战案例。我不喜欢讲虚的所以直接上代码和现场注释。6.1 被测代码用户创建与余额变更假设有一个UserService内含两个方法Service public class UserService { private final UserRepository userRepository; private final OrderRepository orderRepository; public UserService(UserRepository userRepository, OrderRepository orderRepository) { this.userRepository userRepository; this.orderRepository orderRepository; } Transactional public User createUser(String username, int age) { User user new User(); user.setUsername(username); user.setAge(age); user.setStatus(ACTIVE); return userRepository.save(user); } Transactional public void markOrderPaid(Long orderId) { Order order orderRepository.findById(orderId) .orElseThrow(() - new IllegalArgumentException(order not found)); order.setStatus(PAID); order.setPaidTime(LocalDateTime.now()); orderRepository.save(order); } }这个逻辑本身不复杂但涉及两个表user和user_order。要验证markOrderPaid是否真的更新了订单状态和支付时间不能只靠返回对象断言还要看数据库里的最终状态。6.2 准备测试数据集/datasets/user-order-before.xml?xml version1.0 encodingUTF-8? dataset user user_id1 usernamealice age25 statusACTIVE / user_order order_id100 user_id1 amount199.90 statusUNPAID / /dataset/datasets/user-order-expected.xml?xml version1.0 encodingUTF-8? dataset user user_id1 usernamealice age25 statusACTIVE / user_order order_id100 user_id1 amount199.90 statusPAID / /dataset这里刻意让预期数据集和前置数据集只有status字段不同突出测试目标。6.3 编写测试类RunWith(SpringRunner.class) SpringBootTest ActiveProfiles(test) TestExecutionListeners({ DependencyInjectionTestExecutionListener.class, TransactionDbUnitTestExecutionListener.class }) DatabaseSetup(/datasets/user-order-before.xml) DatabaseTearDown(type DatabaseOperation.DELETE_ALL, value /datasets/user-order-before.xml) public class UserServiceTest { Autowired private UserService userService; Autowired private DataSource dataSource; Test public void testMarkOrderPaid() throws Exception { userService.markOrderPaid(100L); // 获取数据库当前结果 IDatabaseConnection connection new DatabaseDataSourceConnection(dataSource); IDataSet actualDataSet connection.createDataSet(); // 加载期望数据集 IDataSet expectedDataSet new XmlDataSet( new FileInputStream(src/test/resources/datasets/user-order-expected.xml) ); // 只比较 user_order 表 Assertion.assertEquals( expectedDataSet.getTable(user_order), actualDataSet.getTable(user_order) ); } }粗看这段代码没问题但我实际跑的时候踩了几个坑下面逐一说明。6.4 坑点一日期时间字段的比对失败user_order表里有一个paid_time字段测试期望数据集里没有写它。DbUnit 在断言的时候如果实际数据库该字段有值而期望数据集里该字段缺失会直接报“列数不一致”错误根本不算“忽略了该列”。解决办法使用IColumnFilter或DefaultColumnFilter过滤掉不参与比对的列。更简单的做法是在期望数据集里加上这个字段并给一个确定值。但支付时间是个动态值你没法提前知道精确时间。所以更稳妥的方案是用DefaultColumnFilterITable expectedTable expectedDataSet.getTable(user_order); ITable actualTable actualDataSet.getTable(user_order); // 过滤掉 paid_time 列不参与比较 ITable filteredExpected DefaultColumnFilter.includedColumnsTable( expectedTable, new String[]{order_id, user_id, amount, status}); ITable filteredActual DefaultColumnFilter.includedColumnsTable( actualTable, new String[]{order_id, user_id, amount, status}); Assertion.assertEquals(filteredExpected, filteredActual);这样既保证了核心状态字段正确又避开动态字段干扰。6.5 坑点二表字段顺序不一致导致的断言失败MySQL 查询返回的列顺序和设备文件里写的字段顺序不一定一致。DbUnit 在断言时默认是按列名匹配按理说不会出问题但如果你用了XmlDataSet加载期望文件某些列因为大小写或者空格问题会被视为不同字段。我的经验是所有字段名统一小写不要在 XML 里加多余空格或换行注释。看似不起眼的问题排查起来很浪费时间。6.6 坑点三事务与 CLEAN_INSERT 的交互如果你在测试类上加了Transactional测试方法会处于一个事务上下文中测试结束后事务回滚。这个特性在某些情况下是好事但和 DbUnit 的cleanInsert放在一起会出问题。DatabaseSetup在事务开始时执行cleanInsert清空表后插入数据然后你的测试代码在事务里操作数据最后事务回滚。但事务回滚并不会把cleanInsert插入的数据一并回滚因为这些操作在同一个事务里吗并不一定。更需要注意的是cleanInsert默认会在操作前关闭外键约束操作完成后重新开启。如果测试方法本身处于事务中外键约束的开关行为可能导致约束状态错乱后续断言出现诡异的外键冲突。我个人的建议是测试方法不要加Transactional让每个测试方法在真实提交的数据库层面执行再配合DatabaseTearDown清理数据。这样反而更接近生产环境能暴露事务性 Bug。7. 结合 Spring 事务控制一种更优雅的隔离方式前面说了不建议在测试方法上直接加Transactional但有些团队习惯用它做自动回滚省去DatabaseTearDown。这里我分享一个兼顾方案。7.1 思路每条测试用例独占事务并强制回滚这种模式下测试用例执行期间产生的数据修改会被事务回滚掉看起来数据库“隔离”了。DbUnit 在这里的职责就变成确保事务开启前数据库处于预期的初始状态。执行时序如下事务开启前DatabaseSetup执行cleanInsert把数据集推入库。测试方法开启事务业务代码在事务内操作数据。断言基于当前事务内的查询结果或者直接查数据库连接需要加入同一个事务。事务回滚业务代码产生的修改全部撤销。测试结束后DatabaseTearDown执行清理把 DbUnit 插入的数据也清掉。这样看起来双重保险很干净。但上一节提到的事务和外键约束的冲突问题依然存在。我的实测结论是只要测试库的 DDL 本身没有复杂外键依赖这个模式跑起来没问题一旦有跨表外键约束且cleanInsert遇到自引用或循环引用就容易报错。7.2 避免大数据集清理的低效问题如果你有几十张表、每张表几千行数据每个测试方法都执行CLEAN_INSERT性能会非常感人。尤其 CI 上并发跑测试数据库很容易成为瓶颈。我常用的优化思路按需只加载当前测试需要的表不要把全量数据集塞进每个测试。对于巨大的基础数据表比如城市列表、字典表单独准备一份“种子数据”只做一次加载不做每个方法的 cleanInsert。用DatabaseOperation.TRUNCATE_TABLE替代DELETE_ALL清空数据的同时重置自增主键速度比 DELETE 快很多。7.3 事务回滚与 DbUnit 数据加载的先后关系有一个容易忽略的细节DatabaseSetup和Transactional的执行顺序并不总是你想象中的那样。DatabaseSetup由TransactionDbUnitTestExecutionListener控制而事务回滚逻辑由 Spring 的TransactionalTestExecutionListener控制。两者的监听器顺序如果放错了数据集加载可能发生在事务已经开启之后导致cleanInsert的数据也参与事务回滚最终测试的初始状态根本不是你想看到的。解决方法是明确定义监听器顺序TestExecutionListeners( value { DependencyInjectionTestExecutionListener.class, TransactionDbUnitTestExecutionListener.class, TransactionalTestExecutionListener.class }, mergeMode TestExecutionListeners.MergeMode.MERGE_WITH_DEFAULTS )这个顺序能保证依赖注入先完成、DbUnit 数据集加载再执行、Spring 事务控制最后接管。如果你只用默认监听器很可能遇到“顺序不对导致数据被回滚”的隐性 Bug。经验这类时序问题最坑人因为测试结果偶尔正确偶尔错误看起来像随机失败。我花过一个下午排查最后才发现是监听器顺序问题。所以建议用我上面这个配置直接固定顺序。8. 进阶动态构建测试数据摆脱大量 XML 文件XML 数据集文件多了以后维护成本会上升。每个接口对应一套“前置数据 期望数据”文件数量翻倍而且取名字也是个麻烦事。8.1 用 Java 代码构建 IDataSetDbUnit 允许你通过Row、Table的 API 动态构建数据集不一定要用文件IDataSet buildDataSet() { ListRow userRows new ArrayList(); userRows.add(new Row(user_id, 1, username, alice, age, 25, status, ACTIVE)); ITable userTable new DefaultTable(user, new String[]{user_id, username, age, status}, userRows); return new DefaultDataSet(userTable); }这种方式适合需要根据参数动态拼装数据的场景。比如测试“用户分页查询”不同分页参数需要不同数量的用户记录纯 XML 很难覆盖全代码动态构建就灵活得多。8.2 封装一个简单的测试数据工厂我在项目里会写一个TestDataFactory类专门负责构建常见实体数据public class TestDataFactory { public static Row createUserRow(Long id, String name, int age, String status) { return new Row( user_id, String.valueOf(id), username, name, age, String.valueOf(age), status, status ); } }这样测试代码里生成数据集就变成了ITable userTable new DefaultTable(user, new String[]{user_id, username, age, status}, Arrays.asList( createUserRow(1L, alice, 25, ACTIVE), createUserRow(2L, bob, 30, ACTIVE) ) );看起来代码量没少多少但可读性提升明显而且不用切换 JSON/XML 格式直接在 Java 世界里维护类型安全。8.3 什么时候应该用文件什么时候应该用代码我的取舍标准数据量大、结构稳定、多个测试复用的基础数据用 XML。只服务于特定测试方法、数据量小、需要灵活调整的用代码构建。需要和外部系统对账的复杂关联数据用 XML因为直观。两者并不互斥可以混合使用。核心原则是确保每个测试方法看到的数据库状态是可预测的。9. 常见问题与排查技巧实录这里整理一份我实际开发中遇到的问题速查表每一条都对应过一次真实排障经历。问题现象可能原因解决方案测试报外键约束错误cleanInsert清表顺序和外键依赖冲突在数据集 XML 中按关联顺序书写父表在前子表在后测试之间数据互相污染tearDown只清一部分表使用DELETE_ALL或TRUNCATE_TABLE确保清理全部涉及表期望数据集字段比实际少DbUnit 默认严格比对列集合用DefaultColumnFilter过滤动态列运行时提示找不到表测试库表结构未初始化检查 Flyway 脚本是否执行或 schema.sql 是否通过Sql加载cleanInsert性能极慢每次清空大量表改用TRUNCATE_TABLE或用DatabaseSetup按需加载局部数据集测试类继承基类但数据集不生效方法级别注解覆盖了类级别检查方法上是否残留旧的DatabaseSetup9.1 如何排查“测试一夜之间全挂”的问题这种场景往往发生在同事改了表结构或加了新字段之后。数据库表新增了一个非空字段但数据集 XML 里对应行没有提供该字段的值cleanInsert直接插入失败所有相关测试一起挂。经验做法准备一个“冒烟测试”专门验证所有数据集文件能否成功插入当前测试库。把每个 XML 数据集跑一遍cleanInsert任何失败都说明表结构和数据集不一致。这个冒烟测试不测业务逻辑只测数据完整性很好用。9.2 如何调试“断言失败但逻辑看起来没问题”如果业务代码看起来没问题断言却失败先把参与比对的表数据导出来肉眼对比。用 DbUnit 的QueryDataSet可以把查询结果导出成 XMLIDatabaseConnection connection new DatabaseDataSourceConnection(dataSource); QueryDataSet queryDataSet new QueryDataSet(connection); queryDataSet.addTable(user_order, SELECT * FROM user_order WHERE order_id 100); FlatXmlDataSet.write(queryDataSet, new FileOutputStream(target/debug-order.xml));导出的 XML 会显示数据库里的真实值和期望数据集对比问题一目了然。实际上大多数断言失败都是数据类型问题比如数据库里amount是BigDecimal期望 XML 里写的是字符串199.9导致精度比较失败。解决办法是给字段配置 Java 类型映射或者统一用BigDecimal的比较逻辑。9.3 关于自增主键的最终建议前面提到自增主键会导致测试数据重复运行时主键偏移。最直接的解决办法是在测试结束后用TRUNCATE_TABLE清理数据它会重置自增计数器。如果某些表因为外键关系不能用 TRUNCATE就老老实实用DELETE_ALL然后手动执行ALTER TABLE ... AUTO_INCREMENT 1。虽然麻烦但能保证每次测试的主键起始值一致。我个人更推荐在测试数据集的 XML 里显式指定主键值不依赖数据库自动生成。这样断言值一目了然也避免了自增偏移问题。10. 我如何把一个老项目的 DAO 层测试一点点补起来最后聊聊落地节奏。如果你接手的是一个没有数据库测试的老项目别想着一步到位先把最核心的数据链路测起来再逐步铺开。我的做法是分三步走第一步挑出项目里最频繁修改、最容易出 Bug 的 3 到 5 个 Service 方法为它们准备测试数据集和期望数据集。先把最痛的点覆盖了团队看到了效果后续推广阻力小。第二步把所有涉及数据库写入操作的方法统一加上DatabaseSetup和DatabaseTearDown保证它们彼此不污染。这个阶段可以先不追求断言精确只验证“方法执行不报错”后续再补齐数据校验。第三步把常用的数据集抽成公共基类文件每个测试类只加载自己需要的局部数据。同时把冒烟测试写好每次有人改表结构都能快速发现数据集是否失效。这三步走下来数据库相关的测试从无到有从有到稳定基本不会再有“测试时好时坏”的玄学问题。经验数据库隔离测试一开始最大的阻力往往不是技术而是团队习惯。很多开发者觉得“数据库测试跑得慢、不稳定、维护成本高”实际上这些痛点大部分都源于没有做好数据隔离。一旦数据集设计和清理策略到位测试速度能接受稳定性也会大幅提升。11. 最后再分享两个小技巧一个关于文件命名。数据集文件不要叫data1.xml、data2.xml最好按“业务场景 状态”命名比如user-order-unpaid.xml、user-order-paid.xml。这样看到文件名就能知道测试前置条件是什么维护成本低很多。另一个关于数据库连接配置。测试库的账号尽量不要用 root单独建一个只拥有测试库权限的账号避免测试代码误操作其他库。我在项目里吃过一次亏测试里连接配置写成了开发库地址cleanInsert把开发库的表清空了那一刻的教训足够深刻。数据库隔离测试这件事真正难的不是学会用 DbUnit而是建立起一套让测试可重复、可预期、可维护的规范。工具只是手段规范才是核心。希望我上面分享的内容能让你在补测试的时候少走一些弯路。
阅读完成 · 觉得有帮助?