跳到正文
码上手 coding.chengyu.dev

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

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

约 40 分钟 进阶 Claude CodeVitestPlaywrightCloudflare 核实于 2026-10-25

学完这一课,你能

  • 能为纯逻辑写单元测试,为接口和页面写端到端测试,并分清各自的适用范围
  • 能让智能体在真实浏览器里走完核心流程并给出证据
  • 能配置一个只读的评审子智能体,用「写者与评审者」模式做代码评审
  • 能对照清单检查 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);
});

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

~/projects/my-shelf
# 令牌来自你自己的 .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 和你给的标准,看不到写代码时的推理,从零开始挑错。

做法有两种:

  1. 用现成的评审技能:Claude Code 自带 /code-review,会在全新的上下文里审查当前 diff 的正确性。
  2. 定义自己的评审子智能体:把评审标准固化下来,每次复用。子智能体是 .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 运行时对应的做法,不要凭印象写。

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

读取 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

下一课:上线。代码有测试、有评审,现在把它放到互联网上。

参考来源

工具更新很快,若与官方文档不一致,以官方为准。

学完了吗?

标记完成会记录在本机浏览器,计入你的学习进度和连续天数。

Esc

输入关键词开始搜索,例如「CLAUDE.md」「MCP」「部署」