全量跑不动、人肉挑不完:代码量涨8倍之后,测试选择策略该先改哪一步

举报
霍格沃兹测试学社 发表于 2026/09/24 17:53:33 2026/09/24
【摘要】 假设你团队里的AI编码搭档干了一整夜:第二天一早,四十多个PR同时挤进流水线,CI从提交到出结果排起三个小时的队。数字是打的比方,但方向不是危言耸听——当写代码的不再只有人,“每条PR跑全量测试”这条CI铁律,正在以肉眼可见的速度失效。真实的样本来了。Anthropic工程师Sachin Malhotra在2026年9月14日的官方博客里公开了一组数字:Claude编写了公司80%的合并代码...

假设你团队里的AI编码搭档干了一整夜:第二天一早,四十多个PR同时挤进流水线,CI从提交到出结果排起三个小时的队。数字是打的比方,但方向不是危言耸听——当写代码的不再只有人,“每条PR跑全量测试”这条CI铁律,正在以肉眼可见的速度失效。

真实的样本来了。Anthropic工程师Sachin Malhotra在2026年9月14日的官方博客里公开了一组数字:Claude编写了公司80%的合并代码;工程师人均每季度合并的代码量,是2021—2025基线的8倍;测试数量涨到10倍;CI任务量半年内暴涨25倍。而他们用来“只跑受影响测试”的那套测试影响分析(Test Impact Analysis,TIA)服务,恰恰成了25倍洪峰里最先垮掉的一环:三次续命补丁一次比一次撑得短,最后推倒重构。

一句交代:本次写作时,原文页面直连抓取超时。下文涉及的架构细节与数字,取自对该文的详实转述,并经多家媒体的标题层信息交叉印证;官方博客原文未能逐字核验,特此说明。

image.png

一、这套系统原本长什么样:一个有状态的“记仇本”

Anthropic这套TIA服务分两个部件。Listener记录每次CI运行的每个测试结果,按代码变更不断积累“哪个测试在哪次改动之后通过或失败”的历史;Selector读这份历史,给定一个PR的diff,决定这个改动到底要跑哪些测试。

问题出在起点。按原始设计,测试结果基本由单一写入者按序处理——单写者、有状态,全部历史攒在一个服务的进程里。这个前提在小流量时代完全成立;可当CI任务量半年翻25倍,这个“记仇本”就成了整条流水线的漏斗口。

四组数字各自在增长什么,值得拆开看:

数字 口径(转述原文) 增长的是谁 最先撞上的瓶颈
80% Claude编写的合并代码占比 代码的作者结构 PR数量不再受人类打字速度约束,触发量失控
8倍 工程师人均每季度合并代码量,对比2021—2025基线 人的产出速率 构建与测试任务总量
10倍 测试数量 测试资产本身 单次流水线时长、失败重跑面
25倍 CI任务量(半年内) 以上三条叠加的总账 记录与选择测试结果的服务本身

注意最后一行:压垮的不是测试,是“管理测试的那个系统”。这正是很多团队规划CI时的盲区——只预算跑测试的机器,不预算记录测试结果的系统。

二、三次续命补丁:70天、29天、不到1天

面对25倍,团队最初做的全是“标准操作”:加机器、分片、重启。转述原文给出的时间衰减曲线,比任何容量规划课都刺眼:

补丁 做法 撑了多久 失效原因 对测试侧的含义
① 给Listener换更大的机器 约70天 单机吞吐天花板还在,流量按复利来 纵向扩容对指数增长只是买时间
② 按package分片、一片一worker 约29天 分片负载不均,热点package先垮 静态分片假设分布稳定,AI生成的代码不信这个假设
③ 每日定时重启绕内存上限 不到1天,积压越滚越大 内存是症状,状态无处安放才是病根 用运维动作掩盖架构问题,代价是数据缺口静默扩大

三次补丁有一个共同点:全是线性手段——更多的核、更多的片、更勤的重启——去对付指数压力。70天→29天→不到1天的衰减不是运气差,是数学:每次补丁买来的余量,都会在下一个增长周期被同一斜率原样吃掉。

这就是“为什么传统CI做法会漏”的完整证据链:全量跑不是不忠实,是扛不住测试10倍、任务25倍的增长;人肉挑测试不是没经验,是经验更新的速度追不上PR生成的速度;而给CI系统打线性补丁,等于用上周期的想象力规划下周期的容量。

