模块五:工程质量与生产部署 | 第02讲:测试分层与自动化——能跑不等于能交付,回归测试方案
本讲定位:用测试金字塔把 VibeNote 从「我本地点过没问题」升级为「每次改代码都有安全网」;覆盖 Vitest 单测、Playwright E2E、AI 产出代码的验证策略与测试驱动迭代。
技术栈:Next.js 14、TypeScript、Vitest、Playwright(与现有生态兼容)。
延伸阅读:课程内容/6.3、reference/advanced/09-testing-automation.md(回归、API/E2E、CI 自动化)。
一、为什么「能跑」不等于「能交付」
典型故事:你给 VibeNote 加了「星标笔记」,本地点点没问题,朋友第二天说「搜索空白」。你查代码发现:新功能复用了公共查询模块,一处改动误伤全局。你修搜索,路由又坏;修路由,星标又坏——三天打地鼠。
自动化测试的价值不是「证明没有 bug」(不可能),而是:
- 回归保护:改 A 时立刻知道 B、C 是否仍满足契约。
- 可执行规格:测试即文档,描述系统允许的行为边界。
- 重构底气:没有测试的重构叫「改运气」;有测试叫「改结构」。
- 协作契约:PR 上红绿结果比「我这边是好的」可信一万倍。
在 Vibe Coding 场景下,你还要面对生成代码的漂移:模型今天写的实现与明天「再优化一版」可能行为不一致——测试是锚定行为的压舱石。
flowchart TB
subgraph Pyramid["测试金字塔(推荐比例示意)"]
U[单元测试 多]
I[集成测试 中]
E[E2E 测试 少而关键]
end
U --> I
I --> E
二、测试金字塔在 VibeNote 的落地映射
2.1 单元测试(Vitest)
适合:纯函数、Zod schema、lib/ 里的笔记 slug 生成、标签解析、Markdown 预处理、权限判断(canEditNote(user, note))。
原则:
- 快速、无 I/O:默认不连真实数据库;需要时用手工 mock 或 test double。
- 一个用例一个断言簇:失败时能秒定位。
- 边界值:空标题、超长正文、非法 UTF-8、并发重复标签。
2.2 集成测试
适合:Route Handler + Drizzle + 测试数据库(或 pg-mem 类方案);验证「HTTP 入参 → DB 状态」闭环。
对个人项目可裁剪:用 E2E 覆盖主路径,单元覆盖核心业务规则,集成测试选 1~2 条最贵的事故链。
2.3 E2E(Playwright)
适合:登录 → 新建笔记 → 列表可见 → 搜索命中 → 删除;以及「未登录访问 /app 被重定向」。
原则:
- 少而关键:E2E 脆弱且慢,只保留「产品敢不敢上线」级路径。
- 稳定选择器:
data-testid优于脆弱 CSS;避免依赖动画完成时机——用expect(...).toBeVisible({ timeout })。 - 测试数据隔离:独立测试用户或每次运行前清理 fixture。
sequenceDiagram
participant Dev as 开发者
participant CI as GitHub Actions
participant Vitest as Vitest
participant PW as Playwright
participant App as VibeNote Preview
Dev->>CI: push / PR
CI->>Vitest: pnpm test
CI->>PW: pnpm e2e
Vitest-->>CI: 单元结果
PW->>App: 打开浏览器会话
PW-->>CI: E2E 结果
三、Vitest 最小接入(可运行配置)
3.1 安装
pnpm add -D vitest @vitejs/plugin-react jsdom @testing-library/react @testing-library/jest-dom
3.2 vitest.config.ts
// vitest.config.ts
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
import path from "path";
export default defineConfig({
plugins: [react()],
test: {
environment: "jsdom",
setupFiles: ["./vitest.setup.ts"],
globals: true,
},
resolve: {
alias: {
"@": path.resolve(__dirname, "."),
},
},
});
3.3 vitest.setup.ts
// vitest.setup.ts
import "@testing-library/jest-dom/vitest";
3.4 业务函数与单测示例
// lib/note-title.ts
export function normalizeNoteTitle(raw: string): string {
const t = raw.trim().replace(/\s+/g, " ");
if (t.length === 0) throw new Error("EMPTY_TITLE");
if (t.length > 120) throw new Error("TITLE_TOO_LONG");
return t;
}
// lib/note-title.test.ts
import { describe, it, expect } from "vitest";
import { normalizeNoteTitle } from "./note-title";
describe("normalizeNoteTitle", () => {
it("trims and collapses spaces", () => {
expect(normalizeNoteTitle(" hello world ")).toBe("hello world");
});
it("rejects empty", () => {
expect(() => normalizeNoteTitle(" ")).toThrowError("EMPTY_TITLE");
});
it("rejects too long", () => {
expect(() => normalizeNoteTitle("x".repeat(121))).toThrowError("TITLE_TOO_LONG");
});
});
package.json 增加:
{
"scripts": {
"test": "vitest run",
"test:watch": "vitest"
}
}
四、Playwright:守住 VibeNote 的「黄金路径」
4.1 安装与初始化
pnpm add -D @playwright/test
pnpm exec playwright install
4.2 playwright.config.ts(示意)
// playwright.config.ts
import { defineConfig } from "@playwright/test";
export default defineConfig({
testDir: "./e2e",
timeout: 60_000,
use: {
baseURL: process.env.E2E_BASE_URL ?? "http://127.0.0.1:3000",
trace: "retain-on-failure",
},
webServer: {
command: "pnpm dev",
url: "http://127.0.0.1:3000",
reuseExistingServer: !process.env.CI,
},
});
4.3 一条可运行的 E2E(按你认证方案替换登录步骤)
// e2e/smoke.spec.ts
import { test, expect } from "@playwright/test";
test("首页可访问", async ({ page }) => {
await page.goto("/");
await expect(page.getByRole("heading", { name: /VibeNote/i })).toBeVisible();
});
// 若尚未接真实登录,可先跳过或 mock:此处强调「结构可运行」
test.skip("黄金路径:创建笔记后出现于列表", async ({ page }) => {
await page.goto("/app");
await page.getByTestId("note-title-input").fill("E2E 笔记");
await page.getByTestId("note-save").click();
await expect(page.getByText("E2E 笔记")).toBeVisible();
});
落地建议:先给关键按钮加
data-testid,再让 AI 按测试补全交互——这是典型的 Vibe Coding + 测试先行契约。
五、测试 AI 生成代码:别信「看起来对」
推荐工作流:
- 先写验收:用例描述用户可观察行为(Given/When/Then)。
- 让 AI 写实现:提示词附上「必须通过以下测试文件」。
- 红绿重构:测试红 → 迭代实现 → 绿 → 手工探索边界。
- 冻结契约:对公共模块禁止无测试重构。
反模式:
- 让 AI「顺便写点测试」但从不运行 CI——测试会沦为摆设。
- E2E 里塞满实现细节断言(像素、内联样式)——脆弱到每次改 UI 都崩。
flowchart LR
A[需求/验收描述] --> B[测试用例]
B --> C[AI 生成实现]
C --> D[本地/CI 运行]
D -->|红| C
D -->|绿| E[Code Review / 人工探索]
六、测试驱动迭代(TDD 的轻量版)
你不必教条式「先写测试后写代码」,但建议对 VibeNote 的三类核心强制 TDD-lite:
- 计费/限额(AI 摘要次数)
- 权限(用户只能操作自己的笔记)
- 数据完整性(标签多对多关系)
流程:
- 写一个失败用例(描述 bug 或新规则)
- 最小修改让测试通过
- 删除重复结构,保持可读
七、CI 中的位置(预告与片段)
在 GitHub Actions 中并行跑 pnpm test 与 pnpm exec playwright test;E2E 任务加缓存浏览器二进制。完整 YAML 将在后续「部署与自动化」中与本讲串联——此处给出最小骨架:
# .github/workflows/ci.yml(片段)
jobs:
unit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- run: pnpm install
- run: pnpm test
七点五、测试数据策略:fixture、种子库与隔离
E2E 最怕「互相踩数据」:A 测试删了 B 测试的笔记。推荐三层策略:
- 每个用例自带前缀:标题使用
e2e-${Date.now()}-${test.info().parallelIndex},避免并行冲突。 - 独立测试账号:Preview 环境创建
e2e_user@example.com(或 OIDC test user),与真实用户隔离。 - 可重置环境:每日定时清空测试库,或在 CI 使用 ephemeral database(Neon branch)。
对 VibeNote,这意味着:不要把生产库直接给 E2E。最低限度也要使用 单独的 schema 或单独的数据库实例。
七点六、Flaky 测试:为什么会「时而红时而绿」
常见根因:
- 竞态:未等待网络空闲就断言。
- 动画/过渡:元素短暂不可点击。
- 时间依赖:
new Date()导致快照不一致。 - 外部依赖:AI 接口抖动。
对策:
- Playwright:
await expect(locator).toBeVisible({ timeout: 15000 }) - 对 AI:E2E 使用 mock 或 固定 seed;把非确定性挡在集成测试之外。
- 重试:CI 层有限重试 + 本地优先修根因(不要无限重试掩盖问题)。
七点七、API 测试与契约:Next Route Handler 怎么测
你有三条路:
- 直接调用 handler 导出函数(若你把它拆成纯函数 + 薄路由层)。
- 起本地 server 用
fetch打(更像黑盒)。 - 用 MSW 拦截(偏前端)。
对 VibeNote,建议把「参数解析 + 授权 + 业务服务」下沉到 services/notes.ts,Route Handler 只做 HTTP 映射——这样 Vitest 覆盖核心业务无需启动服务器。
七点八、测试覆盖率:数字不是目的,风险覆盖才是
覆盖率(lines/branches)适合作为趋势指标,不适合作为唯一 KPI。更实用的问法是:
- 「用户最痛的失败」有没有 E2E?
- 「最复杂的权限规则」有没有单测?
- 「最容易被 AI 改坏的纯函数」有没有单测?
当你用 AI 大规模生成代码时,优先给 高风险模块加测试,而不是追求全局 90% 覆盖率。
七点九、与 CI 成本博弈:什么时候该砍 E2E?
如果出现:
- PR 平均等待超过 15 分钟且主要卡在 E2E
- E2E 失败多半是基础设施而非业务
你可以:
- 把全量 E2E挪到 nightly;PR 只跑 smoke。
- 并行分片(shard)Playwright。
- 缓存浏览器二进制与
pnpm store。
七点十、测试清单:VibeNote 发布前 15 分钟手工补充
即使自动化再完善,也建议保留短清单:
- 手机窄屏:新建笔记键盘遮挡吗?
- 离线:是否有清晰提示(若不做离线,也要提示网络错误)?
- 粘贴大段文本:是否触发体长限制与友好错误?
- 复制分享链接:是否需要登录才能访问(若未来做分享)?
八、回归测试方案:每周 30 分钟的「性价比套餐」
| 频率 | 内容 | 工具 |
|---|---|---|
| 每次 commit | 单元测试 | Vitest |
| 每个 PR | 单元 + 1 条 E2E 冒烟 | Vitest + Playwright |
| 发布前 | 全量 E2E + 手工探索 10 分钟 | Playwright + 清单 |
回归清单(手工补充):多语言输入法、移动端视口、离线弱网(若 PWA)、AI 供应商超时降级文案。
九、思考题
- 为什么测试金字塔强调「E2E 要少」?如果全是 E2E,团队会在哪里浪费最多时间?
- 你会把
drizzle查询测到哪一层:单元、集成还是 E2E?理由是什么? - 当 AI 重写了一个函数并通过所有测试,但仍然「产品感觉不对」,问题更可能出在测试的哪类缺陷?
- Playwright 选择器策略中,
data-testid与aria-label各有什么优劣?
十、本节小结
- 回归是持续交付的敌人,自动化测试是最便宜的保险。
- Vitest 守住规则与纯逻辑;Playwright 守住用户路径。
- 对 AI 生成代码,测试即契约,没有契约的生成等于抽奖。
- 用 CI 红线把「能跑」翻译成「可交付」。
十一、下讲预告
第03讲:日志与排障——日志到底怎么写才有用?效率提升的底层方法
当测试告诉你「红了」,日志告诉你「为什么红」。下一讲我们讨论结构化日志、日志级别、Sentry 错误追踪与生产环境最小可观测性闭环,让 VibeNote 的线上问题从「猜」变成「查」。
十二、深度附录:把测试当作产品承诺的对齐工具
你说「笔记不会丢」,就要有 E2E 证明刷新仍在;你说「搜索可靠」,就要覆盖大小写、空格、标签组合。把产品话术翻译成可验证句子,backlog 才会可执行。Snapshot 适合稳定组件,不适合频繁改动的编辑器页面;业务页用角色与可访问性断言更稳。没有测试的重构像不打包搬家;先锁定行为再改实现。Contract testing 未来拆服务再用,现阶段记住「接口是契约」。八周路线:先 Vitest 三个函数,再权限边界,再 Playwright smoke,再登录创建,再搜索,再 CI 单测,再 CI E2E,最后对齐发布清单。警惕假绿:断言太弱与过度 mock。让 AI 写测试时要求「先 acceptance 再实现,不许删测试降绿」。fixture 用合成数据,录屏外发要脱敏。测试与日志、Git、部署相连,模块五是整体。测试是把我相信换成我验证。
课后动作:为 normalizeNoteTitle 同级函数补 3 个你最担心出 bug 的纯函数测试,并在 PR 模板加勾选项:pnpm test 已通过。
补篇:Playwright 调试三板斧
PWDEBUG=1 逐步执行;trace: on-first-retry 留证;本地与 CI 用同一 baseURL。遇到 flaky,先稳定选择器与等待条件,再谈重试。把最脆的用例单独标记 @slow 或 nightly,别拖垮 PR。
补篇:测试命名规范
用例名写成完整句:当标题为空时应拒绝创建并返回 400。未来你在 CI 日志里一眼能懂失败语义,而不是 test1 failed。
补篇:与可访问性(a11y)测试的交界
getByRole 同时逼迫你把按钮与表单做好语义化,这是测试与体验的双赢。VibeNote 的编辑器若可,给工具栏按钮加 aria-label。
补篇:性能测试要不要上?
早期不必上 k6 压测;但当 AI 摘要成为主成本,应对单用户并发与超时做轻量脚本即可。别把性能测试与功能测试混在同一超长流水线里。
补篇:失败分类
构建失败、测试失败、部署失败、外部平台失败——在 PR 评论里分类记录,能显著减少互相指责。工程文化是测试文化的一部分。
补篇:结语
测试是长期资产,短期像慢,长期像快。你愿意为每次合并付五分钟测试税,还是为每次事故付五小时救火税?
精读延展:工程化的「慢变量」
工程化里真正改变命运的往往是慢变量:习惯、流程、清单、自动化。它们不像新功能那样在演示视频里闪闪发光,却决定你在第六个月还能不能持续交付。VibeNote 这类产品的竞争,表面是功能,底层是可靠性与迭代效率。把每一讲的知识点写成你可执行的规则文件,比收藏一百篇文章更有用。
精读延展:独立开发者的「风险预算」
你不可能同时买到所有保险,所以要明确风险预算:本周最不能承受的是数据泄露、服务不可用、还是成本失控?把预算花在对应加固上,其他的先记录为「已知风险」。这比假装自己什么都防住了更诚实,也更专业。
精读延展:从教程到产品的距离
教程代码默认跑在 happy path;产品代码默认会遇到蠢问题、坏网络、恶意输入与误操作。模块五的意义,就是把你从教程态推进到产品态:你会开始问「如果失败呢?」「如果被攻击呢?」「如果同事离职呢?」这些问题不浪漫,但决定你能不能靠作品吃饭。
精读延展:与用户的信任契约
用户把笔记交给你,是信任契约。契约内容包括可用性、隐私、透明度与纠错机制。工程措施是契约的底层支撑:备份、监控、安全响应、版本回滚。你可以把契约写得很短,但不能心里没有。
精读延展:把知识变成肌肉记忆
看完专栏不等于学会。请把本模块至少三条规则落实进仓库:例如 .env 纪律、一个 E2E、一个 requestId。肌肉记忆来自重复执行,而不是重复阅读。
精读延展:面向下一阶段的接口
当你完成模块五,你就为增长、商业化、协作功能留下了「干净的接口」:你不会在广告接入时才发现安全头拦了一切;也不会在招聘兼职时才发现没有测试与 PR 规范。提前支付成本,是在给未来打折。
终篇延展:VibeNote 测试用例目录(建议)
e2e/auth.spec.ts 登录与重定向;e2e/notes-crud.spec.ts 创建编辑删除;e2e/search.spec.ts 搜索与空态;e2e/ai.spec.ts 摘要成功或降级。单元测试按模块分目录:lib/、services/、utils/。把目录结构写进贡献指南,避免测试文件散落各处难以发现。
终篇延展:Mock 的边界
Mock 掉数据库会让测试变快,但也会让你对真实 SQL 行为盲目自信。折中策略:核心业务用少量集成测试,外围用 mock。对 AI 供应商,永远准备 固定响应 的 mock,避免 CI 依赖外网。
终篇延展:测试与产品文档的双向链接
在每个用户故事 issue 里贴对应测试文件路径;在测试文件头注释里贴 issue 编号。六个月你会感谢自己。
终篇延展:失败截图与视频
Playwright failure 时保存 trace,能显著缩短「我本地复现不了」的扯皮时间。注意 traces 可能含敏感数据,artifact 上传范围要控制。
终篇延展:测试债务
允许临时跳过测试,但必须建 issue 标明原因与截止日期。否则跳过会变成永久禁用。
终篇延展:从 0 到 1 的最低配置
如果你今天只能写一条测试,请写「登录后能创建笔记并在列表看到」。它覆盖鉴权、写库、读库、渲染,是性价比之王。
诵读延展 1
把专栏知识映射成仓库里的真实改动,才算完成学习闭环。把专栏知识映射成仓库里的真实改动,才算完成学习闭环。把专栏知识映射成仓库里的真实改动,才算完成学习闭环。把专栏知识映射成仓库里的真实改动,才算完成学习闭环。把专栏知识映射成仓库里的真实改动,才算完成学习闭环。把专栏知识映射成仓库里的真实改动,才算完成学习闭环。把专栏知识映射成仓库里的真实改动,才算完成学习闭环。把专栏知识映射成仓库里的真实改动,才算完成学习闭环。
诵读延展 2
独立开发最怕的是‘看过等于会了’;用 issue 与 commit 给自己留证据。独立开发最怕的是‘看过等于会了’;用 issue 与 commit 给自己留证据。独立开发最怕的是‘看过等于会了’;用 issue 与 commit 给自己留证据。独立开发最怕的是‘看过等于会了’;用 issue 与 commit 给自己留证据。独立开发最怕的是‘看过等于会了’;用 issue 与 commit 给自己留证据。独立开发最怕的是‘看过等于会了’;用 issue 与 commit 给自己留证据。独立开发最怕的是‘看过等于会了’;用 issue 与 commit 给自己留证据。独立开发最怕的是‘看过等于会了’;用 issue 与 commit 给自己留证据。
诵读延展 3
工程习惯是复利:第一周痛苦,第二个月麻木,第六个月碾压。工程习惯是复利:第一周痛苦,第二个月麻木,第六个月碾压。工程习惯是复利:第一周痛苦,第二个月麻木,第六个月碾压。工程习惯是复利:第一周痛苦,第二个月麻木,第六个月碾压。工程习惯是复利:第一周痛苦,第二个月麻木,第六个月碾压。工程习惯是复利:第一周痛苦,第二个月麻木,第六个月碾压。工程习惯是复利:第一周痛苦,第二个月麻木,第六个月碾压。工程习惯是复利:第一周痛苦,第二个月麻木,第六个月碾压。
诵读延展 4
别低估文档:未来的你是团队里最贵的合作者。别低估文档:未来的你是团队里最贵的合作者。别低估文档:未来的你是团队里最贵的合作者。别低估文档:未来的你是团队里最贵的合作者。别低估文档:未来的你是团队里最贵的合作者。别低估文档:未来的你是团队里最贵的合作者。别低估文档:未来的你是团队里最贵的合作者。别低估文档:未来的你是团队里最贵的合作者。
诵读延展 5
安全、测试、日志、协作、部署,是同一套‘可交付’语言的五种方言。安全、测试、日志、协作、部署,是同一套‘可交付’语言的五种方言。安全、测试、日志、协作、部署,是同一套‘可交付’语言的五种方言。安全、测试、日志、协作、部署,是同一套‘可交付’语言的五种方言。安全、测试、日志、协作、部署,是同一套‘可交付’语言的五种方言。安全、测试、日志、协作、部署,是同一套‘可交付’语言的五种方言。安全、测试、日志、协作、部署,是同一套‘可交付’语言的五种方言。安全、测试、日志、协作、部署,是同一套‘可交付’语言的五种方言。
诵读延展 6
当你能向非技术合作者解释清楚回滚路径,你的工程能力已经进阶。当你能向非技术合作者解释清楚回滚路径,你的工程能力已经进阶。当你能向非技术合作者解释清楚回滚路径,你的工程能力已经进阶。当你能向非技术合作者解释清楚回滚路径,你的工程能力已经进阶。当你能向非技术合作者解释清楚回滚路径,你的工程能力已经进阶。当你能向非技术合作者解释清楚回滚路径,你的工程能力已经进阶。当你能向非技术合作者解释清楚回滚路径,你的工程能力已经进阶。当你能向非技术合作者解释清楚回滚路径,你的工程能力已经进阶。
诵读延展 7
产品价值=功能×可靠性×迭代速度;模块五主要拉升后两项。产品价值=功能×可靠性×迭代速度;模块五主要拉升后两项。产品价值=功能×可靠性×迭代速度;模块五主要拉升后两项。产品价值=功能×可靠性×迭代速度;模块五主要拉升后两项。产品价值=功能×可靠性×迭代速度;模块五主要拉升后两项。产品价值=功能×可靠性×迭代速度;模块五主要拉升后两项。产品价值=功能×可靠性×迭代速度;模块五主要拉升后两项。产品价值=功能×可靠性×迭代速度;模块五主要拉升后两项。
诵读延展 8
遇到争议,用数据与日志说话,不要用音量说话。遇到争议,用数据与日志说话,不要用音量说话。遇到争议,用数据与日志说话,不要用音量说话。遇到争议,用数据与日志说话,不要用音量说话。遇到争议,用数据与日志说话,不要用音量说话。遇到争议,用数据与日志说话,不要用音量说话。遇到争议,用数据与日志说话,不要用音量说话。遇到争议,用数据与日志说话,不要用音量说话。