Home
avatar

.Sam

【AI Native开发-02】一句需求到可靠交付,中间要过哪四道闸门?

这是“AI Native开发”系列的第 2 篇。前两篇分别讨论了 AI Native 开发改变什么,以及 AI 为什么会把未决策的问题直接做成代码。本篇完整走一遍“删除草稿文章”的交付过程。

**案例说明:**本文的删除接口、鉴权、Vitest/Playwright 测试均为一个假想全栈项目的示意产物,不是本博客当前仓库已经存在或已经执行的功能。真实 Astro 静态博客的删除操作应通过本地 CMS/Git 工作流完成,不能把下文的 API 和测试结果当作本仓库事实。

30 秒看懂这篇

同一句“删除文章”,在不同阶段会暴露不同问题:删除规则没定、存储方式猜错、实现越界、测试只测返回码。四道闸门的价值,是让这些问题尽量在更便宜的阶段暴露。

flowchart TB
    A["一句需求"] --> B{"Gate 1 · Spec<br/>正确行为定义了吗?"}
    B -->|通过| C{"Gate 2 · Plan<br/>现状与影响面核清了吗?"}
    C -->|通过| D{"Gate 3 · Implement<br/>改动守在边界内吗?"}
    D -->|通过| E{"Gate 4 · Verify<br/>每条需求有证据吗?"}
    E -->|通过| F["可交付变更"]
    B -. "规则缺口" .-> B
    C -. "架构误判" .-> C
    D -. "越界修改" .-> D
    E -. "证据缺口" .-> E

本文给出 Spec、Plan、文本用例、自动化用例、需求追踪矩阵和 Verify 报告的教学示例,并用一次假想失败演示归因。可直接复用的是方法与模板,不是未经执行验证的项目结果。

一句需求,为什么还不能开始写代码

需求只有一句:

给博客后台增加删除文章功能。

如果让 AI 直接执行,它可能同时修改文章列表、内容文件、构建索引和删除接口。真正的问题不是它能不能写,而是:它有没有被明确授权删除什么,以及谁能删除。

下面让这个需求依次经过四道闸门。每道闸门都回答两个问题:现在能不能继续?如果不能,缺什么?

Gate 1:Spec,先把“正确行为”写出来

初始 Spec

功能:删除文章

角色:管理员可以删除未公开(hide: true)文章;普通用户和访客不能删除。
状态:已公开(hide 为 false 或缺省)文章不能直接删除。
交互:删除前必须二次确认;用户取消时不发起请求。
安全:服务端再次校验登录状态和管理员权限。
失败:删除失败时保留列表状态,并提供重试提示。
非目标:本次不做回收站、恢复、批量删除和审计日志。

Spec 准入与准出

准入:有明确用户问题、目标角色、业务场景和约束。仅有“优化一下”不能进入。

准出:

[ ] 角色和目标明确
[ ] 正常、异常和边界流程明确
[ ] 权限和数据规则明确
[ ] 每条规则可转换成测试
[ ] 非目标明确
[ ] 重要规则有 REQ 编号
[ ] 两个人可以得出相同验收结论

把规则编号后,Spec 才能成为后续工作的索引:

REQ-001 管理员可以删除未公开(hide: true)文章
REQ-002 未登录请求返回 401;已登录但非管理员返回 403;两者均无副作用
REQ-003 已公开(hide 为 false 或缺省)文章不能直接删除,返回 409
REQ-004 取消确认不发请求
REQ-005 删除后重新构建仍不存在
REQ-006 删除失败保留页面状态
REQ-007 目标不存在返回 404,重复删除无额外副作用

如果“删除”到底是物理删除还是归档仍未决定,Gate 1 应该拒绝放行,而不是让 AI 自己选。

Spec 是仓库里的文件,不是聊天记录

这一点常被忽略:Spec 写在对话框里,下一个会话就没了;Spec 写进仓库,才能被 diff、被评审、被追溯。

specs/
  article-delete/
    spec.md          # 上面这份,含 REQ 编号
    plan.md          # 下一节产出
    tests.md          # 文本用例
    verify.md         # 验证证据与结论

判断标准很简单:如果换一个人(或换一个 AI 会话)接手,他能不能只靠仓库里的文件复现同样的验收结论。 做不到,说明 Spec 还停留在聊天记录里。

Gate 2:Plan,先研究当前项目再动手

AI 先阅读当前项目,而不是凭经验生成方案。对这个博客,需要确认:文章是数据库记录还是内容文件?构建时如何生成文章列表?是否存在管理端权限?删除会不会影响 Git 或索引?

