【项目实战】第9篇:自动化测试教程——怎么证明它是对的

系列:《用 AI 写出高质量代码:AI 狼人杀开发实录》 本篇对应版本:v1.13.0 → v1.13.1|时间:2026-10-23
前言
前八篇都在“做功能”。最后一篇讲一件更根本的事:你怎么知道它是对的?
我的验证方式一直是“玩一局看看”。一局八分钟,一天验不了几次,还经常验不到出问题的那条分支。直到我让 AI 改了一处观战接口,上线十分钟后冒烟脚本红了——它比我先发现问题。
那一刻我才真正理解测试的价值:它不是流程负担,它是让 AI 的产出可以被信任的唯一手段。
问题:AI 不会替你怀疑自己
AI 写测试有个特点:它会写“能过的测试”。
你让它写测试,它会照着实现反推断言——实现算错了,测试也跟着算错,全绿,你更放心了。这是最危险的状态:假的安全感。
所以测试不是“验证实现”,是“预先写下你认为对的东西”。
先写你期望的行为,再让 AI 实现。 反过来,测试就成了实现的镜子。

图:三道闸门。单元测试毫秒级、只管一个函数;契约测试秒级、管接口的形状与权限;线上冒烟分钟级、跑在真实环境。越往后越慢,也越接近用户真正会遇到的情况。
为什么“改了契约,旧测试变红”是好事
那次改动一上线,就有两条旧测试 + 一条冒烟断言同时红。
一开始我有点烦,后来想明白了:它们红的正是刚被改掉的那条行为。 如果契约改了而没有任何测试红,那才可怕——说明没东西在守着这个契约。
测试是变更的账本。 你改一笔,它就记一笔,逼你确认“这笔是有意改的”。
解决方案:三道闸门
第一道:契约测试(最划算的一种)
写测试之前先想“这个功能最坏会怎么坏”,测试才有方向。埋点接口的风险不在“少记一条”,而在“记了不该记的”:事件名必须窄,身份必须不可逆,汇总必须登录才能看。
it('未知事件返回 400', async () => {
expect((await post('/api/track', { event: 'whatever' })).status).toBe(400);
});
it('四个已知事件都接受', async () => {
for (const e of ['share_click', 'landing', 'login', 'invite_bound']) {
expect((await post('/api/track', { event: e })).status).toBe(200);
}
});
it('汇总接口未登录返回 401', async () => {
expect((await get('/api/track/summary')).status).toBe(401);
});
契约测试的成本很低(不用起真数据库,用假对象),收益很高:接口的形状一旦被钉住,AI 改实现时就不敢乱动返回体。
第二道:线上冒烟
冒烟脚本的本质是:把验收标准写成会红的断言,在真实环境上跑一遍。
这个项目有 24 项,覆盖开局、发言、投票、结算、观战、分享、埋点。举一条:
await check('观战无需凭证,但不下发未公开的身份', async () => {
const { status, body } = await json(`/api/games/${gameId}/spectate?since=0&client=smoke`);
assert(status === 200, `HTTP ${status}`);
const finished = Boolean(body.snapshot.winner) || body.snapshot.phase === 'over';
if (!finished) {
const leaked = body.snapshot.players.filter((s) => s.role !== null);
assert(leaked.length === 0, `进行中泄露了 ${leaked.length} 个身份`);
assert((body.snapshot.timeline ?? []).length === 0, '进行中不该下发复盘时间轴');
}
});
三个细节值得学:断言失败时带出“谁泄露了”,而不是只报“失败”;区分对局状态(进行中 / 已结束),两种状态正确行为不同;返回一句人类可读的摘要,一眼看出这次验了什么。共同目标是:失败信息要能直接指向原因。
第三道:仓库卫生
npm run repo:check # 检查垃圾文件是否被提交、被忽略的文件有没有混进仓库
它挡住的是调试截图、临时脚本、本地配置(含密钥)被提交上去。听起来是小事,但密钥一旦进了 git 历史,处理起来很麻烦。
案例:那一次“改契约”,到底改了什么
背景:观战接口是免鉴权的(观战是产品卖点,不能要求登录),但它原来下发全量身份——6 个座位,谁是狼一目了然。玩家可以在局内点「观战」,把链接粘到另一个窗口,边看所有人的身份边做决定。
改法:观战只公开“已经翻牌”的身份。
spectateSnapshot(): SpectateSnapshot {
const finished = this.winner !== null || this.aborted !== null || this.phase === 'over';
const seats = this.players.map((p) => {
const revealed = finished || p.revealRole; // 放逐翻牌 / 白痴亮牌 / 长老受罚
return { ...p, role: revealed ? p.role : null };
});
return { players: seats, timeline: finished ? this.timeline : [] };
}
timeline 那一条最容易被漏。它带着每夜“刀了谁、女巫救没救、守卫守了谁”——界面上不渲染,但抓包能读到,足以反推出身份。“UI 不显示”不等于“没泄露”。
这次改动的代价:两条旧测试红了(锁的正是“全量公开”),一条线上冒烟也红了。加上 6 条新测试。
# 结果
Test Files 35 passed (35)
Tests 324 passed (324)
这三红两绿的过程,就是“变更的账本”在起作用。
效果:改完敢跑,红的地方就是该看的地方
| 闸门 | 规模 | 跑一次 | 抓什么 |
|---|---|---|---|
| 单元 + 契约测试 | 35 个文件 / 324 用例 | 约 20 秒 | 规则、接口形状、权限、提示词防线 |
| 线上冒烟 | 24 项 | 约 2 分钟 | 真环境主路径(开局→发言→投票→结算→观战) |
| 仓库卫生 | 1 项 | 秒级 | 调试截图、临时脚本、本地配置混进仓库 |
最能说明效果的是观战那次改动:上线十分钟,冒烟先红——我还没发现问题,它已经把"进行中泄露了 6 个身份"指出来了。修完之后同一个接口:

图:修完的观战页。六个座位全部「未翻牌」——不是前端遮的,接口里 role 就是 null。
从那以后我提改动的习惯变了:不再是「希望没坏」,而是「跑一遍,红的地方就是我该看的地方」。
内容小结
- 测试的价值不是覆盖率,是“变更的账本”:改一笔记一笔,逼你确认这是有意的。
- 先写期望,再让 AI 实现。反过来,测试就成了实现的镜子,全绿也没有意义。
- 契约测试性价比最高:成本低、能挡住“形状漂移”,尤其适合 AI 频繁改动的项目。
- 冒烟测试要跑在真实环境,失败信息要能直接指向原因(谁泄露了、哪一步)。
- “UI 不显示”不等于“没泄露”。免鉴权接口只能下发公开信息,这条要写进设计约束。
- 可迁移吗? 完全可迁移。哪怕只写三条断言——核心规则一条、接口形状一条、权限一条——也比零测试强得多。
全系列收尾:人机分工
九篇写完,规律很清晰:人负责“判断”,AI 负责“执行”。 判断包括三件事——边界在哪、什么算对、什么时候该换手段。
需求上人定边界写验收标准,AI 补细节写文档;架构上人判断“谁拥有真相”,AI 搬实现写胶水;验收上人决定“什么算对”,AI 写测试、跑冒烟。
AI 越强,这三件事越重要。因为它能替你写的东西越多,你判断错一次的代价就越大。
参考资料
- 项目仓库:
werewolf-ai的tests/(35 个文件)、tools/live-smoke.mjs、tools/repo-check.mjs - 测试命令:
npm test/npm run smoke:live/npm run repo:check - 上一篇:第8篇:分享链接与
URL教程——发出去的链接,朋友打不开 - 回到:第1篇:AI 编程入门教程——AI 偷偷写了兜底,我还以为它能跑
[评论 0]
评论加载中…