AI 时代的测试:让生成物守护生成物

AI 能生成测试用例,但“生成一堆用例”和“有效的测试体系”是两回事。聊聊 AI 辅助测试的正确打开方式:人定策略,AI 补人力。
过去一年里,我有一半以上的业务代码不是自己手写的,而是先由模型生成、我再改。这个变化很爽,但很快暴露出一个尴尬的事实:当代码的生产速度被拉高十倍,我原来那套"写完再补测试"的节奏彻底崩了。更微妙的是,我发现自己开始越来越不信任这些代码,不是不信任模型的能力,而是不信任"没有被测试钉死的代码"。测试在 AI 编码时代,正在从一道"保障"工序,悄悄变成一份"验收契约"。
为什么说是契约?因为模型生成的东西天然带着实现者的隐性假设。如果你不把期望显式写成断言,那段代码跑通一次之后,下次模型再改它,没人能保证行为不变。测试第一次成了人和机器之间的协议文本:它告诉模型"这就是你要的东西",而不是"这段代码现在能跑"。我把这个转变看成今年我工程实践里最重要的认知升级,没有之一。
这种不信任不是针对某一家模型厂商,而是针对"生成"这件事本身。无论是哪个旗舰还是本地小模型,只要代码是生成出来的,它就缺少一个人类作者写代码时会带上的"我为什么这么做"的上下文。测试,恰好是补回这份上下文最便宜的手段。
这种转变也倒逼我重新思考"什么时候写测试"。过去测试是收尾动作,功能跑通才补;现在测试更像是开工前的验收标准,先想清楚"这段代码要守住什么行为",生成物才有锚点。我把这种新习惯叫做"契约优先":不先定义验收,就不让模型动手。文章后面会展开具体怎么做,但先把这层认知立住,它决定了你后面所有测试实践的方向。
测试角色的真实迁移
传统测试哲学里,测试是"防止我犯错"。我写错了逻辑,测试拦住我。但在 AI 生成代码的流水线上,错误的形式变了:模型很少犯低级语法错,它犯的是语义错,把一个本该返回列表的函数返回了字典,把边界条件悄悄改掉,把异常吞掉换成返回 None。这类错误单元测试挡得住,但前提是你得先有那些测试。
我现在的认知是:测试从"保险丝"变成了"规格书"。一份好的测试套件,本质上是在用可执行语言重述需求。当模型读懂测试,它才真正读懂你要什么。反过来,没有测试的模块,模型每一次重构都会带来不确定性,你会陷入"改一处、崩三处"的疲惫循环,最后干脆不敢动它,这对一个本该易于演进的代码库是致命的。
这带来一个现实的工作流变化:我让模型写功能时,会同时要求它产出配套测试;但我已经从"测试帮我省心"的幻觉里走出来了,AI 写的测试本身不可全信,它需要被审查。下面我会专门讲怎么审查。这里先说一个我定的铁律:任何 AI 生成的测试,在合入主干前必须有人读过断言,而不是只看 CI 是否变绿。
还有一层迁移容易被忽略:测试_now也是给人看的文档。新人接手一个 AI 生成的模块,最快的理解方式不是读实现,而是读测试。测试用例把"这个模块承诺了什么行为"讲得比任何注释都清楚。所以我在生成测试时,会额外要求模型给关键用例写一句话 docstring,说明它验证的是哪条业务规则。这笔小投入,长期回报很高。
用 AI 生成测试的诱惑与陷阱
让模型顺手把测试写出来,速度确实快得离谱。一个中等复杂度的服务函数,从零到覆盖主要分支的 pytest 用例,几十秒就出来了。以前我要花十分钟憋测试,现在几乎是零成本。但它有三个我反复踩过的坑,每一个都会让"省下的时间"加倍奉还。
第一个坑是"迎合实现"。模型生成的测试,往往是在看了实现之后写的,于是它不自觉地按实现去构造断言。比如函数内部用了一个字典缓存,测试就断言"第二次调用更快":这根本不是需求,只是实现的副作用。这种测试守护的不是行为,而是当前实现,重构时反而会误报。更糟的是,它会给重构者一种虚假安全感:红灯亮了你以为改坏了,其实只是实现换了种写法。
第二个坑是"happy path inflation"。模型倾向于铺满正常流程,对空输入、超长输入、并发、异常路径覆盖得很敷衍。我看过一个号称覆盖率 90% 的测试文件,里面一个异常分支都没有,全是正常返回。覆盖率数字很好看,但对真实故障毫无防御力。这就像给房子装了漂亮的大门,后墙却敞着。
第三个坑是"假断言"。最隐蔽的一种是用了 assert result is not None 或者干脆 assert True,甚至直接 assert foo() == foo() 这种恒真式。模型有时为了"让测试通过"会退化成这种东西,看着绿油油一片,实则毫无意义。还有一类是断言了一个不该断言的随机值,比如对包含时间戳的输出断言完全相等,导致测试时灵时不灵,最后被人习惯性地 pytest -k 跳过。
AI 测试最大的风险不是测错,是测了一个你没要求的东西,还让你以为测过了。它给你的是"测试已存在的证据",而不是"行为被正确验证的证据"。这两件事差了十万八千里。
人工审查 AI 测试的四项清单
我每次让模型生成测试后,都会过一遍这份清单。它很短,但能挡掉绝大多数无效测试。我把它贴在编辑器侧边栏,成了肌肉记忆。
- 断言强度:断言是否钉死了关键输出?还是只用
is not None这种弱断言糊弄?真正的断言应该描述"在给定输入下,输出必须是什么",而不是"输出存在过"。我尤其警惕那些断言了类型却没断言值的用例,assert isinstance(x, list)一点用没有,因为坏数据也可以是 list。 - 边界覆盖:空集合、None、超长字符串、负数、越界索引、并发重复调用,这些模型最爱的"省略区"有没有补上?我习惯让模型单独列一张边界清单再对照。一个实用做法是把"边界用例数 / 总用例数"当成健康度指标,低于三成就要警惕。
- 是否复述实现:把测试盖住实现读一遍,如果这段测试只有在当前实现逻辑下才通过、换一种正确实现就失败,那它是复述实现而非验证行为。健康的测试应该对"实现方式"无知。比如测试一个去重函数,断言"结果长度和输入有关"是行为级,断言"结果里第一个元素等于输入第一个"就是实现级泄漏。
- 异常与契约:函数声明会抛什么异常,测试有没有验证?函数对参数有什么隐含契约(比如不允许空串),测试有没有用非法输入去戳它?很多 bug 就藏在"传了不该传的东西时函数怎么反应"里,而模型总假设调用方是守规矩的。
一个我常用的小技巧:让模型先别看实现,只根据函数签名和 docstring 写测试,再和看了实现写的版本对比。两个版本差异最大的地方,往往就是"复述实现"的重灾区。我会重点审查那些"看实现后才出现的断言",问自己:这条断言描述的是需求,还是描述这版代码恰好长这样?
# 一个"复述实现"的反面教材
def dedupe(items):
seen = set()
out = []
for it in items:
if it not in seen:
seen.add(it)
out.append(it)
return out
# 坏测试:泄漏了"保留首次出现顺序"的实现细节,
# 换一种同样正确的实现(比如排序后去重)就会挂
def test_dedupe_bad():
assert dedupe([3, 1, 3, 2]) == [3, 1, 2]
# 好测试:只断言去重这一行为本身
def test_dedupe_good():
assert set(dedupe([3, 1, 3, 2])) == {1, 2, 3}
assert len(dedupe([3, 1, 3, 2])) == 3属性测试与快照测试的新价值
AI 编码时代,有两类测试重新变得值钱:属性测试(property-based)和快照测试(snapshot)。它们恰好克制了前面说的两类坑。
属性测试的意义被放大了,因为它天然对抗"迎合实现"。你不再写一个具体输入输出,而是写"无论输入什么,这个函数都满足某条不变量"。例如一个排序函数,你断言"输出长度等于输入长度""输出是单调非降的""输出是输入的一个排列"。这些性质不依赖任何具体实现,模型无论怎么重写,只要行为正确就过、错误就挂。用 Hypothesis 写起来很顺手:
from hypothesis import given, strategies as st
import pytest
def test_sort_properties():
@given(st.lists(st.integers()))
def inner(xs):
out = my_sort(xs)
# 不变量一:长度守恒
assert len(out) == len(xs)
# 不变量二:单调非降
assert all(out[i] <= out[i + 1] for i in range(len(out) - 1))
# 不变量三:是输入的排列(元素多重集相同)
assert sorted(out) == sorted(xs)
inner()这类测试我特别愿意交给模型生成,因为"描述不变量"比"描述具体用例"更接近需求本身,模型不容易跑偏。而且属性测试会自发地去探测边界,Hypothesis 的收缩机制会自动找到让性质失败的最小反例,这比手写十个边界用例都管用。我甚至会让模型针对一个函数,专门 brainstorm 出五条不变量,再挑最稳的三条写进测试。
快照测试则适合那些输出结构复杂、但正确性靠"肉眼对比"的场景,比如渲染的 HTML、序列化的配置、AI 生成的文本摘要。第一次跑生成基准快照,之后任何改动若改变了输出,测试就标红,逼你去看"这次变化合理吗"。不过要警惕快照腐化:别一看到差异就无脑更新快照,那样它就退化成了恒真断言。我的规则是,快照更新必须人工 review diff,并且在提交信息里写清"为什么这次输出变化是可接受的"。
属性测试和快照测试的共同点是:它们把"验证什么"从"某个具体例子"提升到了"一类行为"。这正好对冲了 AI 测试最容易犯的"只见树木"病。
E2E 只保关键路径
E2E 测试在 AI 项目里有个残酷现实:又慢又脆。模型改一行前端逻辑,可能让五个 E2E 因为选择器变化集体挂掉,而真正有 bug 的地方一个没抓到。我现在的策略非常功利,E2E 只守关键路径,其余的信任交给下层测试。
所谓关键路径,就是"如果它挂了,公司当天就会收到客诉"的那条线。比如登录、下单、支付回调、数据导出。我把 E2E 数量压到个位数,每个都对应一个明确的业务损失场景。其余的交互细节,交给组件测试和单元层去覆盖。这样 E2E 跑一次两三分钟,且红灯必有大事。
# 只保关键路径的 E2E 示例(伪代码,playwright 风格)
CRITICAL_FLOWS = [
"user_can_login_and_see_dashboard",
"user_can_create_order_and_pay",
"webhook_payment_callback_updates_order",
]
def test_critical_flow(flow_name):
if flow_name not in CRITICAL_FLOWS:
pytest.skip("非关键路径不进 E2E 套件,交给组件/单元测试")
run_flow(flow_name)这样做的好处是,E2E 挂了一定是大事,没人会习惯性忽略红灯。它的角色从"全覆盖网"退化成了"火警报警器":覆盖面小,但一响就真有人跑。我还给关键 E2E 单独设了 CI 优先级,让它们最先跑、最先报警,把反馈循环缩到最短。
这里有个张弛的尺度:小项目可以几乎不要 E2E,靠强单元层兜底;大项目则必须保留少数 E2E 作为"系统没散架"的最终证明。判断标准是"如果所有下层测试都绿、但 E2E 挂了,你会不会吓一跳":会,说明这条 E2E 守的是下层测不到的集成风险,留着;不会,说明它在重复下层已经覆盖的东西,删掉或降级。
用 AI 评审 AI 生成的测试
既然测试也是模型写的,一个自然的想法是:能不能让另一个模型来审?我试了半年,结论是可行,但要摆对位置。让评审模型做"静态审查员"而不是"最终裁判"。
我的做法是把"实现 + 模型A写的测试"一起喂给模型B,请它扮演资深 reviewer,专门找上面那四类问题:弱断言、边界缺失、复述实现、假断言。模型B因为没有参与写测试,往往比我自己更快看出"这段断言其实啥也没验"。它还会指出一些我忽略的边界,比如时区、浮点比较、编码问题、并发竞态。一个真实例子:模型B指出我对一个金额比较用了 ==,而浮点累计会出 0.0000001 的误差,应该改用 pytest.approx。这种细节我常漏,模型B反而稳。
但这里有个关键约束:评审模型的结论必须落地成"我看得懂的具体问题",而不是一句"这个测试可能不够强"。我要求它逐条列出怀疑点并给出改进断言样例,然后我自己拍板改不改。我绝不会让模型B自动改写测试后直接合入,那等于把信任链完全交给机器,正是我们要躲开的陷阱。评审模型的输出是线索,不是裁决。
# 让评审模型输出结构化问题清单的提示骨架
REVIEW_PROMPT = """你是资深测试 reviewer。针对下面的实现与测试,逐条列出:
1. 弱断言(没钉死行为)
2. 边界缺失(哪些输入没覆盖)
3. 复述实现(断言依赖了当前实现细节)
4. 假断言(恒真或无效)
对每条给出具体改进断言。不要改写代码,只给审查意见。"""AI 评审 AI 测试的价值,不在于替代人的判断,而在于把人的注意力精准引到该看的地方。它负责"扫雷",人负责"排雷"。人和模型之间那道"最终拍板"的缝,不能让任何自动化跨过去。
把测试嵌回 AI 编码工作流
前面讲了"测试长什么样",但"测试在什么时候写、由谁写"更难。AI 编码如果不把测试设计进工作流,它就永远是被补的作业,而不是契约。我现在的默认节奏是:先写测试骨架,再让模型填实现,最后让模型补边界用例。这个顺序和传统的"先实现后测试"反着来,但在生成式开发里更顺。
具体做法:我先用自然语言把"这个模块要守住哪几条行为"写清楚,让模型据此生成测试骨架(只写断言、留空实现或用占位),我 review 骨架确认它验的是真需求,再让模型去实现。这样实现一出生就被测试盯着,而不是写完再找人验收。我管这叫"测试先行生成"。它最大的好处是,模型在写实现时能看到期望,反而更少写出偏题的版本。
还有一层是 CI 门禁。我把"AI 测试必须通过人工断言审查"做成一条卡点:任何包含测试文件的 PR,CI 除了跑测试,还要跑一个轻量检查,统计弱断言(is not None、恒真式)的数量,超过阈值就标黄。虽然它拦不住所有坏测试,但能逼大家在合并前扫一眼。工具层面可以用 pytest 配合自定义规则,也可以让评审模型在 PR 评论里自动贴出可疑断言清单。
工作流的关键不是"多写测试",而是"让测试在错误发生之前就在场"。生成式开发最怕的就是实现跑在验收前面,测试沦为事后补票。把顺序倒过来,很多语义错根本不会流到审查环节。
用变异测试度量测试是否真的有效
怎么知道你的测试"真的能抓住 bug",而不是假装工作?我最近重度依赖变异测试(mutation testing)。它的思路很暴力:自动把实现改坏一小处(比如把 > 改成 >=、把 and 改成 or),然后跑你的测试套件。如果测试挂了,说明它抓到了这个突变;如果测试依然绿,说明这套测试对这处错误是盲的,这就是"存活突变",是你要补强的地方。
在 AI 生成测试的场景里,变异测试的价值尤其大。因为 AI 测试最容易写的是"复述实现"的断言,而这类断言对实现突变往往无感:实现悄悄变了,测试照样过。变异测试能把这些"假守护"揪出来。我用 mutmut(Python)或 cosmic-ray 跑过几个模块,存活突变率高的文件,几乎都对应着一批弱断言或复述实现的用例。
# 变异测试示意:手工制造一个突变来检验测试是否敏感
def dedupe(items):
seen = set()
out = []
for it in items:
if it not in seen:
seen.add(it)
out.append(it) # 突变:若改成 out.insert(0, it) 反转顺序
return out
# 若测试只断言 set(out)==set(input),上面的顺序突变就抓不到
# 若测试断言 out[0]==input[0](行为级),突变才会让测试挂
# 变异测试帮你发现:你的断言到底锁没锁住真正重要的行为不过变异测试有成本:它要跑很多轮,CI 慢。我的折中是只对"核心模块"和"AI 生成的测试文件"定期跑,不进每次提交的快通道。把它当成体检,而不是每日打卡。频率上,我每月对关键模块跑一次,专门清理存活突变,长期能把"假测试"比例压到很低。
给团队落地 AI 测试的渐进路线
如果是带团队,我建议别一上来就搞全套,按下面这个渐进路线走,踩坑成本最低。
- 第一阶段:只要求"AI 生成功能时必须附带测试",但明确这些测试是草稿,不计入质量门禁。目的是先让团队习惯"有测试伴随生成物"的节奏,不逼大家立刻审查。
- 第二阶段:推行四项清单审查,把"弱断言、边界缺失、复述实现、假断言"做成 PR 模板勾选项。谁提测试 PR 谁先自查,reviewer 重点看这四项。这一步能把最烂的假测试挡在门外。
- 第三阶段:引入属性测试和快照测试到合适的模块,并用变异测试对核心模块做月度体检。此时测试从"数量"转向"有效性",团队开始在意"测试到底验了什么"。
- 第四阶段:E2E 收敛到关键路径,AI 测试进入 CI 自动贴可疑清单。整套机制自运转,人只负责最终拍板。
这套路线的核心原则是"先有、再好、最后有效",不要试图一步到位。我见过团队一上来就要求 90% 覆盖率加变异测试零存活,结果大家为了达标狂写恒真式,反而更糟。渐进才能把文化带起来,而不是把人逼走。
测试债务:AI 生成代码的特殊维护负担
最后聊一个长期视角:AI 生成代码带来的"测试债务"和传统代码不一样。传统代码膨胀,测试债务是"覆盖变少";AI 代码膨胀,测试债务是"虚假覆盖变多"。前者你知道自己没测,后者你以为测了。后者更危险,因为它消除了写测试的紧迫感。
我见过一个仓库,AI 半年里堆了四百个测试,覆盖率 80%,但跑变异测试存活突变率高达 40%,意味着近一半实现错误测试根本抓不到。团队还沉浸在"我们测试很全"的安全感里。这就是典型的 AI 测试债务:数量掩盖了无效。我的应对是定期用变异测试给"测试有效性"做体检,把存活突变率当成和覆盖率同等重要的指标,甚至更重要。
另一个维护负担是测试本身的演进。AI 改实现频繁,测试要跟着改。如果测试是复述实现的,每次重构都伴着测试大改,维护成本被放大。行为级测试则稳得多,实现换了,测试不动。所以"是否复述实现"不仅关乎当前有效性,还关乎长期维护成本。这点我在审查时权重给得很高:短期能过的假测试,长期是负债。
对 AI 生成代码,测试不是一次写完就完的事。它是一份要随生成物一起演进、且必须定期验证有效性的活契约。把测试有效性当成持续指标来盯,才不会被虚假覆盖反噬。
测试与生成的版本同步
AI 编码里还有个细节:实现和测试都是生成物,它们该被当成一对一起版本管理,而不是各管各的。我要求模型改动功能时,必须同步更新对应测试,且两者在同一次提交里。若出现"实现改了、测试没动还全绿",这是强信号,要么测试是恒真式,要么它在复述旧实现。这两种都是雷,我会在 review 时专门查"实现变更但测试零改动"的提交。
另一个同步场景是模型换代。当你换了个更强的编码模型,它生成的实现风格会变,老测试可能突然变脆弱(比如依赖了旧实现的某种输出顺序)。这时别急着改测试去迁就,先问"是行为变了还是只是实现变了"。行为真变,改测试;只是实现变,说明老测试太脆,本就该加固。把"生成物随生成模型演进"当成常态,测试审查的权重反而要随模型升级提高,而不是降低。
测试和生成物是同一份契约的正反两面,必须同生同改。把它们拆开管理,是 AI 编码项目里最隐蔽的技术债来源之一。
我的实践取舍
写到这,我想把取舍说清楚,免得有人照抄翻车。这些是我踩了坑之后沉淀下来的,不是教科书结论。
我把测试预算分成三块:单元/属性测试由模型生成、我审查;快照测试用于输出结构类模块;E2E 只保关键路径、手动维护为主。AI 生成的测试绝不无条件信任,必须过四项清单,其中"是否复述实现"这条我会反复逼自己问。
我刻意不追求高覆盖率数字。在生成式代码里,为了覆盖率而堆的测试,很容易变成复述实现的重灾区。我宁可十个强断言,不要一百个恒真式。覆盖率从 85% 掉到 60% 不可怕,可怕的是那 85% 里有一半在假装工作。我现在看 CI,第一眼不是覆盖率,而是"今天有没有红灯",以及"红灯对应的断言到底验了什么"。
还有一个我强烈推荐的日常习惯:每周抽半小时,随机翻开一个 AI 生成的测试文件,假装不知道实现,只读测试,问自己"如果实现悄悄坏掉,这个测试会响吗"。答不上来的,要么补强断言,要么删掉,留着假测试比没测试还危险,因为它制造安全的幻觉。
最后一句忠告:当你开始用生成物守护生成物,真正的守门人永远得是那个能读得懂需求、也读得懂代码的人,也就是你。测试不是终点,它是你和机器之间的那纸契约,别让任何一方独自改写它。AI 能写出测试,但只有你能决定"被验证的究竟是不是你真正在乎的东西"。测试在 2026 年剩下的价值,就在这里。
回头看这一年,AI 帮我写的代码越来越多,而我花在测试上的心思不但没少,反而更细了。区别是,以前测试是"我防我自己",现在测试是"人和机器共同守约"。这份契约写得越清楚,生成式开发就越接近一门可预期的工程,而不是赌运气。别因为代码是机器写的,就放松对它的验收,恰恰相反,正因为它来得太容易,我们才更该用测试把它钉牢。你交给模型的信任,最终都要靠断言来兑现。