一份可执行的 Plan

1. 读取内容集合 schema 和文章状态字段
2. 找到文章列表页及其数据来源
3. 找到当前鉴权入口和管理员判断方式
4. 设计删除接口,只允许未公开/已归档文章
5. 服务端校验权限和文章状态
6. 设计失败响应:401/403/404/409
7. 先生成接口文本用例,再生成自动化测试
8. 实现接口并运行接口测试
9. 增加删除按钮和二次确认
10. 增加端到端测试并运行受影响测试
11. 检查构建、Diff 和文章索引

Plan 阶段拦住的一次错误

第一次分析时,AI 可能假设文章存储在数据库,计划使用 DELETE FROM articles。但代码库实际使用 Markdown 文件和 Git 管理内容。

这就是 Plan Gate 的价值:**它在代码写出来之前暴露了架构假设错误。**正确方案应结合项目架构决定:对本博客是通过本地 CMS/Git 受控地删除或改写内容文件,而不是新增不存在的归档状态。

准入:Spec 已评审,相关代码入口和构建/测试命令已找到。

准出:影响文件、执行顺序、测试计划、风险、回滚方式和每条 REQ 的映射都已明确;没有无关重构。

技术不确定时,先做 Spike 而不是硬写 Plan

上面那个“数据库还是文件”的问题,如果连人也答不上来,Plan 就写不实。这时正确动作是插入一次 Spike:用最小代价验证一个技术问题,代码写完就丢掉。

Spike 目标:确认删除内容文件后,构建能否正常生成文章列表
时间盒:不超过半天
产物:一段结论,不是一份可合并的实现
结束条件:能回答“删除后构建是否报错、索引是否残留”

Spike 的输出会回流成 Spec 或 Plan 的约束条件。Spike 的代码是一次性的,Spike 的结论是永久的。 混淆这两者,就会出现「原型直接上线」这种最常见的事故。

Gate 3:Implement,限制 AI 的动作范围

Plan 审核通过后,不能再用一句“全部实现”把范围重新打开。建议按小任务执行:

任务 1:实现服务端状态和权限判断
任务 2:生成并补齐接口自动化测试
任务 3:实现前端按钮与二次确认
任务 4:生成并调试端到端测试
任务 5:运行受影响测试并检查 Diff

每个任务都要求:只修改批准文件,先看 Diff,再跑对应检查;发现 Spec 与现有代码冲突时暂停。

准入:Spec 和 Plan 已批准,基线构建/测试结果已记录,依赖和环境可用,回滚方式明确。

准出:Plan 步骤完成,代码和测试都已实现,异常路径已覆盖,类型与静态检查通过,没有临时代码、未批准依赖或越界文件。

注意:Implement 准出只表示“可以正式验证”,不表示“已经正确”。

Gate 4:Verify,用证据而不是感觉交付

测试从 Spec 开始产生,Verify 阶段将它们变成证据链:

REQ-001
→ 文本用例 TC-001
→ 自动化用例 e2e/article-delete.spec.ts
→ 执行命令和测试环境
→ 日志、截图、响应和结果
→ 人工验收
→ Verify 结论

文本用例示例

TC-001 管理员删除未公开文章
Given 当前用户是管理员,目标文章为未公开状态(hide: true)
When 管理员确认删除
Then 删除请求成功,文章从列表消失

TC-002 普通用户越权删除
Given 当前用户不是管理员
When 调用删除接口
Then 返回 403,文章仍然存在

TC-003 取消删除
Given 删除确认弹窗已打开
When 用户点击取消
Then 不发起删除请求,文章仍然存在

TC-004 删除已发布文章
Given 目标文章为已公开状态(hide 为 false 或缺省)
When 管理员确认删除
Then 返回 409,文章仍可访问

自动化用例:从文本用例到可执行代码

TC-002(越权删除)生成的接口测试大致如下。它值得完整贴出,因为其中几个细节决定了这个测试是否真的有效:

// api/article-delete.test.ts
import { describe, it, expect, beforeEach } from "vitest"
import { createArticle, loginAs, cleanup, api, readArticleFromStore } from "./helpers"

