单元测试覆盖率多少才合适?先说结论:别把 100% 当目标
我 2019 年在一个支付对账项目里,被覆盖率卡过脖子。
当时团队规定 JaCoCo 行覆盖必须 95%,结果有人写了 200 多个 assertNotNull,CI 是绿的,线上还是错。
那次以后我把单元测试拆成两类:一类锁业务规则,一类锁外部边界。
前者用 JUnit5 + Mockito,毫秒级;后者用 Testcontainers 连 MySQL 8.0.33,秒级。
我们项目 Java 17、Spring Boot 3.1.5、JUnit 5.9.3、Mockito 4.11.0、JaCoCo 0.8.11,这套参数跑了两年,单测套件从 4 分 20 秒降到 28 秒。
一、单元测试怎么 Mock 数据库?先问 SQL 行为要不要真
很多人搜“单元测试 Mock 数据库”,上来就 @MockBean Repository。
我以前也这么干,后来发现只要逻辑里出现 order by、limit、on duplicate key update,Mock 就测不出来。
我的判断表很简单:规则计算、状态流转、金额折扣,走纯单元测试;分页排序、唯一索引、事务回滚,走集成测试。
如果只是测 Service 的 if/else,把 Repository 接口注入进去,用 Mockito mock 或者内存 Fake 都行。
单测方法控制在 50ms 内,单个测试类 200ms 内,全量单元套件别超过 30 秒。
超过 30 秒,开发就开始跳过本地跑测试,这个指标就废了。
二、Mockito 别乱用 verify,@SpringBootTest 也别当默认
我见过一个订单服务,一个测试方法里 verify 了 11 次。
后来我们把 verify 从 61 处降到 9 处,只保留“必须调用支付网关一次”这种外部副作用。
内部协作用断言输出,不验证调用过程。
对比一下:@SpringBootTest + @MockBean 启动一次要 3 到 8 秒,纯 Mockito 构造对象通常 5 到 30ms;
H2 2.2.224 启动 1 秒左右,但 MySQL 方言坑多;Testcontainers 1.19.3 启动 MySQL 8.0.33 约 8 到 15 秒,但能抓到真实 SQL 问题。
我的做法是:80% 规则用纯单测,15% 契约用 Testcontainers,5% 冒烟用 @SpringBootTest。
不是标准答案,是我们踩出来的比例。
三、覆盖率定多少?核心 85% 分支,普通 60%,DTO 0%
行覆盖和分支覆盖不是一回事。 我见过行覆盖 100%、分支覆盖只有 58% 的模块,因为异常分支没测。 JaCoCo 我一般这样配:支付、权限、计价核心包分支覆盖 85% 到 95%;普通 CRUD 50% 到 70%;DTO、getter、配置类排除。 增量覆盖率更靠谱:新代码 80%,老代码不强制。 别把覆盖率当 KPI,它只是手电筒。 真要卡,就卡“核心分支 + 边界值”。 比如折扣规则,我会用 @ParameterizedTest 跑 0、1、99、100、101、9999 这几组数,比写十个 happy path 有用。
四、一个订单折扣案例:从 7 个 mock 到 1 个 Fake
2021 年做 SaaS 订单模块,DiscountCalculator 最初 mock 了 7 个依赖:Repository、Clock、Random、MQ、Redis、配置中心、支付网关。
重构后 80 个测试全红,因为实现类名改了。
后来改成构造注入,只保留 Clock 和支付网关两个接口。
时间用 Clock.fixed,随机用固定种子,Repository 用内存 Map 写 Fake。
测试从 90 秒降到 12 秒,而且重构不跟着挂。
步骤:1. 先列变化频率表,计价规则每天变,DAO 很少变;2. 高频变化写纯单测;3. 低频但易错 SQL 写 Testcontainers;4. 只 mock 外部 I/O;5. 断言可观测结果;6. CI 里单测和集成测试分两个 job。
五、常见问题快答:静态方法、私有方法、事务
静态方法怎么 Mock?Mockito 3.4+ 有 mockStatic,但我只在老代码没法改时用。
新代码把静态工具改成注入实例。
私有方法不要测,测它调用的公共行为。
事务回滚别用 Mockito 测,直接 Testcontainers + @Transactional 或手动清理。
H2 不是 MySQL,ON DUPLICATE KEY UPDATE 和窗口函数差异很大,我因此漏过一个排序 bug。
最后一句:单元测试不是写得多,是隔离变化。
变化越快、故障越贵的代码,越值得写细;其他地方,留点力气给集成测试和监控。