image.png

三、重构:状态搬进数据库,写入端从此无状态

最终方案是把“进程内的记仇本”改成数据库支撑的journal,两部件的职责被重新切分:

  • 任意listener worker都作为无状态追加者,把每次CI的每个测试结果直接append进journal——不要求顺序、不需要协调,扛不住就横向加worker;
  • 一个独立消费者每隔几秒把journal滚动归并成per-test的通过/失败历史,供selector读取。

写入与计算分离、追加优于更新、读路径只消费归并好的视图——“单写者”这个原始前提就此消失。据转述,这次重构由一名工程师在约3周内完成;作者自评,同样的工作放在一年前大约需要一个季度。工具链先把作者自己的产能也提了一个量级——这是整篇复盘里很Anthropic的一句注脚,也提醒测试团队:重构的成本曲线同样在被AI改写,过去“等明年排期”的架构债,如今可能三周就能还掉。

四、工程转译:中等规模Python团队的最小自建版

先看清楚Anthropic思想里哪些是“规模特权”、哪些是“普适结构”:横向扩展的worker池是前者(多数团队根本到不了25倍),而“结果历史要持久化、要能被选择器消费、写入要能被多来源追加”是后者。下面的最小自建版属于我方工程推导,不是官方实现,按你们的技术栈酌情取用:

  1. 依赖记录:单机起步直接用pytest-testmon,它基于测试执行痕迹记录“哪个测试依赖哪些源文件”,把依赖从每次现算变成一份持久资产;
  2. 历史结果持久化:CI每次运行结束,把“test→本次变更文件→pass/fail”三元组追加进一张只增不改的结果表——这就是journal的最小形态,放数据库或对象存储,别留在runner的内存或进程本地文件里;
  3. 基线刷新:main分支每天(或每N次合并)跑一次全量,重建依赖关系与通过历史的可信基线。selector只敢在新鲜基线附近做减法,基线一过期就整体降权;
  4. 盲区兜底:给依赖图看不见的测试(数据库迁移、外部API、共享fixture)打tag列白名单,diff命中相关路径即强制运行;依赖算不出、历史太薄、或属于新增测试的,一律自动回退全量——宁可多跑,不可漏跑。

四条合起来,就是一张“CI测试选择路线对照表”:

路线 每次PR成本 随代码量与PR量增长 漏测风险主要来源 适合阶段
无脑全量跑 高,且只会更高 线性甚至更差 几乎没有,主要是flaky干扰 测试数千以内、PR低频
人肉分层挑选 中,靠资深同学记忆 受人力与经验带宽硬约束 挑选者知识盲区、交接断层 团队约10人以内、模块稳定
TIA+journal最小版 一次性研发投入 写入端可加机器,基本免疫 依赖图盲区、历史数据陈旧 PR高频、测试资产持续膨胀

五、回归/CI落点:journal思路的最小实现

下面两段是可落地的示意代码(我方示例方案):第一段对应journal的“追加+滚动归并”,第二段用pytest钩子把结果写进去。字段和表名按你们CI替换即可。

# journal_min.py —— 结果日志只追加,消费者定期归并出 test→文件 历史,selector 只读归并结果
import json, sqlite3, time

def init(db_path):
    con = sqlite3.connect(db_path)
    con.execute("CREATE TABLE IF NOT EXISTS runs ("
                "ts REAL, run_id TEXT, test_id TEXT, outcome TEXT, changed_files TEXT)")
    con.execute("CREATE TABLE IF NOT EXISTS test_history ("
                "test_id TEXT, src_file TEXT, passes INTEGER, failures INTEGER)")
    return con

def append_result(con, run_id, test_id, outcome, changed_files):
    # 任意 CI runner 都可调用:无状态、不要求顺序,扩容=加 runner
    con.execute("INSERT INTO runs VALUES (?,?,?,?,?)",
                (time.time(), run_id, test_id, outcome, json.dumps(changed_files)))

def merge_journal(con, window_days=90):
    # 独立消费者:每几秒(或每次合并后)滚动归并,selector 永远不直接读原始 runs
    con.execute("DELETE FROM test_history")
    con.execute(
        "INSERT INTO test_history "
        "SELECT r.test_id, j.value, "
        "SUM(r.outcome = 'passed'), SUM(r.outcome = 'failed') "
        "FROM runs r, json_each(r.changed_files) j "
        "WHERE r.ts > ? GROUP BY r.test_id, j.value",
        (time.time() - window_days * 86400,))