describe("DELETE /api/articles/:id", () => {
  beforeEach(async () => {
    await cleanup()
  })

  it("TC-002 普通用户越权删除返回 403,文章仍存在", async () => {
    const article = await createArticle({ hide: true })
    const token = await loginAs("normal_user")

    // api 是已配置 baseURL 的测试客户端,不依赖原生 fetch 猜测相对地址
    const res = await api.delete(`/api/articles/${article.id}`, {
      headers: { Authorization: `Bearer ${token}` },
    })

    expect(res.status).toBe(403)

    // 直接检查受控测试存储,不用公开 GET 是否存在来推断文件状态
    await expect(readArticleFromStore(article.id)).resolves.toMatchObject({
      id: article.id,
      hide: true,
    })
  })

  it("TC-004 已公开文章不可删除,返回 409", async () => {
    const article = await createArticle({ hide: false })
    const token = await loginAs("admin")

    const res = await api.delete(`/api/articles/${article.id}`, {
      headers: { Authorization: `Bearer ${token}` },
    })

    expect(res.status).toBe(409)
    await expect(readArticleFromStore(article.id)).resolves.toMatchObject({
      id: article.id,
      hide: false,
    })
  })
})

这里的关键不是多写一个断言,而是直接验证权威状态:只断言 403 无法区分“被正确拒绝”和“文章已被删后又构造了错误响应”。TC-002 的存储断言对应 REQ-002,TC-004 的存储断言对应 REQ-003。

对应的端到端用例覆盖交互层:

// e2e/article-delete.spec.ts
import { test, expect } from "@playwright/test"

test("TC-003 取消确认不发起删除请求", async ({ page }) => {
  await page.goto("/admin/articles")

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

  await page.getByTestId("article-row-unpublished-1").getByTestId("delete-btn").click()
  await expect(page.getByTestId("confirm-dialog")).toBeVisible()
  await page.getByTestId("confirm-cancel").click()

  // 断言「什么都没发生」,比断言「弹窗关了」更接近 Spec 原意
  expect(deleteCalled).toBe(false)
  await expect(page.getByTestId("article-row-unpublished-1")).toBeVisible()
})

这里用监听网络请求来验证 REQ-004,而不是只检查弹窗关闭。Spec 写的是“取消时不发起请求”,测试就应该断言请求没有发生,两者要逐字对应。

选择器优先使用用户可见的语义契约,例如 getByRole、getByLabel;缺少稳定语义时再使用 data-testid。应避免依赖脆弱 CSS 路径。上面的 data-testid 是示例中的显式测试契约,并非所有场景的唯一正确选择。

生成后必须做的三步

AI 可以根据文本用例生成 Playwright、Vitest 或接口测试,但生成后不能直接信任。至少要经过三步:

  1. 调试:检查选择器、登录态、fixture、测试数据、等待条件和清理逻辑;失败时保留请求响应、截图、视频或 trace。
  2. 执行:先跑单条用例,再跑受影响测试,最后跑完整回归;区分代码失败、断言失败、环境失败和 flaky test。
  3. 审查:确认测试验证的是用户可观察行为,而不是某个私有函数或实现细节;确认断言没有被放宽到“页面没崩”。

例如,若测试只断言“接口返回 204”,还没有证明文章真的消失,也没有证明普通用户不能越权。因此需要同时检查状态变化、权限和页面结果。

分层执行:不是每次都跑全量

写代码时      → 只跑当前改动相关的单元与接口测试(秒级)
提交前        → 跑受影响模块的完整测试 + 类型与静态检查(分钟级)
合并前        → 跑完整回归,包含 E2E(可以接受十几分钟)
发布前        → 冒烟用例 + 人工验收关键路径

分层的目的是让反馈快到能被真正使用。每改一行就跑二十分钟全量回归,结果是没人跑测试。

一次假想失败:业务 Spec 正确,字段映射错了

在这个教学案例中,TC-004(删除已发布文章应返回 409)第一次执行失败:实际返回 204,文章被删掉了。

现象:已公开文章被成功删除,返回 204
排查:实现读取的是 article.status
根因:Plan/Implement 把 Spec 中的 hide 状态错误映射成了不存在的
      article.status;其值为 undefined,状态校验因此被绕过

这个失败该回到哪一层?前文 Spec 已明确 hide: true 为未公开、false 或缺省为公开,所以不是 Spec 状态定义错误,而是 Plan/Implement 的数据模型映射错误。测试在这里正好多做了一次有价值的事:拦住了“实现没有忠实映射契约”的缺陷。

修复顺序是:修正 Plan 的字段映射,再改实现,并保留这条回归测试。如果未来发现 Spec 只写了“已发布不可删”而没有核对数据模型,才应把根因归到 Spec 的事实约束缺口。归因看证据,不把所有上游偏差都笼统算到 Spec 头上。

