53.10 回归测试与缺陷管理

一句话

回归测试的目标不是“把历史用例全跑一遍”,而是用最小集合覆盖本次变更的影响面;缺陷管理的目标不是“记录 bug”,而是让每个逃逸缺陷都变成一条以后一定会跑的新用例。

覆盖率只说明“执行过”,不等于“回归住了”(覆盖率门禁见 53.8 覆盖率与质量门禁)。

先破除五个误区

误区 现实
“回归 = 全量重跑” 1200 条跑 25 分钟,PR 反馈被拖死。全量放夜间/发布前,PR 只跑影响面
“bug 数越少越好” 数量可以被“不报/合并/降级”做低。要看逃逸率、重开率、修复时长
“严重程度 = 优先级” 严重程度是影响面,优先级是排期;S1 也可能因为已有缓解而排到下周
“修完就关” 没有回归用例,同一个 bug 下个版本还会回来;关闭条件应包含“有用例守住”
“重复 bug 直接关” 不关联根因,重复会继续产生;重复单是“同一根因”的信号,不是噪声

回归跑什么:按影响面选,而不是全量

策略 选多少 漏测风险 用在哪
全量 100% 0 夜间、发布前
变更影响面 5–15% 中 每个 PR
风险加权(核心 + 近期改动) 10–20% 中低 每个 PR
随机抽样 10% 高 不推荐

“影响面”从哪来:源码依赖图(改了 order/ 就跑依赖它的测试)、历史失败关联(这个文件过去常坏)、owner 标注。映射表会过期,过期时回退到全量,不要假装精准。

一个示例账(数字为示例,不是定律):

套件 条数 时长 放在哪
全量回归 1200 25 min 夜间 / 发布前
影响面选择 80 2 min 每个 PR
冒烟 12 1 min 合并后

全量里 3% 的缺陷是“影响面没选中、夜间才抓到”的——这个比例要持续度量;超过 5%,说明映射表或选择规则该修了。

缺陷生命周期:每一步都要有“退出条件”

New → Triaged → In Progress → Fixed → Verified → Closed
        │                       │        │
        └── 拒绝/重复 ──────────┘        └── 复现 → Reopened
状态 退出条件
New 有复现步骤、影响版本、日志/截图
Triaged 定了严重程度、优先级、owner、目标版本
In Progress 有根因假设或修复方案
Fixed 代码合并 + 新增回归用例(先红后绿)
Verified 在原环境/预发验证通过,用例进回归集
Closed 验证通过且一个发布周期无重开

🔑 关闭缺陷的充分条件不是“代码改了”,而是“有一条用例能在不改代码时红、改完后绿”。 没有这条用例,就不算修完。

严重程度 vs 优先级

P0 立即 P1 本迭代 P2 排期
S1 资损/全站不可用 立即止血 立即 本迭代
S2 核心功能受损/有绕过 立即 本迭代 排期
S3 非核心功能/体验 本迭代 排期 排期
S4 文案/建议 排期 排期 backlog

S1 不一定 P0:如果已有开关可关、影响面仅限内部,优先级可以降;S4 也可能因为对外承诺变成 P1。severity 由影响决定,priority 由业务和时间决定,两者分开评审。

可运行的回归门禁

把“上一次已知良好”的结果与本次结果对比,只对新增失败返回非 0:

package main

import (
	"encoding/json"
	"flag"
	"fmt"
	"os"
	"sort"
)

type result struct {
	Name   string `json:"name"`
	Status string `json:"status"` // passed / failed / skipped
}
type report struct {
	Tests []result `json:"tests"`
}

func load(path string) (map[string]string, error) {
	var r report
	b, err := os.ReadFile(path)
	if err != nil {
		return nil, err
	}
	if err := json.Unmarshal(b, &r); err != nil {
		return nil, err
	}
	m := make(map[string]string, len(r.Tests))
	for _, t := range r.Tests {
		m[t.Name] = t.Status
	}
	return m, nil
}

func main() {
	baselinePath := flag.String("baseline", "baseline.json", "上一次已知良好的结果")
	currentPath := flag.String("current", "current.json", "本次结果")
	flag.Parse()

	base, err := load(*baselinePath)
	if err != nil {
		fmt.Fprintln(os.Stderr, "读取基线失败:", err)
		os.Exit(2)
	}
	cur, err := load(*currentPath)
	if err != nil {
		fmt.Fprintln(os.Stderr, "读取本次结果:", err)
		os.Exit(2)
	}

	var newFail, stillFail, fixed, skipped []string
	for name, status := range cur {
		old := base[name]
		switch {
		case status == "failed" && old != "failed":
			newFail = append(newFail, name)
		case status == "failed":
			stillFail = append(stillFail, name)
		case old == "failed" && status == "passed":
			fixed = append(fixed, name)
		case status == "skipped":
			skipped = append(skipped, name)
		}
	}
	sort.Strings(newFail)
	sort.Strings(stillFail)
	sort.Strings(fixed)

	fmt.Printf("新增失败 %d,仍失败 %d,已修复 %d,跳过 %d\n",
		len(newFail), len(stillFail), len(fixed), len(skipped))
	for _, n := range newFail {
		fmt.Println("  NEW FAIL ", n)
	}
	for _, n := range stillFail {
		fmt.Println("  STILL    ", n)
	}
	for _, n := range fixed {
		fmt.Println("  FIXED    ", n)
	}
	if len(newFail) > 0 {
		os.Exit(1)
	}
}

