Home
avatar

.Sam

【AI Native开发-11】测试全绿,也可能什么也没测到:问题出在哪?

30 秒看懂这篇

mock 掉认证、只断言函数被调用、删除真实文章——这些测试都可能短暂变绿,却不能提供可靠保护。自动化前必须先定四件事:测试层级、现有设施、行为断言、数据隔离。

flowchart TD
    A["一条文本用例"] --> B{"要验证哪一层契约?"}
    B --> C["单元:纯业务规则"]
    B --> D["接口 / 集成:路由 + 中间件 + 存储"]
    B --> E["E2E:关键用户路径"]
    C --> F["复用 fixture,准备独立数据"]
    D --> F
    E --> F
    F --> G["断言行为与副作用"]
    G --> H["用特定错误实现做变异检查"]
    H --> I["独立 · 可重复 · 可追溯"]

核心原则:按契约选层级,优先复用项目设施,不用 mock 架空被测逻辑,不用真实内容做破坏性测试。

本篇给出测试映射表、断言原则、数据隔离与变异检查方法。不是每条文本用例都值得自动化;应优先考虑风险、稳定性、执行成本和重复价值,其余可明确保留为人工或探索性验证。

一个典型场景

你让 AI”把删除功能的测试用例自动化了”。它很快写完,pnpm test 全绿。你很高兴,直到发现:

  • 它没有用项目现有的测试登录工具,自己在每个测试里手写了一个假的 session 对象,mock 掉了整个认证中间件——于是权限校验那段代码在测试里根本没被执行,TEST-002-3(绕过前端直打接口也要被拦)名义上自动化了,实际没测;
  • 它断言的是 expect(deleteArticle).toHaveBeenCalled()——验证”函数被调用了”,而不是”文件真的被删了 / 没权限时文件还在”;
  • 它在测试里真的删了 src/content/blog/ 下的一篇真实文章,第二次跑测试就因为文章不存在而失败;
  • 一个 E2E 用例里,它断言”确认弹窗关闭了”,但没验证取消时到底发没发删除请求。

全绿是假象。这些测试要么没测到行为,要么不可重复,要么断言错了对象。问题不在 AI 不会写测试语法,而在它不知道这个项目”怎么测才算数”。 这些规则必须显式告诉它。

核心问题:自动化阶段的三个默认错误

AI 写测试代码时有三个强烈的默认倾向,每一个都会让测试失效:

默认一:过度 mock,把要测的东西也 mock 掉。 AI 倾向于 mock 掉所有依赖让测试”干净”,但常常连被测逻辑本身都 mock 了——比如 mock 掉权限中间件,权限测试就成了空转。

默认二:断言实现而非行为。 AI 喜欢断言”某函数被调用""某变量等于某值”,因为这好写。但这些断言和内部实现绑死,重构即红,且不能证明用户看到的结果正确。

默认三:测试不可重复。 AI 会用真实数据、不留清理、依赖执行顺序。测试第一次绿、第二次红,或者反过来——这比没测试更糟,因为它制造了”已覆盖”的幻觉。

对策不是别让 AI 写测试,而是在它动笔前,先把层级、设施、断言原则、数据策略钉死。

第一步:先选测试层级

对每条文本用例,先决定在哪一层测。不要所有东西都堆到 E2E(慢、脆),也不要全用单元(测不到真实协作)。

选择原则:问"这条用例验证的是哪一层的契约?"

单元测试(快、多、隔离):
  - 验证纯逻辑:状态判断、权限判断、校验函数
  - 例:hide: true 可删 / hide: false 不可删的判定逻辑
  - TEST-003 状态边界的核心判定

接口 / 集成测试(中速、真实协作):
  - 验证真实 HTTP 路由 + 真实中间件 + 真实文件操作
  - 权限中间件【不 mock】,用真实登录态
  - 例:TEST-002 权限、TEST-005 服务端二次校验、TEST-001 完整删除
  - 文件操作用临时目录,不碰 src/content

E2E 测试(慢、少、关键路径):
  - 验证用户真实操作路径,跨前后端
  - 只覆盖关键用户旅程和"前端行为"类断言
  - 例:TEST-004 取消确认不发请求(必须在浏览器层才测得到)

关键判断:TEST-002-3 要验证“绕过前端后服务端仍会拦截”,接口/集成层通常最直接,因为能穿过真实路由与鉴权中间件。 权限纯规则也可在单元层测试,完整用户路径还可在 E2E 抽查;不能用 mock 把本条用例要验证的鉴权环节架空。选层级时明确“哪些依赖必须真实”,比机械规定唯一层级更重要。