需求追踪矩阵

Verify 的最终产物不是一句“测试通过”,而是一张能逐条对账的表。以下状态均为假想案例中的填写示例:

REQ规则文本用例自动化状态
REQ-001管理员可删未公开文章TC-001e2e/article-delete.spec.ts通过(示意)
REQ-002未登录返回 401;非管理员返回 403;均无副作用TC-002api/article-delete.test.ts通过(示意)
REQ-003已公开文章不可直接删除TC-004api/article-delete.test.ts通过(修 Plan/实现后)
REQ-004取消不发请求TC-003e2e/article-delete.spec.ts通过(示意)
REQ-005删除后重新构建仍不存在TC-005—人工验收
REQ-006删除失败保留页面状态并提示错误TC-006—人工验收
REQ-007目标不存在返回 404,重复删除无额外副作用TC-007api/article-delete.test.ts通过(示意)

空缺和人工项都要显式写出来。表里出现“—”不可怕,可怕的是表里没有这一行。

flaky 测试:必须当作缺陷而不是噪音

自动化跑起来之后,最消耗信任的不是失败,而是时而失败。一旦团队开始习惯性重跑,自动化的判定能力就归零了 —— 因为没人再相信红色意味着有问题。

常见根因与对应处理:

症状根因处理
偶发元素找不到用了固定 waitForTimeout改为等待具体状态或响应
单跑通过、并行失败用例间共享测试数据每个用例自建数据并清理
换文案后失败选择器依赖文案或 class改用 data-testid
跨天失败断言依赖当前时间注入固定时间或用相对断言
首次运行失败、重跑通过依赖前序用例留下的状态beforeEach 重置

处理原则建议写成硬规则:

flaky 测试的处理顺序:
1. 定位根因并修复(默认动作)
2. 无法立即修复时,标记 skip 并建缺陷单,
   同时在 Verify 报告中显式列为「未覆盖项」
3. 禁止的做法:加重试次数掩盖、删掉断言、直接删测试

第 2 条的要点是降级要留痕。跳过一个用例本身可以接受,但它必须出现在需求追踪矩阵里变成一个可见的空缺,而不是无声消失。

闸门的度量指标

闸门是否真的在工作,需要能被观察。以下指标可按迭代统计:

指标含义健康信号
Gate 1 拒绝率Spec 评审退回比例长期为零时检查评审是否流于形式;成熟稳定期也可能自然降低
缺口发现阶段分布问题在哪个阶段被发现重心持续左移
Spec 后变更率Spec 批准后又改规则的比例逐步下降
计划外文件修改数超出 Plan 范围的改动趋近于零
目标变异检查未拦截数对选定错误实现不敏感的用例数应为零
逃逸缺陷数流程未拦住、生产才暴露持续下降
重跑率因 flaky 重跑的比例先建立基线,再设团队阈值;1% 仅可作初始参考

其中最有诊断价值的是 Gate 1 拒绝率 和 缺口发现阶段分布。前者说明闸门是否在真正判断,后者说明整套流程是否达到了它唯一的目的 —— 让错误更早被发现。如果几个迭代下来,问题重心仍然停在测试阶段和生产环境,那么流程虽然跑了,收益并没有产生。

一个提醒:这些指标用于团队自检,不适合作为个人考核项。 一旦拿来考核,Gate 1 拒绝率会立刻被优化成一个漂亮的数字,而实际判断质量会下降。

Verify 准出

[ ] 每条 REQ 都有文本用例和自动化或人工证据
[ ] 类型、静态、单元、接口和关键 E2E 通过
[ ] 权限、状态、失败和边界场景通过
[ ] 失败、跳过和未执行项有明确解释
[ ] 人工验收通过
[ ] 验证命令、环境和版本可复现
[ ] 已知限制已记录

四道闸门如何减少返工

没有闸门时,错误可能在上线后才暴露:

需求模糊 → AI 猜测 → 改十几个文件 → 测试只测 happy path → 线上误删

有闸门时,错误尽量在更便宜的地方被发现:

Spec Gate:发现删除规则未定义
Plan Gate:发现存储方式被误判
Implement Gate:发现越界修改或缺少权限测试
Verify Gate:发现自动化只测返回码

闸门不是为了让流程更重,而是为了避免用实现成本解决本应在需求阶段解决的问题。

失败后回到哪里

