53.8 覆盖率与质量门禁
一句话
覆盖率是“哪些代码没被跑到”的探照灯,不是“测试写得好不好”的分数;门禁应该卡新增代码覆盖 + 关键模块分支 + 变异分数,而不是全仓一个百分比。
先破除五个误区
| 误区 | 现实 |
|---|---|
| “覆盖率 90% = 质量好” | 覆盖率只证明“执行过”,不证明“断言过”。一个测试把所有分支跑一遍、什么也不断言,也能 100% |
| “覆盖率低就是测试差” | 生成代码、平台适配、错误兜底会天然拉低;要按模块看,不要按全仓看 |
| “门禁设 80% 就安全” | 老代码 80% 能撑住总数,新代码 0% 也能过。全仓阈值对存量仓库基本无效 |
| “覆盖率只算行” | Go 原生只统计语句(statement);分支、条件、变异需要额外工具或人工设计用例 |
| “加测试就是为了提数字” | 没断言的测试、反射调用、t.Skip 都能刷数字;门禁会奖励错误行为 |
指标不止一种
| 指标 | 回答的问题 | Go 原生 | 盲区 |
|---|---|---|---|
| 语句覆盖 | 这行执行过吗 | ✅ -coverprofile | 分支/条件没走也算 |
| 分支覆盖 | 每个 if/switch 都走到了吗 | ❌(需第三方/手工) | 条件组合仍可能漏 |
| 条件覆盖 / MC/DC | 每个布尔子表达式都影响结果吗 | ❌ | 成本高,关键模块才做 |
| 变异分数 | 改坏代码,测试抓得住吗 | ❌(工具) | 慢,适合夜间 |
| 新增代码覆盖 | 本次 diff 的代码测了吗 | 组合工具 | 依赖 diff 解析正确性 |
Go 的 go test -cover 是语句覆盖;-race 时必须配 -covermode=atomic,否则报错。别把“行覆盖”当成“分支覆盖”。覆盖率只对真正执行代码的测试层有意义(单元/集成,见 53.2 单元测试、53.3 集成测试);E2E 覆盖率通常没有统计价值。
覆盖率的三个盲区
- 执行 ≠ 断言:测试调用了函数但没检查结果,覆盖率满分、缺陷照漏。
- 分支组合爆炸:两个独立
if有 4 种组合,覆盖率只要求每行执行一次,2 种就能 100%。 - 错误路径最难覆盖:
if err != nil的 return 往往没被测到,而线上恰恰走这里。
🔑 覆盖率最好的用法不是“打分”,而是找没被跑到的代码:打开报告,看红色区块,问“这块为什么没测”。
数字:阈值怎么定
Google 在《Software Engineering at Google》里给的经验区间:60% 可接受、75% 可称赞、90% 典范。这是经验值,不是行业标准;它成立的前提是“覆盖率只作为信号之一”。
收益递减同样明显:
| 覆盖区间 | 提升难度 | 典型收益 |
|---|---|---|
| 0 → 60% | 低 | 抓住主路径的低级错误 |
| 60 → 80% | 中 | 覆盖大部分分支与错误路径 |
| 80 → 90% | 高 | 边界与异常,需要设计用例 |
| 90 → 100% | 很高 | 大量防御性/不可达代码,收益最低 |
对存量仓库,正确做法是双阈值:全仓只防倒退(如不低于 50%),新增代码卡 80%,核心包单独卡 85–90%。
门禁设计:四层,而不是一个数
| 层级 | 建议阈值 | 作用 | 失败时 |
|---|---|---|---|
| 全仓总量 | 50–60%,且不允许下降 | 防整体倒退 | 阻断合并 |
| 新增代码(diff) | 80% | 主门禁,防止“老代码撑总量” | 阻断合并 |
| 核心包 | 85–90% | 支付/账务/权限等 | 阻断合并 |
| 变异分数 | 60–70%(夜间) | 验证断言质量 | 出报告,不阻断 |
“新增代码覆盖”是这套设计的核心:只看本次 diff 涉及的行,老代码的 80% 不再能掩护新代码的 0%。实现方式有两种:diff-cover 这类工具,或自建“diff 行号 ∩ coverprofile”。
门禁的工程化与静态检查同源(见 52.2 静态检查):只卡新增、给 canary、别让存量拖死;放在流水线哪一步、耗时怎么控,见 52.4 流水线设计与耗时优化。覆盖率上去了但回归集没更新,旧 bug 照样回来(见 53.10 回归测试与缺陷管理)。
可运行的覆盖率门禁
先看被测代码(只展示关键函数):
package cover
import "errors"
func Discount(total int, member bool) (int, error) {
if err := validate(total); err != nil {
return 0, err
}
return apply(total, member), nil
}
func validate(total int) error {
if total <= 0 {
return errors.New("invalid total")
}
return nil
}
func apply(total int, member bool) int {
switch {
case member:
return total * 90 / 100
case total >= 10000:
return total * 95 / 100
default:
return total
}
}
测试只覆盖“会员价”和“原价”,漏了 total <= 0 与 total >= 10000:
$ go test -coverprofile=cover.out .
ok example.com/cover coverage: 70.0% of statements
$ go tool cover -func=cover.out
example.com/cover/discount.go:5: Discount 66.7%
example.com/cover/discount.go:12: validate 66.7%
example.com/cover/discount.go:19: apply 75.0%
total: (statements) 70.0%
门禁工具读 cover.out,按文件和总量判定:
package main
import (
"bufio"
"flag"
"fmt"
"os"
"sort"
"strconv"
"strings"
)
func main() {
profile := flag.String("profile", "cover.out", "go test -coverprofile 产物")
min := flag.Float64("min", 80, "总覆盖率下限(百分比)")
flag.Parse()
f, err := os.Open(*profile)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(2)
}
defer f.Close()
byFile := map[string][2]int{} // [covered, total]
var covered, total int
sc := bufio.NewScanner(f)
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
if line == "" || strings.HasPrefix(line, "mode:") {
continue
}
// example.com/m/a.go:3.20,7.2 2 1
colon := strings.LastIndex(line, ":")
fields := strings.Fields(line[colon+1:])
if colon < 0 || len(fields) != 3 {
continue
}
n, _ := strconv.Atoi(fields[1])
count, _ := strconv.Atoi(fields[2])
v := byFile[line[:colon]]
v[1] += n
total += n
if count > 0 {
v[0] += n
covered += n
}
byFile[line[:colon]] = v
}
files := make([]string, 0, len(byFile))
for k := range byFile {
files = append(files, k)
}
sort.Strings(files)
for _, k := range files {
v := byFile[k]
fmt.Printf("%-40s %5.1f%% (%d/%d)\n", k, pct(v[0], v[1]), v[0], v[1])
}
got := pct(covered, total)
fmt.Printf("%-40s %5.1f%% (%d/%d)\n", "TOTAL", got, covered, total)
if got < *min {
fmt.Printf("gate: FAIL (%.1f%% < %.0f%%)\n", got, *min)
os.Exit(1)
}
fmt.Printf("gate: PASS (>= %.0f%%)\n", *min)
}
func pct(c, t int) float64 {
if t == 0 {
return 0
}
return float64(c) / float64(t) * 100
}
$ go run ./cmd/covergate -profile cover.out -min 80
example.com/cover/discount.go 70.0% (7/10)
TOTAL 70.0% (7/10)
gate: FAIL (70.0% < 80%)
exit 1
# 补上 total<=0 与 total>=10000 两个用例后
$ go test -coverprofile=cover.out .
ok example.com/cover coverage: 100.0% of statements
$ go run ./cmd/covergate -profile cover.out -min 80
TOTAL 100.0% (10/10)
gate: PASS (>= 80%)
变异测试:验证断言,而不是验证执行
覆盖率高但断言弱,最典型的例子是“边界差一”:
// 原实现
return total * 90 / 100
// 变异体
return total / 100 * 90
| 测试输入 | 原实现 | 变异体 | 测试能杀死变异体吗 |
|---|---|---|---|
total = 2000 | 1800 | 1800 | ❌ 不能 |
total = 1999 | 1799 | 1710 | ✅ 能 |
只用 2000 做用例,覆盖率高、变异体存活;加一个 1999,才真正验证了“先乘后除”的精度语义。变异分数 = 被杀死的变异体 / 总变异体,适合核心模块夜间跑(工具如 go-mutesting、gremlins,本机未安装验证)。
生产落地:指名 → 可抄 → 可验 → 可退
指名:Go 原生 go test -coverprofile + go tool cover;diff 覆盖用 diff-cover(Python 工具)或 CI 平台自带的 codecov/Sonar 能力;变异测试用 go-mutesting / gremlins。替代:先在核心包做人工分支清单,比上工具更快见效。
可抄:
go test -covermode=atomic -race -coverprofile=cover.out ./...
go tool cover -func=cover.out | tail -1
go run ./cmd/covergate -profile cover.out -min 80
可验:上面的 70% → FAIL、100% → PASS 就是可复现输出;接入 diff 覆盖后,再验一次“只改一个未测分支”会让门禁红。
可退:
- 新门禁误伤存量:先降为 report-only 一个迭代,同时把 diff 覆盖打开(只卡新增)。
- 生成代码拉低总量:用
//go:build或目录排除,不要靠“多写无断言测试”补。 - 变异测试太慢:只对核心包、每周夜间跑;不进 PR 门禁。
反模式
| 反模式 | 后果 |
|---|---|
| 全仓一个 80% 阈值 | 老代码撑总量,新代码裸奔 |
| 用覆盖率做个人/团队 KPI | 刷无断言测试、排除难测代码 |
| 追求 100% | 大量防御性/不可达代码,投入产出极低 |
| 只看行覆盖不看分支 | if err != nil 没测到,线上走的就是它 |
| 排除核心包 | 支付/权限覆盖率最低,风险最高 |
| 变异测试进 PR 门禁 | 跑几十分钟,团队直接绕过 |
| 覆盖率报告只看总数 | 不知道哪块红,无法行动 |
自检
Q1 覆盖率 95%,线上还是出了空指针。覆盖率骗人了吗?
▸ 答案:没有骗人,是解读错了。覆盖率只说明“这些语句执行过”,不说明“断言过”。空指针可能出现在一个被调用但返回值没被检查的路径上;测试执行了那行,却没验证 nil。解决不是继续提覆盖率,而是看变异分数/断言质量,并对错误路径显式设计用例。
Q2 存量仓库覆盖率 40%,怎么设门禁才不会被全员抵制?
▸ 答案:双阈值 + ratchet。全仓只要求“不低于当前值”(如 40% 且不下降),新增代码卡 80%,核心包单独 85%。这样老代码不被逼着补测,新代码不能裸奔;存量按季度排还债预算。直接设全仓 80% 的结果通常是:第一天红,第三天加 || true,门禁永久失效。
Q3 为什么 Go 的 -cover 不能当分支覆盖用?
▸ 答案:Go 原生 coverprofile 记录的是语句块是否执行,一个 if 的两个分支只要都包含语句,各执行一次就算覆盖;但条件组合、短路求值、边界值不一定被验证。要分支/条件覆盖需要额外工具或人工设计用例;Go 里更实用的是“关键模块的变异测试 + 边界用例”。
Q4 变异测试和覆盖率是什么关系?
▸ 答案:覆盖率回答“代码有没有被执行”,变异测试回答“断言有没有效”。高覆盖是变异测试的前提(没执行的代码,变异体必存活),但高覆盖不保证高变异分数。例如 total*90/100 改成 total/100*90,用 2000 做用例两者都是 1800,变异体存活;加 1999 才能杀死。变异测试慢,适合核心模块夜间跑。
Q5 新增代码覆盖率 80% 是怎么算的?最容易错在哪?
▸ 答案:把本次 diff 的新增/修改行与 coverprofile 的语句块求交集,统计这些块里被执行的语句占比。最容易错在 diff 解析:重命名、移动代码、生成文件、多包路径前缀都会让行号对不上;路径前缀(example.com/m/)与 git 路径不一致也会导致“0 行可算”,从而静默通过。必须加 canary:改一个未测分支,确认门禁会红。