def select_for_diff(con, changed_files):
    """返回可跳过的测试集合:diff 没碰它的任何依赖文件、且历史够干净够厚;其余一律照跑。"""
    marks = ",".join("?" * len(changed_files))
    hit = {t for (t,) in con.execute(
        f"SELECT DISTINCT test_id FROM test_history WHERE src_file IN ({marks})",
        changed_files)}                      # diff 命中的测试,无条件执行
    skippable = set()
    for test_id, p, f in con.execute(
            "SELECT test_id, SUM(passes), SUM(failures) FROM test_history "
            "GROUP BY test_id"):
        if test_id not in hit and f == 0 and p >= 3:
            skippable.add(test_id)            # 无依赖命中+历史厚且零失败,才允许跳过
    return skippable                          # 从未出现在历史里的新测试,不在跳过集内,照跑
# conftest.py —— pytest 钩子:把每个测试的结果连同本分支 diff 追加进 journal
import os, subprocess
from journal_min import init, append_result

def pytest_runtest_logreport(report):
    if report.when != "call" and report.outcome == "passed":
        return  # setup/teardown 的 error 仍要记录,只跳过纯 setup 通过噪声
    changed = subprocess.run(
        ["git", "diff", "--name-only", "origin/main...HEAD"],
        capture_output=True, text=True).stdout.splitlines()
    con = init("test_journal.db")
    append_result(con, run_id=os.environ.get("CI_PIPELINE_ID", "local"),
                  test_id=report.nodeid, outcome=report.outcome, changed_files=changed)

选择规则刻意保守:diff命中依赖的测试无条件执行;只有“没被diff碰到、窗口内至少3次通过、0次失败”的测试才进跳过集,没有历史或历史太薄的新测试一律照跑。要清楚它会漏什么:

  • IO与数据层副作用:测试读写数据库、缓存、消息队列时,依赖的是“数据状态”而非“文件”,import图看不见;
  • 动态加载与反射:importlib、插件注册、配置驱动的行为切换,静态与运行时痕迹都可能漏记;
  • 共享状态与执行顺序:单独跑通过、并行时互相污染的用例,历史记录会替它们“作伪证”;
  • flaky污染:一次偶发失败会永久压低该测试的跳过资格,一次偶发通过又可能放行真正危险的跳过——官方如何处理这个抖动,恰恰没有公布。

六、边界:官方没有公布的三件事

先把边界讲清楚:selector的选择准确率与漏测率、flaky测试的处理策略、这套系统的运行成本,官方均未披露,本文不做任何外推,读者看到别处引用这三个维度时请保持警惕。

其次,“25倍”是Anthropic特定组织形态与技术栈的处境——一家把80%合并代码交给AI写的公司,不是每个团队明年都该给自己立这个KPI,更不该据此恐慌。它的正确用法是当“压力测试的思想实验”:如果你的PR产量翻倍,你的测试结果记录系统会先在哪一步跪下?

最后一条留给所有打算抄作业的人:selector省下的每一分钟,都是拿漏测风险换的。兜底策略必须先于省时间上线——没有“未知即全量”的回退开关之前,任何测试跳过优化都不该进main分支。

七、作者留下的四条建议

转述原文末尾,作者给出四条复盘建议:

  1. v0架构就按未来10—20倍余量设计,别等第三次补丁再承认现实;
  2. 容量规划按复利增长做,不按线性增长做——25倍只用了半年;
  3. 把服务做成agent能靠日志、指标、trace自诊断的形态,因为以后半夜处理告警的可能不是你;
  4. 避免单实例关键服务,状态从一开始就别放在进程里。

第四条送给所有还把测试报告、缓存、中间状态写在runner本地磁盘的团队:这条铁律的适用规模,远比你想的低。

结语:明天上班可以先做的一件事

不必先立项建平台。第一步,今晚就能做:给你的pytest工程加一张append-only的结果表和一个钩子,让历史先攒起来;第二步,用一周时间接入pytest-testmon或等价机制,算出你们工程的“可跳过测试面”到底有多大;第三步,用两周把“未知即全量、过期即回退”的门禁立进CI。Anthropic用25倍证明了这条路的终点长什么样,而你要做的只是别让下一个70天从加机器开始。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。