业务规则矛盾或漏写 → 回到 Spec
技术方案不可行或影响范围错误 → 回到 Plan
代码行为不符合已批准方案 → 回到 Implement
选择器、fixture 或断言错误 → 回到测试设计
环境、数据或登录态不稳定 → 回到测试执行
证据不足 → 不能准出 Verify

不要把所有失败都叫作“代码 bug”。一次失败的归因,本身就是软件生产知识的沉淀。

归因时既要检查 Spec 的事实前提,也要避免把下游映射错误误归给 Spec。本文 TC-004 中 Spec 已明确 hide,所以应定位到 Plan / Implement 的字段映射,而不是改坏原本正确的契约。完整的六步决策树见第 15 篇。

归因结果至少回写两处:verify.md 记录失败、根因与修复顺序;docs/pitfalls.md 记录可复用事实和下次行动。本案例应记下“公开状态使用 hide,不要虚构 status”,下一篇会展开这类上下文资产。

如何在现有项目里落地这套流程

一次性把全流程铺开通常会失败。推荐按四步推进,每步都有明确的退出条件:

第一步:只做 Verify(一到两周)。 不改变现有开发方式,只要求每次变更提交验证证据与需求追踪矩阵。这一步几乎不增加阻力,却能立刻暴露测试基建的真实水平。退出条件:关键路径能产出可复现证据。

第二步:加入 Spec(两到四周)。 只对高风险变更要求 Spec(权限、删除、资金、迁移),其余照旧。退出条件:Spec 平均质量分达到 7 分以上,且拒绝率不为零。

第三步:加入 Plan 与范围约束(两到四周)。 要求 AI 先研究代码库产出 Plan,实现阶段不得超出批准文件范围。退出条件:计划外文件修改数趋近于零。

第四步:按模块调整自主级别。 覆盖率与稳定性达标的模块提升到 L3,高风险模块保持 L2。退出条件:逃逸缺陷数持续下降。

顺序刻意从 Verify 开始而不是从 Spec 开始,原因有两个:Verify 的改造成本最低、见效最快,容易建立团队信心;而没有证据能力时,Spec 写得再好也无法闭环 —— 闸门会退化成签字。

结语

从一句需求到一次可靠交付,中间不是多写几份文档,而是不断把猜测变成决定,把决定变成实现,把实现变成证据。

Spec 决定方向,Plan 控制范围,Implement 受控落地,Verify 证明结果。

附录:Verify 报告模板

可直接放入 specs/<feature>/verify.md,本次交付的填写示例如下:

# Verify 报告:删除文章

## 结论
通过,可交付。含 2 项人工验收、0 项未覆盖。

## 需求追踪矩阵
| REQ | 用例 | 自动化 | 结论 |
| REQ-001 | TC-001 | e2e/article-delete.spec.ts | 通过 |
| REQ-002 | TC-002 | api/article-delete.test.ts | 通过 |
| REQ-003 | TC-004 | api/article-delete.test.ts | 通过(修 Plan / 实现后) |
| REQ-004 | TC-003 | e2e/article-delete.spec.ts | 通过 |
| REQ-005 | TC-005 | — | 人工验收通过 |
| REQ-006 | TC-006 | — | 人工验收通过 |
| REQ-007 | TC-007 | api/article-delete.test.ts | 通过 |

## 执行环境(假想全栈项目示例)
提交:<commit sha>
Node 22.x / pnpm 9.x / Playwright 1.4x
命令:pnpm test:api && pnpm test:e2e && pnpm build
构建结果:构建成功(示意)

## 失败与归因记录
TC-004 首次失败:已公开文章返回 204 而非 409
根因:Spec 已定义 hide,Plan/Implement 却错误映射为不存在的 status 字段
归因层级:Plan / Implement(非 Spec)
修复顺序:修正 Plan 字段映射 → 修改实现 → 保留回归用例
已沉淀至 docs/pitfalls.md

## 有效性自检
变异检查:换成“对所有删除都返回成功”的错误实现后,5 条关键用例全部失败(预期)
计划外文件修改:0

## 已知限制
- 未实现回收站与恢复(Spec 非目标)
- 未实现批量删除(Spec 非目标)
- 无审计日志,删除操作不可追溯操作人(建议下一迭代)

模板里最值得保留的两栏是 失败与归因记录 和 已知限制。前者让流程改进有依据,后者让下一个接手的人知道边界在哪 —— 两者都是“测试通过”这三个字无法承载的信息。

下一篇,我们讨论上下文工程:如果 AI 不理解项目的目录、架构和历史决策,再好的 Spec 也可能被错误实现。

AI Native SDD 软件工程 测试工程