写生产级代码时,我们都希望自己心里更有把握,确认代码的行为符合预期。实现这一点的主要手段之一,就是 单元测试。它本质上是一组小而自动化的校验,用来验证代码中的某个具体部分是否按预期工作。顺着这个话题,通常会冒出几个问题:我们到底需要多少测试?这些测试的价值有多大?一个常见答案是 测试覆盖率。它用来衡量,测试运行时到底执行了多少代码。
很多团队会把 测试覆盖率 当成质量闸门。一旦数值跌破某个阈值,新代码就不能合并。不过,这类阈值往往很随意:既要看起来足够安全,又要保证团队勉强做得到。75%、80%、85%,这些数字看着很熟悉,却很少真正有意义。
在把覆盖率当成质量信号之前,我们至少该先问清楚几件事:
先来看一个简单例子。我们写一个函数,根据用户年龄和活跃状态计算折扣系数:
对应代码如下。我这里选的是 Go,不过语言本身并不重要:
package main
// 根据年龄和活跃状态计算最终折扣系数。
func CalcDiscount(userAge int, isUserActive bool) float64 {
// 默认不打折,折扣系数从 1.0 开始累乘。
discountMul := 1.0
// 未成年人先叠加一层 10% 折扣。
if userAge < 18 {
discountMul *= 0.9 // 10% discount for minors
}
// 不活跃用户再叠加一层 20% 折扣。
if !isUserActive {
discountMul *= 0.8 // 20% discount for inactive users
}
// 返回最终折扣系数。
return discountMul
}
这个函数确实很简单,不过这正是我想要的效果。例子越简单,问题越容易看清。后面我们会基于这段代码,继续拆测试用例到底该怎么设计。接着,我们给这个函数写几组测试。既然这篇文章要讨论 测试覆盖率,那就顺手把覆盖率也测出来:
package main
import (
// fmt 用来拼接子测试名称,方便区分不同输入组合。
"fmt"
// math 用来比较浮点数差值,避免直接比较带来误判。
"math"
// testing 提供 Go 的测试能力。
"testing"
)
// 用表驱动测试一次性覆盖多组输入场景。
func TestCalcDiscount(t *testing.T) {
// 每个测试用例包含输入和期望输出。
type TestCase struct {
UserAge int
IsUserActive bool
Expected float64
}
// 四组用例刚好对应四种年龄与活跃状态组合。
testCases := []TestCase{
// Test Case 1 - user age < 18 and user is inactive
{UserAge: 17, IsUserActive: false, Expected: 0.72},
// Test Case 2 - user age ≥ 18 and user is active
{UserAge: 30, IsUserActive: true, Expected: 1.0},
// Test Case 3 - user age < 18 and user is active
{UserAge: 17, IsUserActive: true, Expected: 0.9},
// Test Case 4 - user age ≥ 18 and user is inactive
{UserAge: 25, IsUserActive: false, Expected: 0.8},
}
// 逐个执行测试用例。
for _, testCase := range testCases {
// 重新绑定循环变量,避免闭包拿到同一个引用。
testCase := testCase
// 用年龄和活跃状态拼出更直观的子测试名称。
testCaseName := fmt.Sprintf(
"%d-%t",
testCase.UserAge,
testCase.IsUserActive,
)
// 每组输入作为一个独立子测试运行。
t.Run(testCaseName, func(t *testing.T) {
// 调用被测函数,拿到实际结果。
actual := CalcDiscount(testCase.UserAge, testCase.IsUserActive)
// 浮点数比较通常看误差,而不是直接用等号。
delta := math.Abs(actual - testCase.Expected)
// 允许的最小误差范围。
const epsilon = 0.00001
// 如果误差超出阈值,说明结果不符合预期。
if delta > epsilon {
t.Errorf("Expected %v, got %v", testCase.Expected, actual)
}
})
}
}
我会逐步增加测试用例,并在每次增加后重新测一遍覆盖率:
$ go test -covermode=count -coverprofile=/dev/null
PASS
coverage: 100.0% of statements
ok github.com/kapitanov/personal-blog/posts/code-examples/code-1 0.211s
这里先假设我的目标,是尽快把覆盖率做到 100%。而且在写测试时,我不打算先去分析函数内部实现。虽然这个例子很简单,但不妨假设真实场景里的函数足够复杂,复杂到很难靠人工完整推导。这时我们就很容易把希望寄托在 代码覆盖率 上。
如果把测试用例一个个加进去,再观察覆盖率变化,会看到下面这个现象:
if 语句里的语句都跑到了。但从需求角度看,其他场景其实还完全没测到这就暴露出问题了。如果我把 测试覆盖率 当成核心指标,又设置了一个 100% 的阈值,那么在代码复杂、人工分析又不充分的情况下,我很容易产生错觉,以为这个函数已经被很好地测试过了。实际上,它离真正测全还差得很远。
要解释这个现象,可以把代码映射成调用路径,再看每个测试到底覆盖了哪些分支。
以 Test Case 1 为例,它实际只覆盖了整条调用路径中的 25%。这时候问题就很清楚了:如果想把这个函数真正测全,我们要覆盖所有可能路径,而这个例子里刚好需要 4 个测试用例。
说到这里,你大概也能猜到,代码覆盖率 其实不止一种:
if、switch、for 等结构中的 true 和 false 分支,是否都被执行过。在这个例子里,Test Case 1 只覆盖了 4 个分支中的 2 个,因此分支覆盖率是 50%
顺便补一句,这个折扣函数如果只想做到 100% 分支覆盖,其实只要两个测试就够了:一个是未满 18 岁且不活跃,另一个是已满 18 岁且活跃。但即便如此,它依然不能证明所有业务场景都已经验证到位。
多数测试工具默认提供的都是 语句覆盖率,少数工具会补充 分支覆盖率。但从这个例子里已经能看出来,这两类覆盖率都不能保证测试真的足够充分。理论上,只有 路径覆盖率 才更接近把所有场景都验证到。不过,现实里它真的做得到吗?再看一个例子:
package main
import "slices"
// 返回整数切片中的最小值。
// 这里约定空切片不是合法输入。
func MinOf(xs []int) int {
// 先挡住空切片,否则后面访问 xs[0] 会直接越界。
if len(xs) == 0 {
panic("empty slice")
}
// 原地排序后,最小值自然会落到第一个位置。
slices.Sort(xs)
// 返回排序后的首元素。
return xs[0]
}
这个函数表面上只有两个分支:空切片和非空切片。那是不是只要两个测试,一个传空切片,一个传非空切片,就够了?
答案并不是。因为 slices.Sort 自己内部也有分支和路径,而且这些实现细节并不受我们控制。更麻烦的是,它还是标准库函数,我们未必知道它内部怎么实现,甚至不同的小版本工具链里,这些实现都可能发生变化。
最后再看一个稍微绕一点的例子:
package main
// 这个条件把三项输入压缩在一个表达式里,不容易直观看出路径数量。
func ComplexCondition(a, b, c bool) bool {
// 只要 a 为 true,或者 b 和 c 同时为 true,就返回 true。
if a || (b && c) {
return true
}
// 其他情况统一返回 false。
return false
}
乍看之下,这段代码好像只有两条执行路径:条件成立时返回 true,不成立时返回 false。但如果从输入组合去看,a、b、c 实际上有多种搭配。把条件拆开后,这件事会更直观:
package main
// 把复合条件拆开后,更容易看清分支和执行路径。
func ComplexConditionExplicit(a, b, c bool) bool {
// 第一层判断:a 只要为 true,就可以直接返回。
if a {
return true
}
// 第二层判断:只有 a 为 false 时,才会继续检查 b。
if b {
// 第三层判断:b 为 true 时,还要再看 c。
if c {
return true
}
// b 为 true 但 c 为 false,最终结果是 false。
return false
}
// a、b 都不满足时,也只能返回 false。
return false
}
这样一展开就能看出来,要覆盖所有可能路径,我们需要的测试数,往往比最开始直觉判断的更多。换句话说,就算你真的追到了 100% 路径覆盖,也不代表测试设计这件事已经变简单了。
整体来看,测试覆盖率 当然是一个有用指标。至少它能帮我们快速发现,哪些代码压根没有被测试碰到。但我们也必须承认它的边界:哪怕是 100% 的语句覆盖率,或者 100% 的分支覆盖率,也完全不能保证代码已经被充分测试。说得直接一点,它离这个目标还差得很远。
另一方面,100% 的路径覆盖率在真实项目里通常又不现实。就算某段代码一度达到了,代码一改,测试也很容易跟着失效。
所以,我更倾向于遵循下面这条规则:
当你的可测试代码还没有做到 100% 覆盖时,说明你一定还有未测试代码;当你的可测试代码已经做到 100% 覆盖时,也依然可能存在未测试代码。
围绕这条规则,我会给出几条更实用的建议。
先找出代码库里真正关键的部分,再决定测试资源投到哪里。很多基础设施代码、胶水代码,未必值得单独写 单元测试。
比如,日志初始化、HTTP 服务启动这类逻辑,很多时候更适合放到集成测试里顺带验证。相反,真正承载业务规则的代码,通常才是最值得重点测试的部分。因此,更合理的做法是:只统计关键可测试模块的覆盖率。哪怕别的模块被间接执行到了,也不要让它们稀释这个数字的含义。
低于 100% 的覆盖率,至少说明一件事:还有代码没有被测到。从这个角度看,覆盖率依然有价值。但要注意,做到 100% 也只意味着你把相关代码跑到了,不意味着测试场景设计已经合理。所以更稳妥的理解是:覆盖率应该尽量追满,但不能把它当成结论本身。
真实项目里,你很难永远做到完美覆盖。有些代码天然就不太适合测,比如 Go 里那些到处都是的 if err != nil { return err } 分支。为了把这类代码也强行补齐,往往要写很多脆弱、维护成本又高的测试,而这些测试未必真有业务价值。
所以,没必要为了数字漂亮而透支时间。但如果你的覆盖率是 95%,那也别自我安慰。它至少说明,还有一部分代码没被测到。真正重要的,不是这个数字本身,而是你能不能说清楚:哪些代码没测,为什么没测,这个取舍是否合理。
像 75%、80%、85% 这样的阈值,看着很正式,实际意义往往不大。把阈值调到 100% 也一样,并不会自动带来高质量测试。同理,给不同模块设不同阈值,或者规定覆盖率只能涨不能跌,本质上也容易把大家引向为了数字而优化,而不是为了风险而测试。
哪怕你真的以某种方式做到了整个代码库的 100% 路径覆盖,也不能说明系统在生产环境里一定没问题。配置错误、网络故障、硬件异常、第三方依赖波动,这些问题都不会因为单元测试写得多就自动消失。
所以,单元测试只是工具箱里的一件工具。除了它,我们还需要 集成测试、端到端测试,以及其他更贴近真实运行环境的验证手段。这些建议多少带一点个人立场,不过我自己在项目里一直是这么做的,而且实践下来确实很有帮助。更具体一点,覆盖率更适合被当成 告警信号,而不是最终答案:它能提醒我们哪里还没测到,却替代不了测试设计本身。