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 率——它们更难被单方面“优化”,且直接对应质量与交付。