单元测试怎么写才不鸡肋?我踩过 7 个坑后,把 JUnit5/Mockito/Jest/pytest 的取舍和覆盖率阈值摊开
先说结论,单元测试不是越多越好,也不是“写了就行”。2019 年我在一个 SaaS 订单项目里,CI 从 12 分钟涨到 27 分钟,大家还在加测试,结果线上退款金额还是算错。我翻了 340 个测试,里面有 190 个在 mock 数据库,60 个在断 getter/setter,真正覆盖退款金额、优惠券叠加、时区边界的不到 30 个。后来我删了约 80 个假测试,加了 22 个围绕退款的参数化用例,CI 回到 14 分钟,退款相关 bug 从每月 3 到 4 个降到 0 到 1 个。这个数字不是行业标准,只是我那次改完后的体感。
我现在判断一个单元测试值不值得写,只看一件事:如果这段代码写错了,测试失败时我会不会立刻想修?如果不会,它大概率是装饰。测试金字塔也别背成 70/20/10 就完事,现实里更接近 60 到 70 的单元、20 到 30 的集成、5 到 10 的端到端。金额、权限、时间窗口、状态机放单元层;序列化、事务、缓存、SQL 放集成层;登录和支付主链路才放端到端。JaCoCo 行覆盖我先卡 70 到 80,分支 60 到 70;SonarQube 新代码质量门卡 80 行覆盖、70 分支覆盖。全仓别一刀切,老代码一上来就卡 80,只会逼人写假测试。
工具对比:JUnit5、Mockito、Jest、Vitest、pytest 到底差在哪
| 维度 | JUnit 5.10.2 + Mockito 5.11.0 | Jest 29.7.0 | Vitest 1.6.0 | pytest 8.2.0 |
|---|---|---|---|---|
| 参数化 | @ParameterizedTest + @CsvSource | test.each | test.each | @pytest.mark.parametrize |
| 时间控制 | Clock 注入 + @Timeout(2) | jest.useFakeTimers | vi.useFakeTimers | freezegun 1.5 + monkeypatch |
| 断言 | AssertJ 3.25.3 | expect | expect | assert + pytest.approx |
| mock 风格 | Mockito mock/when/verify | jest.mock | vi.mock | pytest-mock 3.14 |
| 常见坑 | 过度 verify 调用次数 | 快照太多、模块 mock 污染 | 配置和 Jest 不完全一样 | fixture 作用域乱套 |
JUnit5 里我最常用 @ParameterizedTest 加 @CsvSource,把金额边界写成表:0、0.01、99.99、100、1000、1000.01。别用 double 算钱,Python 用 Decimal('0.1'),Java 用 BigDecimal,JS 用整数分。Mockito 5.x 默认 inline mock maker,静态方法能 mock,但我只在旧代码里用,新代码更愿意包一层接口。Jest 的 fakeTimers 要显式设置 now,不然时区测试会飘;Vitest 在 Vite 项目里启动快,但别因为快就乱加 watch 测试。pytest 的 monkeypatch 很顺手,fixture 别搞成全局大杂烩,scope='function' 是默认但最稳,scope='session' 只给只读资源。
我现在的四步写法:从不可测到能回归
第一步,先找高频变更和边界。打开 git log,看最近 3 个月改得最多的 5 个文件,再找金额、权限、时间、重试次数。第二步,先写一个会失败的测试,红给你看。比如折扣计算,输入 100 元、优惠券 20、会员 9 折,预期 72;输入 99.99、满 100 减 20,预期 99.99。测试跑红,再改代码。第三步,解耦依赖,但别为测试把架构拆成 12 层。把 new Date() 换成注入 Clock,把 HTTP 调用放 adapter,把随机数包 RandomProvider。第四步,断言业务结果,不 verify 内部调用。比如别写 verify(couponService, times(1)).calculate(),而是断言最终应付金额和优惠明细。
举个 Python 例子,金额用 Decimal,参数化边界,避免 float。代码大概是:@pytest.mark.parametrize('price,coupon,expected', [(Decimal('100'), Decimal('20'), Decimal('80')), (Decimal('99.99'), Decimal('20'), Decimal('99.99'))]),然后 assert calc(price, coupon) == expected。Java 侧就是 @CsvSource({'100,20,80', '99.99,20,99.99'}),断言用 AssertJ 的 isEqualByComparingTo。JS 侧我用 Jest 时,异步接口一定 await expect(...).resolves.toEqual(...),别漏 await,不然测试永远是绿的。这四个步骤不高级,但能挡住我 80% 的低级错。
用户常搜的问题,我按自己的踩坑答一遍
问题一:mock 越多越好吗?不是。一个测试里 mock 超过 5 个,通常说明这个类干了太多事。先拆职责,再谈测试。问题二:私有方法怎么测?通过公有行为测。反射测私有方法,重构时第一个碎。问题三:覆盖率 100% 还漏 bug?正常,行覆盖不等于分支覆盖,分支覆盖也不等于逻辑正确。上变异测试,Java 用 PIT 1.15.0,JS 用 Stryker 8.2,Python 用 mutmut 3.x,变异得分 60 到 80 比行覆盖 100 更有信息量。问题四:单测跑得慢?JUnit5 开 parallel.enabled=true,pytest 用 pytest-xdist 跑 -n auto,Jest 设 --maxWorkers=50%,CI 分片。问题五:时间随机怎么测?注入 Clock,freezegun,fake timers,随机数用固定 seed。问题六:数据库和 HTTP 怎么办?单元测试别碰真库,集成测试用 Testcontainers 1.19,HTTP 用 WireMock 3.x 或 MockWebServer 4.12。问题七:遗留代码怎么加?先给最近 3 个线上 bug 写回归测试,再重构。
独立观点:把单元测试当可失败性保险,不是 KPI
我的观点可能不讨喜:覆盖率是体温计,不是体检报告。你发烧不代表病好了,你 100% 覆盖也不代表逻辑对。测试要能失败,删掉生产代码一行,测试应该红;如果删了还不红,那就是装饰。我宁愿 300 个高价值测试跑 4 分钟,也不要 1200 个假测试跑 18 分钟。团队规则我会这么定:新代码 80 行覆盖、70 分支覆盖;核心金额和权限 90 行覆盖加变异测试 70 分;UI 快照不超过 20 个,超过就删。每次改价格、权限、退款,先看有没有测试,没有就手痒。不是因为我爱测试,是因为我赔过钱,真的。
工具清单和命令,拿去就能改
Java:mvn test 跑单测,mvn verify 跑 JaCoCo 0.8.11,PIT 1.15.0 做变异。JUnit 5.10.2、Mockito 5.11.0、AssertJ 3.25.3 先升到这些稳定版本。JS/TS:Jest 29.7.0 的 coverageThreshold 把 global branches 设 70、functions/lines/statements 设 80;Vitest 1.6.0 看 Vite 版本配。Python:pytest 8.2.0 加 pytest-cov,命令 pytest -q --cov=src --cov-report=term-missing --cov-fail-under=75,并行 pytest -n auto。Go:go test ./... -race -coverprofile=cover.out -covermode=atomic。最后提醒一句,别一次全改,先挑 1 个模块,把最近 3 个 bug 变成 3 个测试,两周后回头看 CI 时间。如果 CI 超过 10 分钟,先删假测试,再谈加测试。