FunTester 莫把覆盖率当成答案

FunTester · April 27, 2026 · 64 hits

写生产级代码时,我们都希望自己心里更有把握,确认代码的行为符合预期。实现这一点的主要手段之一,就是 单元测试。它本质上是一组小而自动化的校验,用来验证代码中的某个具体部分是否按预期工作。顺着这个话题,通常会冒出几个问题:我们到底需要多少测试?这些测试的价值有多大?一个常见答案是 测试覆盖率。它用来衡量,测试运行时到底执行了多少代码。

很多团队会把 测试覆盖率 当成质量闸门。一旦数值跌破某个阈值,新代码就不能合并。不过,这类阈值往往很随意:既要看起来足够安全,又要保证团队勉强做得到。75%、80%、85%,这些数字看着很熟悉,却很少真正有意义。

在把覆盖率当成质量信号之前,我们至少该先问清楚几件事:

  • 测试覆盖率 真实反映了什么?
  • 它作为指标到底有多大参考价值?
  • 某个覆盖率百分比,真的能保证代码已经被充分测试吗?

看起来很高的覆盖率

先来看一个简单例子。我们写一个函数,根据用户年龄和活跃状态计算折扣系数:

  • 未满 18 岁的用户,给 10% 折扣,系数是 0.9
  • 不活跃用户,给 20% 折扣,系数是 0.8
  • 既未满 18 岁又不活跃的用户,同时享受两种折扣,系数是 0.72
  • 其他用户不打折,系数是 1.0

对应代码如下。我这里选的是 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%。而且在写测试时,我不打算先去分析函数内部实现。虽然这个例子很简单,但不妨假设真实场景里的函数足够复杂,复杂到很难靠人工完整推导。这时我们就很容易把希望寄托在 代码覆盖率 上。

如果把测试用例一个个加进去,再观察覆盖率变化,会看到下面这个现象:

  • 没有测试时,覆盖率是 0,这很正常
  • 只有一个测试,也就是 Test Case 1 时,覆盖率已经达到 100%。这个结果乍看很合理,因为两个 if 语句里的语句都跑到了。但从需求角度看,其他场景其实还完全没测到
  • 继续补充更多测试后,覆盖率不会再涨,依旧是 100%

这就暴露出问题了。如果我把 测试覆盖率 当成核心指标,又设置了一个 100% 的阈值,那么在代码复杂、人工分析又不充分的情况下,我很容易产生错觉,以为这个函数已经被很好地测试过了。实际上,它离真正测全还差得很远。

再往下挖一层

要解释这个现象,可以把代码映射成调用路径,再看每个测试到底覆盖了哪些分支。

Test Case 1 为例,它实际只覆盖了整条调用路径中的 25%。这时候问题就很清楚了:如果想把这个函数真正测全,我们要覆盖所有可能路径,而这个例子里刚好需要 4 个测试用例。

说到这里,你大概也能猜到,代码覆盖率 其实不止一种:

  • 语句覆盖率:衡量每条语句是否被执行过。前面的例子里,我们测的就是它。哪怕只有一个测试,也能做到 100% 语句覆盖
  • 分支覆盖率:衡量控制结构里的每个分支,也就是 ifswitchfor 等结构中的 truefalse 分支,是否都被执行过。在这个例子里,Test Case 1 只覆盖了 4 个分支中的 2 个,因此分支覆盖率是 50%
  • 路径覆盖率:衡量所有可能执行路径是否都跑到过。这个例子里共有 4 条路径,因此 Test Case 1 只能覆盖其中 1 条,也就是 25%

顺便补一句,这个折扣函数如果只想做到 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。但如果从输入组合去看,abc 实际上有多种搭配。把条件拆开后,这件事会更直观:

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% 路径覆盖,也不能说明系统在生产环境里一定没问题。配置错误、网络故障、硬件异常、第三方依赖波动,这些问题都不会因为单元测试写得多就自动消失。

所以,单元测试只是工具箱里的一件工具。除了它,我们还需要 集成测试、端到端测试,以及其他更贴近真实运行环境的验证手段。这些建议多少带一点个人立场,不过我自己在项目里一直是这么做的,而且实践下来确实很有帮助。更具体一点,覆盖率更适合被当成 告警信号,而不是最终答案:它能提醒我们哪里还没测到,却替代不了测试设计本身。


FunTester 原创精华
如果觉得我的文章对您有用,请随意打赏。您的支持将鼓励我继续创作!
No Reply at the moment.
需要 Sign In 后方可回复, 如果你还没有账号请点击这里 Sign Up