测试与评审:让另一双眼睛挑错
把规格里的验收标准变成 Vitest 单元测试和 Playwright 端到端测试,在真实浏览器里验证行为,再让独立会话或子智能体做代码评审与安全检查。
学完这一课,你能
- 能为纯逻辑写单元测试,为接口和页面写端到端测试,并分清各自的适用范围
- 能让智能体在真实浏览器里走完核心流程并给出证据
- 能配置一个只读的评审子智能体,用「写者与评审者」模式做代码评审
- 能对照清单检查 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 返回内容的解析函数是整个项目最该测的地方:它的输入完全不可控。安装并配置:
npm install -D vitest
// vitest.config.ts
import { defineConfig } from "vitest/config";
export default defineConfig({
test: { include: ["src/**/*.test.ts"] },
});
Astro 官方也提供了 getViteConfig() 用来让 Vitest 继承 Astro 的配置。但我们要测的函数不依赖 Astro 的任何特性,用最朴素的配置更不容易被适配器影响。
// src/lib/ai-parse.test.ts
import { describe, it, expect } from "vitest";
import { parseAiResult } from "./ai-parse";
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 测试:
npm init playwright@latest
按提示选 TypeScript,浏览器按需安装。然后配置它自己启动服务。Astro 官方建议对着构建后的产物测,这样更接近线上:
// playwright.config.ts
import { defineConfig } from "@playwright/test";
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 是故意的保险:端到端测试会写数据,绝不能指到线上地址。
// e2e/books.spec.ts
import { test, expect } from "@playwright/test";
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);
});
测试数据用时间戳命名,避免多次运行互相干扰。运行前,在你自己的终端设置令牌并执行:
# 令牌来自你自己的 .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 在真实浏览器里验证
自动化测试擅长回归,不擅长发现「没想到」的问题。每个阶段完成后,让智能体用浏览器工具(桌面应用的浏览器面板,或 claude --chrome,设置方法见上一课)走一遍真实流程,并读取控制台:
- 在管理页新增一本书,刷新后还在吗?
- 过几秒刷新,摘要和标签出现了吗?
- 切到暗色、再切回来,有没有闪白?
- 控制台有没有报错或警告?网络面板里有没有失败的请求?
关键的一条要求是:要证据,不要结论。官方最佳实践也强调,让智能体展示测试输出、命令及其结果或截图,而不是只说「已验证」,审证据比自己重新验证快得多。
让另一个会话来评审
写代码的会话有认知偏差:它记得自己为什么这么写,所以会下意识为自己的实现辩护。官方推荐的「写者与评审者」模式很简单:开一个新的会话或子智能体,它只看 diff 和你给的标准,看不到写代码时的推理,从零开始挑错。
做法有两种:
- 用现成的评审技能:Claude Code 自带
/code-review,会在全新的上下文里审查当前 diff 的正确性。 - 定义自己的评审子智能体:把评审标准固化下来,每次复用。子智能体是
.claude/agents/下的一个 Markdown 文件,只有name和description必填:
---
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 运行时对应的做法,不要凭印象写。
读取 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 单元测试覆盖?
让写代码的同一个会话来评审自己的改动,为什么效果往往不如开一个新会话或子智能体?
动手任务
动手清单
0/5下一课:上线。代码有测试、有评审,现在把它放到互联网上。
参考来源
- Best practices for Claude Code(Anthropic 官方文档)
- Create custom subagents(Claude Code 官方文档)
- Use Claude Code with Chrome(Claude Code 官方文档)
- Testing(Astro 官方文档)
- Workers Vitest integration(Cloudflare 官方文档)
- Write your first test(Cloudflare 官方文档)
- Installation(Playwright 官方文档)
- Web server(Playwright 官方文档)
工具更新很快,若与官方文档不一致,以官方为准。
学完了吗?
标记完成会记录在本机浏览器,计入你的学习进度和连续天数。