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 覆盖率通常没有统计价值。

覆盖率的三个盲区

  1. 执行 ≠ 断言:测试调用了函数但没检查结果,覆盖率满分、缺陷照漏。
  2. 分支组合爆炸:两个独立 if 有 4 种组合,覆盖率只要求每行执行一次,2 种就能 100%。
  3. 错误路径最难覆盖: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:改一个未测分支,确认门禁会红。

关联