baseline.json(上一次 last green 的结果):

{"tests":[
  {"name":"TestPay","status":"failed"},
  {"name":"TestCoupon","status":"failed"},
  {"name":"TestLogin","status":"passed"}
]}

current.json(本次结果):

{"tests":[
  {"name":"TestPay","status":"failed"},
  {"name":"TestCoupon","status":"passed"},
  {"name":"TestLogin","status":"passed"},
  {"name":"TestCart","status":"failed"}
]}
$ go run . -baseline baseline.json -current current.json
新增失败 1,仍失败 1,已修复 1,跳过 0
  NEW FAIL  TestCart
  STILL     TestPay
  FIXED     TestCoupon
exit 1

读取的是“上一次已知良好(last green)”的基线,不是“上一次运行”。否则一个偶发失败下次变绿,会被当成“已修复”,噪声被洗白。

度量:看逃逸与修复,不看 bug 数

指标 定义 用途
逃逸缺陷率 上线后发现的缺陷 / 总缺陷 回归集是否有效
重开率 Reopened / Closed 修复质量
平均修复时长(MTTR) 从 Triaged 到 Verified 流程瓶颈
回归套件时长 全量 + PR 选择集 反馈速度
flaky 率 重跑才通过的用例占比 门禁可信度
新增失败数 每次 PR 对比基线 回归门禁信号

不要把“bug 总数”当团队 KPI:它可以通过不报、合并、降级来优化,最终让数据好看、系统更糟(古德哈特定律,见 59.3 度量的陷阱)。

生产落地:指名 → 可抄 → 可验 → 可退

指名:结果格式用 JUnit XML(go-junit-report 可从 go test -json 转换);缺陷跟踪用 Jira/Linear/GitHub Issues;回归选择可用 **Bazel 的 target 依赖图(如 bazel-diff)**或自建“依赖图 + owner”映射。替代:先用“目录 → 测试包”的粗映射,跑一周再细化。

可抄:

go test -json ./... > test.json
go-junit-report < test.json > junit.xml   # CI 消费
go run ./cmd/regress -baseline baseline.json -current current.json

可验:把 TestCart 从 failed 改回 passed,门禁应返回 0;把 TestPay 从 failed 改成 passed 而基线仍是 failed,输出应出现 FIXED TestPay。这两条能证明“新增失败才阻断”的逻辑没写反。

可退:

  • 影响面选择漏测:立即回退全量,查映射表;不要用“多跑几条”掩盖规则错误。
  • 新门禁误杀(flaky):先隔离 flaky(见 52.4 流水线设计与耗时优化 的抖动部分),再恢复阻断;不要直接删门禁。
  • 缺陷流程太重:先保留“Triaged 四要素 + 关闭必须有用例”,其余字段按需减。

反模式

反模式 后果
回归只增不删 套件无限膨胀,最后没人跑
用全量回归做 PR 门禁 25 分钟反馈,团队绕过
修完不补用例 同一 bug 反复回归
把重复单直接关掉 根因不修,重复继续产生
severity 当 priority 高影响低紧急的 bug 被无限延期
用 bug 数考核 藏 bug、拆单、降级,指标失真
基线取“上一次运行” flaky 被洗白成修复
跳过(skipped)不计入 门禁看起来绿,实际没跑

自检

Q1 1200 条回归跑 25 分钟,PR 等不起。怎么在不显著增加漏测的前提下压到 2 分钟?

▸ 答案:PR 只跑“影响面选择集”:按源码依赖图 + owner + 历史失败关联选出约 80 条(5–15%),2 分钟跑完;全量放夜间和发布前。关键是度量漏测:夜间抓到的、影响面没选中的缺陷比例要持续跟踪,超过 5% 就修映射规则或扩大选择集。不要用随机抽样,也不要把全量硬塞进 PR。

Q2 一个线上缺陷修完了,什么条件下才能关闭?

▸ 答案:至少三条:① 有一条回归用例,在修复前能红、修复后能绿;② 在原环境/预发验证通过;③ 用例已进回归集,且一个发布周期内没有重开。只满足“代码合并”不够——没有用例守住,下个版本很可能再犯。关闭条件写进缺陷模板,评审时检查;复盘方法见 56.6 无指责复盘。

Q3 严重程度和优先级为什么必须分开?

▸ 答案:严重程度描述影响(资损、不可用、核心功能、体验),优先级描述排期(现在、本迭代、以后)。两者不总是一致:S1 如果有开关可止血,优先级可以降;S4 如果是对外承诺,也可能插队。混在一起会导致“高严重低紧急”的缺陷被无限延期,或者“低严重高紧急”的琐事挤掉关键修复。

Q4 回归门禁的基线应该取哪一次结果?

▸ 答案:上一次**已知良好(last green)**的主分支结果,而不是上一次运行。取上一次运行会把偶发失败洗白:上次红、这次绿就被当成“已修复”,实际只是 flaky。基线还要绑定代码版本/环境;跨版本比较会制造大量假新增失败。

Q5 为什么不能把 bug 数量当团队 KPI?

▸ 答案:因为数量可以被“不报、合并重复、降级、拆单”做低,而系统风险不变甚至更高。这是古德哈特定律:指标一旦成为目标,就不再是好指标。更好的度量是逃逸缺陷率、重开率、MTTR、回归套件时长和 flaky 率——它们更难被单方面“优化”,且直接对应质量与交付。

关联