KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA
03 · 质量门禁金字塔:什么东西该挡在主分支之前 — keel 龙骨
这一章回答:检查那么多,怎么排顺序、卡在哪一层、哪些必须卡死哪些只能提醒。核心判断标准是四个字——反馈性价比。
这一章回答:检查那么多,怎么排顺序、卡在哪一层、哪些必须卡死哪些只能提醒。核心判断标准是四个字——反馈性价比。
一个 PR 从提交到合并,中间能插的检查有几十种。全卡死 → 没人愿意等,团队会想办法绕过流水线;全不卡 → 流水线沦为装饰。金字塔的价值是给出"谁必须在哪一层"的排序依据。
一、金字塔本体:按成本升序、按拦截范围降序
▲ 昂贵的、慢的、但能抓到深层问题的
┌──────────┴──────────┐
│ 端到端 / 负载 / 契约 │ 分钟~小时级,nightly 或发布前
├─────────────────────┤
│ 集成 / 数据库 / 外部服务 │ 需要真实依赖,容器或 mock
├─────────────────────┤
│ 单元测试 │ 秒级,全 PR 必跑
├─────────────────────┤
│ 类型检查 / 静态扫描(SAST) │ 秒~十秒级,无需运行
├─────────────────────┤
│ 格式化 / Lint / 依赖审计 │ 秒级,最便宜
└─────────────────────┘
排这个顺序的依据不是"哪个更重要",而是两个变量的比值:
| 检查 | 单次成本 | 典型反馈时间 | 抓到的问题类型 |
|---|---|---|---|
| 格式化 / Lint | 极低 | 秒 | 风格、明显的坏味道、未使用变量 |
| 类型检查 | 低 | 秒~十秒 | 接口契约不匹配、None 传播、遗漏分支 |
| 依赖 / 密钥扫描 | 低 | 十秒 | 已知 CVE、明文密钥进库 |
| 单元测试 | 中 | 秒~分钟 | 逻辑错误、边界条件、回归 |
| 集成测试 | 高 | 分钟 | SQL 与迁移、缓存语义、外部协议 |
| 端到端 / 契约 | 高 | 分钟~小时 | 服务间协议漂移、真实链路不可用 |
| 压力 / 长稳 | 很高 | 小时 | 泄漏、抖动、容量问题 |
排序原则:便宜且快的检查排在最前,能消灭最多的低级失败,把人的时间留给高级问题。 这就是为什么"忘记 import"、"类型对不上"这类问题绝不该出现在人工评审阶段——它们最便宜地被机器抓住,却最贵地被人类讨论。
二、必卡 vs 仅通知:一个可操作的决策表
| 门禁 | 是否阻塞合并 | 理由 |
|---|---|---|
| 格式化 / Lint | 阻塞(可自动修复) | 机器能修的东西不该让人讨论 |
| 类型检查 | 阻塞 | 契约错误在运行时才发现代价太大 |
| 单元测试 | 阻塞 | 这是最后一道廉价的防线 |
| 覆盖率下降超过阈值 | 阻塞 | 覆盖率绝对值没意义,趋势有意义 |
| 依赖高危 CVE | 分情况:有可用修复则阻塞;无修复则转 issue | 一刀切会让团队习惯性 --no-verify |
| 集成 / E2E | 阻塞(合并前至少跑冒烟子集) | 全量可放到夜跑 |
| 负载测试 | 不阻塞 PR,阻塞发布 | 分钟级的成本适配发布频率,不适配 PR 频率 |
| AI Review 意见 | 永远只建议 | LLM 会自信地给错建议;把它当评审员不给它否决权 |
最后一条是 AI 时代的新纪律:LLM 的意见是建议不是判决。原因见下。
三、不稳定的测试(flake)是最贵的债
一条看似结实的流水线,如果每周随机红三次,会发生三件事:人开始不看红了("再点一次重跑")、真错误被淹没、新人对流水线失去信任。三者叠加的后果是流水线事实上失效,却还在消耗 CI 分钟数。
治理做法:
| 手段 | 落地方式 | 副作用 |
|---|---|---|
| 自动重跑失败用例 1 次 | pytest-rerunfailures / CI 层 retry |
掩盖真 flake,必须配检测报告 |
| 隔离而不是删除 | 给 flaky 用例打 @pytest.mark.flaky,挪到独立 job |
长期不修会变成"永久隔离区" |
| 设修复预算 | 每周固定配额修复 flake,超期自动 disable 并建 issue | 需要有人负责,否则照样失效 |
| 记录 flake 率 | 同一 commit 多次运行结果不一致的比例 | 指标本身要能查,否则无从判断是否在恶化 |
面试里说"我们治理过 flake"比"我们加了很多测试"更能体现工程成熟度——前者说明你真的长期运营过一条流水线。
四、覆盖率的含义与四个陷阱
覆盖率回答的是"哪些代码被执行过",不是"哪些代码被验证过"。
| 陷阱 | 表现 | 对策 |
|---|---|---|
| 只为行覆盖写断言 | assert result is not None 蹭到的行,断言无意义 |
评审时看断言强度,不看百分比 |
| 生产代码反向适配测试 | 为了好 mock 把设计改丑 | 契约测试优于大量 unit mock |
| 整体覆盖掩盖热点 | 60% 但核心支付模块 0% | 关键模块设更高阈值,全局阈值没意义 |
| 忽略分支覆盖 | if 只有一条分支被跑过仍算覆盖 |
看 branch coverage 指标 |
进阶手段是变异测试(mutation testing):工具(如 mutmut / cosmic-ray)自动往源码注入小变异(把 < 改成 <=),如果测试仍然全绿,说明这个分支没被真正断言。它是唯一能直接度量"测试有没有用"的手段,代价是耗时,适合夜跑或对核心模块做。
五、PR 体积:AI 时代被严重低估的一个门禁
DORA 的 2025 研究反复强调小批量是释放 AI 收益的关键能力之一;反面也很清楚——LLM 乐于一次性输出跨几十个文件的大改动,而人的评审能力在超过 ~400 行后急剧下降。
可行的轻量门禁:
| 门禁 | 阈值(可自定义) | 处理 |
|---|---|---|
| 单次 PR 改动行数 | > 500 行 warn,> 1000 行请求拆分说明 | 用 label 标记 needs-split |
| 单次 PR 涉及文件数 | > 30 提示 | 往往意味着"顺手重构"混进了功能改动 |
是否同时改了 *.lock/依赖清单与业务代码 |
是 → 要求拆成两个 PR | 依赖升级有独立回滚需求 |
| 是否包含生成物 | 是 → 拒绝 | 生成物不该进版本库 |
这条门禁的本质是把"评审可行性"变成可执行的规则。 AI 让写大改动的成本趋近于零,所以"人不至于被撑死"必须由流水线来保护。
六、分支保护与合并队列
主分支 ── 要求:所需检查全部通过 + 至少 1 人评审 + 分支最新 + 线性历史
↑
└── required checks:把 03 章的阻塞级门禁列进来
- Required checks 必须收敛:把夜跑任务也设为 required,等于禁止任何人合并。
- 合并队列(merge queue):多个 PR 各自在临时分支上与最新 main 重跑一遍再合,消除了"两个 PR 各自绿、合起来红"的经典问题。CI 时间贵但对不允许破窗的仓库值得。
- 禁止绕过路径:管理员也要走同一条路,
admin-enforced这类设置存在。
动手:可观察结果
| 动作 | 产出物 | 判断标准 |
|---|---|---|
| 给项目排一张金字塔 | 各检查的耗时表 | 能算出"从 push 到拿到第一条错误"的中位时间 |
| 给 coverage 分组设两套阈值 | fail_under + 关键模块单独阈值 |
故意删一个核心用例,看到 CI 红而非整体 |
| 连续 10 次跑同一 commit 的测试 | flake 率 | 数值可查;> 5% 就该启动治理 |
| 对一个 300 行模块做变异测试 | 存活变异体清单 | 存活率低说明测试有效;存活高说明只是跑过代码 |
| 加 PR 体积门禁 | label 自动打 | 造一个 800 行的 PR,看是否被标记 |
故障注入
| 注入方式 | 观察什么 | 说明的现象 |
|---|---|---|
| 提交一个依赖里有高危 CVE 的 PR | 流水线是红还是只是提示 | 只有"红了又被迫 --no-verify"或"转 issue"两种合理结果 |
让一个测试带随机性(random.random()) |
多次运行结果 | flake 如何被"重跑一次就好了"掩盖 |
| 把集成测试挪到 require 列表 | 合并等待时间 | 分钟级涨价会立刻体现在团队行为上(开始批量攒 PR) |
| 制造两个各自绿、合并后红的 PR | 是否出现 | 出现 → 缺合并队列或不要求"分支必须最新" |
自测题
- 金字塔的排序依据是什么?为什么不是"按重要性排序"?
- 覆盖率 80% 但测试无效,可能是哪三个原因?
- 变异测试能回答哪个覆盖率回答不了的问题?
- AI Review 的意见为什么不建议设为阻塞门禁?
- 两个 PR 各自通过、合起来失败,有哪两种流水线层面的解法?