# 测试与评审：让另一双眼睛挑错

> 把规格里的验收标准变成 Vitest 单元测试和 Playwright 端到端测试，在真实浏览器里验证行为，再让独立会话或子智能体做代码评审与安全检查。

- 网址：https://coding.chengyu.dev/learn/ship/test-review/
- 学习路径：实战：从想法到上线一个网站
- 预计用时：40 分钟；核实日期：2026-10-25
- 官方来源：[Best practices for Claude Code（Anthropic 官方文档）](https://code.claude.com/docs/en/best-practices)，[Create custom subagents（Claude Code 官方文档）](https://code.claude.com/docs/en/sub-agents)，[Use Claude Code with Chrome（Claude Code 官方文档）](https://code.claude.com/docs/en/chrome)，[Testing（Astro 官方文档）](https://docs.astro.build/en/guides/testing/)，[Workers Vitest integration（Cloudflare 官方文档）](https://developers.cloudflare.com/workers/testing/vitest-integration/)，[Write your first test（Cloudflare 官方文档）](https://developers.cloudflare.com/workers/testing/vitest-integration/get-started/write-your-first-test/)，[Installation（Playwright 官方文档）](https://playwright.dev/docs/intro)，[Web server（Playwright 官方文档）](https://playwright.dev/docs/test-webserver)

## 学习目标

- 能为纯逻辑写单元测试，为接口和页面写端到端测试，并分清各自的适用范围
- 能让智能体在真实浏览器里走完核心流程并给出证据
- 能配置一个只读的评审子智能体，用「写者与评审者」模式做代码评审
- 能对照清单检查 SQL 注入、XSS、越权和密钥泄露

到现在为止，书单「能跑」。但「能跑」的意思只是：你亲手点过几下，没看到报错。真正的风险藏在你没点到的地方：私有书在某个页面意外露出、AI 返回了怪格式、令牌校验漏了一个接口。

人工检查有两个缺点：不可重复，且会被「我刚写的，肯定没问题」蒙蔽。这一课建立三道防线：**自动化测试**、**真实浏览器验证**、**独立评审**。

## 先想清楚：测什么、用什么测

测试不是越多越好。按这个项目的结构，各层的分工是：

| 层 | 工具 | 测什么 | 书单里的例子 |
|---|---|---|---|
| 单元测试 | Vitest | 不依赖运行环境的纯逻辑 | `parseAiResult`、`parseBookInput` |
| 端到端测试 | Playwright | 通过真实 HTTP 与浏览器走完流程 | 无令牌 401、私有书不出现在首页 |
| 真实浏览器验证 | 智能体 + 浏览器工具 | 测试覆盖不到的体验与视觉 | 暗色模式、中文输入、控制台报错 |
| 评审 | 独立会话 / 子智能体 | 逻辑漏洞、越权、安全 | 每个写接口都鉴权了吗 |

注意一个现实限制：`src/lib/auth.ts` 导入了 `cloudflare:workers`，这个模块只存在于 Workers 运行时，普通的 Node 单元测试跑不起来。有两种办法：**把它交给端到端测试覆盖**（本课的做法，简单），或者使用 Cloudflare 的 Workers Vitest 集成在 Workers 运行时里跑测试。后者需要的包是 `@cloudflare/vitest-plugin`（要求 Vitest 4.1 以上）。官方文档提示：如果你以前用过 `@cloudflare/vitest-pool-workers`，现在推荐迁移到这个插件，写教程时请以官方最新页面为准。

## 单元测试：从最容易出错的纯函数开始

AI 返回内容的解析函数是整个项目最该测的地方：它的输入完全不可控。安装并配置：

```bash
npm install -D vitest
```

```ts
// vitest.config.ts

export default defineConfig({
  test: { include: ["src/**/*.test.ts"] },
});
```

Astro 官方也提供了 `getViteConfig()` 用来让 Vitest 继承 Astro 的配置。但我们要测的函数不依赖 Astro 的任何特性，用最朴素的配置更不容易被适配器影响。

```ts
// src/lib/ai-parse.test.ts

describe("parseAiResult", () => {
  it("能解析被代码块包裹的 JSON", () => {
    const text = '```json\n{"summary":"一本关于时间的书","tags":["效率","成长"]}\n```';
    expect(parseAiResult(text)).toEqual({ summary: "一本关于时间的书", tags: ["效率", "成长"] });
  });

  it("非 JSON 内容返回 null，而不是抛错", () => {
    expect(parseAiResult("抱歉，我无法回答这个问题")).toBeNull();
  });

  it("标签最多保留 5 个并去掉空白项", () => {
    const r = parseAiResult('{"summary":"x","tags":["a"," ","b","c","d","e","f"]}');
    expect(r?.tags).toEqual(["a", "b", "c", "d", "e"]);
  });

  it("过长的摘要被截断到 80 字以内", () => {
    const r = parseAiResult(JSON.stringify({ summary: "字".repeat(200), tags: [] }));
    expect(r?.summary.length).toBeLessThanOrEqual(80);
  });
});
```

好测试的特点：**每条测试名都是一句人话**，失败时一眼知道哪条规则被破坏了。让智能体写测试时，务必要求它先列出打算覆盖的行为，你审过再写代码，不然它会写出一堆只是「把实现再抄一遍」的测试。

## 端到端测试：把验收标准逐条变成断言

回到第 1 课的规格。验收标准里「无令牌返回 401」「私有书不出现在公开页」，现在就能变成 Playwright 测试：

```bash
npm init playwright@latest
```

按提示选 TypeScript，浏览器按需安装。然后配置它自己启动服务。Astro 官方建议对着构建后的产物测，这样更接近线上：

```ts
// playwright.config.ts

const baseURL = "http://localhost:4321";
if (!baseURL.startsWith("http://localhost")) throw new Error("E2E 只允许对本地服务运行");

export default defineConfig({
  testDir: "./e2e",
  webServer: {
    command: "npm run build && npm run preview",
    url: baseURL,
    reuseExistingServer: !process.env.CI,
    timeout: 120 * 1000,
  },
  use: { baseURL },
});
```

开头那行 `throw` 是故意的保险：**端到端测试会写数据，绝不能指到线上地址**。

```ts
// e2e/books.spec.ts

const TOKEN = process.env.ADMIN_TOKEN ?? ""; // 由你的 shell 提供，不写进测试文件
const auth = { Authorization: `Bearer ${TOKEN}` };

test("没有令牌不能新增书", async ({ request }) => {
  const res = await request.post("/api/books", { data: { title: "x" } });
  expect(res.status()).toBe(401);
});

test("公开书出现在首页，私有书不出现", async ({ request, page }) => {
  const pub = `公开-${Date.now()}`;
  const priv = `私有-${Date.now()}`;
  await request.post("/api/books", { headers: auth, data: { title: pub, isPublic: true } });
  await request.post("/api/books", { headers: auth, data: { title: priv, isPublic: false } });

  await page.goto("/");
  await expect(page.getByText(pub)).toBeVisible();
  await expect(page.getByText(priv)).toHaveCount(0);
});

test("私有书的详情页返回 404", async ({ request }) => {
  const res = await request.post("/api/books", { headers: auth, data: { title: "秘密", isPublic: false } });
  const { id } = await res.json();
  expect((await request.get(`/book/${id}`)).status()).toBe(404);
});
```

测试数据用时间戳命名，避免多次运行互相干扰。运行前，在你自己的终端设置令牌并执行：

```text
# 令牌来自你自己的 .dev.vars，只在当前终端会话里使用
$ export ADMIN_TOKEN="$(grep '^ADMIN_TOKEN=' .dev.vars | cut -d= -f2-)"
$ npx wrangler d1 migrations apply my-shelf-db --local
$ npx vitest run
$ npx playwright test
  3 passed
# 失败时打开报告看截图与追踪
$ npx playwright show-report
```

> **测试通过不等于做对了**
>
> 智能体写的测试有一个老毛病：为了让测试通过，反过来改断言。看到「测试修好了」的汇报，要看 diff 里改的是**实现**还是**测试**。如果改的是测试，必须有一个说得通的理由。

## 在真实浏览器里验证

自动化测试擅长回归，不擅长发现「没想到」的问题。每个阶段完成后，让智能体用浏览器工具（桌面应用的浏览器面板，或 `claude --chrome`，设置方法见上一课）走一遍真实流程，并读取控制台：

- 在管理页新增一本书，刷新后还在吗？
- 过几秒刷新，摘要和标签出现了吗？
- 切到暗色、再切回来，有没有闪白？
- 控制台有没有报错或警告？网络面板里有没有失败的请求？

关键的一条要求是：**要证据，不要结论**。官方最佳实践也强调，让智能体展示测试输出、命令及其结果或截图，而不是只说「已验证」，审证据比自己重新验证快得多。

## 让另一个会话来评审

写代码的会话有认知偏差：它记得自己为什么这么写，所以会下意识为自己的实现辩护。官方推荐的「写者与评审者」模式很简单：**开一个新的会话或子智能体，它只看 diff 和你给的标准，看不到写代码时的推理**，从零开始挑错。

做法有两种：

1. **用现成的评审技能**：Claude Code 自带 `/code-review`，会在全新的上下文里审查当前 diff 的正确性。
2. **定义自己的评审子智能体**：把评审标准固化下来，每次复用。子智能体是 `.claude/agents/` 下的一个 Markdown 文件，只有 `name` 和 `description` 必填：

```markdown
---
name: shelf-reviewer
description: 审查书单项目的改动是否符合 SPEC.md，重点关注正确性、越权和安全。
tools: Read, Grep, Glob, Bash
model: sonnet
---
你是严格的代码评审员，审查对象是当前分支相对 main 的改动。
依据 SPEC.md 的验收标准逐条核对，只报告会影响正确性或安全的问题，
不要提纯风格偏好。每个问题给出：文件与行号、复现方式、严重程度、建议修法。
Bash 仅用于运行 git diff、git log 和测试，不要修改任何文件。
```

`tools` 列出的是它能用的工具，这里给了 `Bash` 是为了让它运行 `git diff` 和测试，「不要修改文件」只是提示约束，不是硬性限制。想要硬隔离，去掉 `Bash` 并把 diff 直接贴给它。

用起来只需一句：「用 shelf-reviewer 子智能体审查当前分支。」之后把结果交给写代码的会话处理。要留意官方提醒的一点：**被要求找问题的评审者几乎总会找出些问题**，一味照单全收会导致过度设计。所以评审提示里要写明「只报告影响正确性和需求的缺口」，其余的当作可选建议。

## 常见安全检查

书单项目虽小，但公开在互联网上，下面这张清单值得逐项过：

| 风险 | 怎么检查 | 本项目的做法 |
|---|---|---|
| SQL 注入 | 搜索所有 `prepare(`，看有没有把用户输入拼进字符串 | 一律 `bind()`；仅固定片段拼接 |
| XSS | 搜索 `set:html`、`innerHTML` | 书名、短评、AI 摘要都用默认插值，自动转义 |
| 越权（写接口） | 列出所有 POST、PATCH、DELETE，确认第一行就是鉴权 | `isAdmin` 在最前面，端到端测试覆盖 401 |
| 私有数据泄露 | 每一个读取路径（列表、详情、筛选）是否都带 `is_public = 1` 条件 | 管理员才能看全部，详情页对私有书 404 |
| 密钥泄露 | `git log -p -S "ADMIN_TOKEN"`，检查 `.gitignore` | `.dev.vars` 被忽略，线上用 secret |
| 输入过大 | 每个字段是否有长度上限 | 校验函数限制标题 200 字等 |
| 费用滥用 | 谁能触发 AI 调用 | 只有已鉴权的新增接口会调用 |
| 跨站请求伪造 | 鉴权是否依赖 Cookie | 用 `Authorization` 头，不用 Cookie，天然不受影响 |
| 依赖漏洞 | `npm audit` | 定期运行，高危再处理 |

一个值得追问的细节：`isAdmin` 里令牌是用 `===` 比较的，严格的做法是常数时间比较，防止通过响应时间推测令牌。个人项目风险很低，只要令牌足够长就不用担心，但如果要改，先让智能体去查 Cloudflare 官方文档里 Workers 运行时对应的做法，不要凭印象写。

**为书单项目补全测试并做独立评审**

```text
读取 SPEC.md 的「验收标准」，为书单项目补全自动化测试，分三步，每步都先给我看计划再动手。

第一步，单元测试：
- 安装 vitest，配置只包含 src/**/*.test.ts；
- 为 src/lib/ai-parse.ts 和 src/lib/validate.ts 写测试，先列出你要覆盖的行为（包括 AI 返回代码块包裹的 JSON、纯文本、空对象、超长摘要、标签含非字符串），等我确认再写。

第二步，端到端测试：
- 用 `npm init playwright@latest` 初始化，配置 webServer 先构建再预览，baseURL 必须是 localhost，并在配置里加保护：非本地地址直接抛错；
- 令牌从环境变量 ADMIN_TOKEN 读取，绝不写进文件，也不要读取 `.dev.vars`；
- 为验收标准里的每一条写一个测试，至少覆盖：无令牌 401、私有书不出现在首页与公开接口、私有书详情 404、AI 失败时书仍保存（可以通过对接口传入异常输入来模拟，若无法模拟请告诉我，不要硬造）。

第三步，真实验证：
- 运行全部测试，把完整输出贴给我；任何测试失败时，先判断是实现错还是测试错，说明理由再改，禁止为了通过而削弱断言；
- 用浏览器工具走一遍：新增一本书、刷新、等待摘要出现、切换暗色，并读取控制台错误。附上截图或日志作为证据。

最后，用 shelf-reviewer 子智能体（若不存在请先按我的描述创建在 .claude/agents/）审查当前分支，只报告影响正确性与安全的问题。
```

> 小测：下面哪个功能最适合用普通 Vitest 单元测试覆盖？

> 小测：让写代码的同一个会话来评审自己的改动，为什么效果往往不如开一个新会话或子智能体？

## 动手任务

- [ ] 为 `ai-parse.ts` 与 `validate.ts` 写单元测试，`npx vitest run` 通过
- [ ] 用 Playwright 把至少 3 条验收标准变成端到端测试，且配置里有「只允许本地地址」的保护
- [ ] 让智能体在真实浏览器里走一遍新增、刷新、暗色切换，并拿到控制台与截图证据
- [ ] 创建 `.claude/agents/shelf-reviewer.md`，在新会话里完成一次评审，按严重程度处理问题
- [ ] 对照安全清单，亲自 grep 一遍 `set:html`、`prepare(`，并运行 `git log -p -S ADMIN_TOKEN` 确认没有泄露

下一课：[上线](/learn/ship/deploy/)。代码有测试、有评审，现在把它放到互联网上。