第二步:先出测试映射表,再写代码

本文的接口/E2E 路径是可迁移的假想全栈项目示例,不是本博客当前仓库已经存在的文件或命令;落地前必须先核对真实测试设施。

不要直接让 AI 写测试代码。先让它产出一张”用例 → 自动化”的映射表,人确认后再写:

TEST 编号     层级    文件                        关键断言                数据准备
TEST-001-1   接口    tests/api/delete.test.ts    204 + 文件消失          临时未公开文章(hide:true) + admin
TEST-002-1   接口    tests/api/delete.test.ts    403 + 文件仍在(副作用)   临时未公开文章 + 普通用户
TEST-002-2   接口    tests/api/delete.test.ts    401 + 文件仍在           临时未公开文章 + 未登录
TEST-002-3   接口    tests/api/delete.test.ts    伪造角色仍 403 + 文件仍在  普通用户 + admin 角色参数
TEST-003-1   接口    tests/api/delete.test.ts    409 + 文件仍在           临时已公开文章(hide:false) + admin
TEST-004-1   E2E     e2e/delete-cancel.spec.ts   无 DELETE 请求发出       临时未公开文章 + admin
TEST-007-1   接口    tests/api/delete.test.ts    第二次返回 404            临时未公开文章 + admin

这张表让人能在写代码前就发现问题:某条 TEST 没分配层级?漏了。关键副作用断言没列?补上。确认映射完整再让 AI 生成代码,能避免”写完才发现没测权限”的返工。

第三步:复用现有测试设施

在指令里明确要求 AI 复用项目已有设施,禁止新造:

写测试前先做调研(只读):
1. 项目用什么测试框架?命令是什么?(看 package.json)
2. 现有的测试登录 / 认证 fixture 在哪个文件?怎么用?
3. 有没有测试数据工厂 / builder?
4. 接口测试怎么起服务、怎么发请求?
5. 临时文件 / 目录怎么创建和清理?(beforeEach/afterEach)

要求:
- 登录态【必须】复用现有 auth fixture,不要自己 mock 认证中间件
- 测试数据【必须】在临时目录创建(如 tmp/ 或 os.tmpdir),
  绝不能用 src/content 下的真实文章
- 每个测试结束后清理自己创建的数据(afterEach)
- 断言工具、请求工具复用现有的
- 如果现有设施缺东西(比如没有普通用户登录的 fixture),
  先提出来,不要默默 mock 掉

最后一条很重要:当现有测试设施不足以覆盖某场景时(比如没有”普通用户”的登录方式),正确做法是先补 fixture,而不是 mock 掉认证。 mock 认证是权限测试的头号杀手。

第四步:断言业务不变量

这是区分”有效测试”和”陪跑测试”的核心。对比两种写法:

import { vi } from "vitest"
import { api, readArticleFromStore } from "./helpers"

// ❌ 只调用并检查 mock 自身,没有经过任何被测系统
const deleteArticle = vi.fn().mockResolvedValue(undefined)
await deleteArticle(articleId)
expect(deleteArticle).toHaveBeenCalledWith(articleId)

// ✅ api 是配置了 baseURL 的测试客户端;直接检查受控测试存储
const res = await api.delete(`/api/articles/${article.id}`, { headers: adminAuth })
expect(res.status).toBe(204)
await expect(readArticleFromStore(article.id)).resolves.toBeNull()

权限被拒的用例,副作用断言尤其关键(第 02 篇也强调过):

// 使用同一受控测试存储和已配置的 API 客户端
const res = await api.delete(`/api/articles/${article.id}`, { headers: normalUserAuth })
expect(res.status).toBe(403)

// GET 200 不等于文件存在:可能来自缓存、快照或另一个数据源
await expect(readArticleFromStore(article.id)).resolves.toMatchObject({ id: article.id })
// 如 Spec 有要求,再检查索引、图片与其他关联副作用

E2E 层的”取消不发请求”用监听实现(第 02 篇的例子):

let deleteCalled = false
page.on("request", (req) => {
  if (req.method() === "DELETE") deleteCalled = true
})

await page.getByRole("button", { name: "删除" }).click()
await page.getByRole("button", { name: "取消" }).click()

// 断言"什么都没发生",比断言"弹窗关了"更接近 Spec 原意
expect(deleteCalled).toBe(false)

断言原则总结:

断言用户 / 系统可观察的结果:
  ✓ 返回的状态码、响应体
  ✓ 数据的最终状态(文件在不在、记录变没变)
  ✓ 副作用(请求有没有发出、关联数据动没动)
  ✓ 界面可见的变化

不要断言内部实现:
  ✗ 某个内部函数被调用了几次
  ✗ 某个私有变量的值
  ✗ 弹窗组件的内部 state(除非这就是要测的行为)

一个实用判据:**如果一次不改变外部契约的合理重构会让测试变红,就应检查测试是否过度绑定了内部实现。**交互测试在确有协作契约时仍然有价值,不必机械排除所有函数调用断言。

第五步:数据隔离与幂等

AI 写的测试最常见的不可重复问题,根源在数据。规则:

数据隔离:
- 每个测试自己造数据(用工厂 / builder),不依赖共享的固定数据
- 数据造在临时位置,绝不动 src/content 等真实内容目录
- beforeEach 准备,afterEach 清理,保证测试可独立、可乱序、可重复运行

幂等验证:
- 重复操作的用例(TEST-007),第二次明确返回 404(文章不存在)
- 断言第二次请求不会产生额外副作用,且系统状态一致

独立性:
- 测试之间不共享可变状态
- 一个测试的失败不影响其他测试运行

变异检查:确认测试能抓住特定错误

可以抽查关键用例,把被测逻辑临时换成一个明确的错误实现,例如删除接口固定返回 204,不做鉴权和文件操作:

- 测试变红:说明它能抓住“固定成功”这个错误实现
- 测试仍绿:说明它对这个错误没有保护能力,应检查 mock 与断言

这只是变异测试的简化版,不是有效性的充分证明。比如“固定返回 403、什么都不做”仍可能通过“403 + 文件存在”的权限用例;需要设计其他变异体,并结合需求覆盖评审。

建议优先检查权限、资金、数据删除等关键路径,不必给每条普通用例都增加同样成本。一个对多种合理错误实现都不敏感的测试套件,覆盖率再高也不能提供相应信心。

自动化用例准入准出

准入:

  • 文本用例已过评审,REQ → TEST 矩阵完整;
  • 测试映射表(TEST → 层级 → 文件 → 断言 → 数据)已经人确认;
  • AI 已调研并复用现有测试设施。

准出:

  • 每条文本用例都有对应自动化(或明确标注”仅人工验证”并说明原因);
  • 测试可独立、重复、乱序运行,不依赖真实内容数据;
  • 权限中间件等关键依赖未被错误 mock;
  • 断言的是行为和副作用,不是内部实现;
  • 权限 / 异常用例包含”副作用没发生”的断言;
  • 关键用例已用有针对性的空实现或变异体验证,能拦截所选错误实现;
  • 每条自动化用例都能通过 TEST 编号回溯到文本用例和 REQ;
  • 测试通过、失败信息可定位(失败时能看懂是哪条行为不对)。

自动化反模式

反模式一:把被测行为也 mock 掉。 如果认证、数据层和核心业务规则全部被 mock,测试就只能验证预设的 mock 互动,无法为真实权限与副作用提供证据。对策:按用例契约保留关键真实依赖,并用有针对性的变异检查确认测试能抓住错误;合理的依赖隔离和单元 mock 不在此列。

反模式二:真实数据测试。 在真实内容目录上测删除,第一次跑删了数据,第二次就崩。对策:临时目录 + 工厂造数 + 强制清理。

反模式三:断言实现细节。 断言函数调用次数、内部状态,重构即红,团队逐渐不敢重构、甚至开始忽略测试。对策:断言可观察行为。

反模式四:跳过映射表直接写。 AI 拿到文本用例直接写代码,写完发现层级选错、漏了场景、mock 了不该 mock 的。对策:先映射后编码,人在映射表上把关。

回退与检查

设施不足先补 fixture,预期冲突回测试设计,无法稳定自动化的项目显式转人工验证;测试变红进入第 12 篇调试流程。人重点审查映射表、关键依赖是否被错误 mock、行为断言、变异检查和数据隔离。

结语

自动化测试的目标不是”代码覆盖率高”,而是”当行为错了,测试会红”。一个空实现也能通过的测试套件,覆盖率 100% 也只是更贵的安慰剂。


测试写好了,一定会有红的时候。红了之后最危险的做法,是让 AI”看着办,修到通过”。下一篇讲测试调试:失败后怎么先保住证据、怎么区分这到底是产品 bug、测试 bug、环境问题还是数据问题、怎么让 AI 基于证据定位而不是靠猜,以及怎么把每次失败沉淀成回归资产,而不是修完就忘。

AI Native SDD 自动化测试 测试代码 测试工程