建立分层的测试体系,确保代码质量可量化、回归风险可控,在开发速度和可靠性之间取得平衡。
- 项目初始化时确定测试策略
- 编写新功能时同步编写测试
- 修复 Bug 时补充回归测试
- 重构前确保测试覆盖
- 发布前运行完整测试套件
/ E2E \ 少量(关键用户路径)
/ 集成测试 \ 适量(模块间交互)
/ 单元测试 \ 大量(函数/组件级)
| 层级 | 占比 | 速度 | 覆盖目标 |
|---|---|---|---|
| 单元测试 | 70% | 极快(ms) | 函数、工具类、业务逻辑 |
| 集成测试 | 20% | 中等(s) | API 端点、数据库交互、模块协作 |
| E2E 测试 | 10% | 慢(10s+) | 关键用户流程(注册/下单/支付) |
什么必须写单元测试:
- 业务逻辑函数
- 工具/辅助函数
- 数据转换/验证逻辑
- 状态管理逻辑
- 边界条件多的函数
测试命名规范:
格式:{被测函数} + {场景} + {期望结果}
示例:
✓ calculateTotal_withDiscount_returnsDiscountedPrice
✓ validateEmail_invalidFormat_throwsValidationError
✓ createUser_duplicateEmail_returnsConflictError
测试结构(AAA 模式):
describe('calculateTotal', () => {
it('should apply 10% discount for orders over 100', () => {
// Arrange(准备)
const items = [{ price: 120, quantity: 1 }];
// Act(执行)
const result = calculateTotal(items);
// Assert(断言)
expect(result).toBe(108);
});
});测试原则:
- 每个测试只验证一件事
- 测试之间无依赖、无顺序要求
- 使用 mock/stub 隔离外部依赖
- 测试行为,不测试实现细节
覆盖目标:
- API 端点:请求 → 处理 → 响应 → 数据库
- 数据库操作:CRUD + 事务 + 约束
- 模块间调用:服务 A 调用服务 B
API 测试模板:
describe('POST /api/v1/users', () => {
it('should create user with valid data', async () => {
const res = await request(app)
.post('/api/v1/users')
.send({ email: 'test@example.com', password: 'Pass123!' });
expect(res.status).toBe(201);
expect(res.body.data).toHaveProperty('id');
});
it('should return 422 for invalid email', async () => {
const res = await request(app)
.post('/api/v1/users')
.send({ email: 'invalid', password: 'Pass123!' });
expect(res.status).toBe(422);
});
});数据库测试:
- 使用测试数据库(不是生产库)
- 每个测试用事务包裹,测试后回滚
- 或使用 factory 生成 + 测试后清理
只覆盖关键路径(不要多):
- 用户注册 → 登录 → 核心操作 → 退出
- 下单/支付流程
- 关键业务流程的 happy path
E2E 原则:
- 数量少但覆盖关键路径
- 使用真实浏览器(Playwright/Cypress)
- 测试用户可见的行为,不测试内部实现
- 失败时自动截图/录像
- 不在 CI 中频繁运行(慢),发布前运行
策略:
- 单元测试:内联数据(直接在测试中定义)
- 集成测试:Factory/Fixture 生成
- E2E:Seed 脚本 + API 创建
Factory 示例:
const createUser = (overrides = {}) => ({
name: 'Test User',
email: `user${Date.now()}@test.com`,
password: 'TestPass123!',
...overrides,
});原则:
- 测试数据不依赖外部状态
- 每个测试独立创建自己需要的数据
- 不使用生产真实数据
- 测试后清理(不留垃圾数据)
| 层级 | 最低覆盖率 | 说明 |
|---|---|---|
| 核心业务逻辑 | 90% | 支付/权限/核心算法 |
| 一般业务代码 | 70% | 常规 CRUD/服务 |
| 工具函数 | 80% | 公共 utils |
| UI 组件 | 不强制 | 重点测交互逻辑 |
| 整体项目 | 70% | 底线 |
覆盖率不是目标,是参考:
- 100% 覆盖率 ≠ 没有 Bug
- 关注关键路径和边界条件
- 不要为了覆盖率写无意义的测试
# 测试执行策略
on-push:
- lint(秒级)
- 单元测试(分钟级)
- 集成测试(分钟级)
on-pr:
- 上述全部
- 覆盖率检查(不低于当前值)
before-release:
- 上述全部
- E2E 测试
- 性能基准测试(可选)底座 = app/src/test/motionHarness.ts(局部桩 + 确定性推进;不是全局 setup —— src/test/setup.ts 不加 matchMedia 桩)。下游引用下列条目时逐字照抄:
- 确定性推进只有一个正解:
gsap.timeline({ paused: true })+tl.time(t)(底座 =freezeAt(tl, t))。实测逐字精度:power2tween(dur 0.5、x: 0 → 100)在tl.time(0.25)⇒translate3d(87.5px, 0px, 0px)。 - 禁用四个假正解:
gsap.updateRoot(t)(globalTimeline._start会漂移)·gsap.ticker.tick()(墙钟驱动)·gsap.ticker.sleep()(新建 tween 会同步唤醒它)·await sleep()/ 真实定时器 / fake timers(不可复现)。 - 可中断 / 覆盖类判据必须双断言:同时断 tween 计数(
tweenCount(el)/gsap.globalTimeline.getChildren().length,或旧 tween 的totalTime()冻结)与currentTransform(el)—— GSAP 3 默认overwrite: false,覆盖同属性时旧 tween 仍在跑,只看style.transform会假绿。 matchMedia桩必须实现addListener/removeListener:jsdom 30 没有window.matchMedia,而 GSAP 走 legacy 分支(gsap-core.js:4078),只实现addEventListener的桩不会被调用。桩是用例级的:谁装谁restore()。- 绝不可把 jsdom 的
performance挂到globalThis(Performance-impl.js:14自调用 ⇒ 栈溢出打挂进程);GSAP 用例全同步,不需要await。 tl.to()返回 Timeline 本身,不是 Tween —— 要 tween 句柄用tl.to(...).getChildren()或gsap.to。
依据与「可测 / 不可测」的完整边界见动效规范的「判据纪律(可测与不可测)」。
R-FLAKE(判「某红是 flake」的三条件,缺一 ⇒ 只写「未判定」):① ≥3 次重复读数 ② 与某个可观测量的相关性(如 transform 耗时 / 缓存冷热)③ 该文件不在本次写集内的证据。
WARM-CACHE:全量 vitest 冷 / 热两次都要跑、两次读数都登记;不得只贴一次「0 failed」就当全量无红。
P9(并行假红):门禁与变异体实验一律串行;任何并行跑出来的红不得当缺陷登记,其签名必须带测试名 + 超时阈值 + 错误串形态并注明「串行复跑通过」;Rust 侧与 JS 侧分开列。
仪器:scripts/viewport-probe.mjs。批 8 T12 把批 3 造在 gitignored tmp/ 里的视口探针收编入库(ADR-034:109 逐字承认过「这条判据的仪器不入库」—— 本部分补的就是那个洞)。它加载真实构建产物 app/dist(本地只读静态服务器 + 真 CDP 精密视口),跑「顶栏自然宽 / Tab 越界 / 纵向溢出」三类判据,并按需读任意选择器的解算样式与几何(--probe,判据参数化)。
调用形态(CLI 逐字):
node scripts/viewport-probe.mjs --width 1024 --height 640 --dist app/dist [--port 9490] \
[--json <out.json>] [--probe '<selector>:<cssProp>'] [--screenshot <out.png>]
退出码:0 = ②③ 判据与自检全过;1 = 有溢出或自检失败;2 = 产物缺失 / 端口失败 / profile 失败
- 扩展(可选):
--dpr 1·--reduced-motion no-preference|reduce·--mode http|file。--probe可重复;<cssProp>收解算样式属性(含--custom-prop,camelCase 一并收)与几何名rect|x|y|w|h;输出路径按仓库根解析(给绝对路径最稳)。 - 🔴 正式入口与触发条件(何时必须跑)由 T24 追加 —— 本部分只登记仪器形态与盲区(U4 裁为 d:入库 + 按需入口 + 写死触发条件;不接 husky、不进 CI)。
- 🔴 正式入口(按需;T24 追加):
cd app; npm run check:visual(=node ../scripts/viewport-probe.mjs --width 1024 --height 640)。🔴 npm script 的 cwd = 包目录 ⇒ 命令里的相对路径要从app/出发(../scripts/…),--dist走默认app/dist。要别的档位就自己拼命令(--width/--dpr/--probe/--json都只在 CLI 上),--json/--screenshot的路径给绝对路径最稳(P-27)。⚠️ 本机 npm 用 pwsh 包装 stderr ⇒ 失败时先看到的是 4 行 npm/pwsh 包装噪声(npm warn …与NativeCommandError各占两行)⇒ 🔴 真正的具名报文在最后一行(别把包装噪声当成报文)。 - 🔴 触发条件(写死;U4-d 的核心) —— 凡命中下面任一条,提交前必须跑一次
cd app; npm run check:visual:① 改了app/src/ui/primitives/**(最多见);② 改了 token 面(app/src/ui/tokens.css·tokens.ts· 生成物tokens.gen.ts· 生成器app/scripts/gen-tokens.mjs);③ 改了任何.css(git ls-files app/src里.css后缀现为 17 件,含shell/TopBar.css与各视图 css);④ 改了顶栏结构 / 布局(shell/TopBar.tsx·views/**的容器布局);⑤ 改了会改观感的全局样式或主题绑定。按需入口 = 有正式形态 + 明确触发条件,不是自动门禁。 - 🔴 跑前必须真构建(否则读数无效):
cd app; npm run build在前 —— 分钟级,且串行 / 独占窗口(承第九部分的 P9:与全量测试、变异体实验都不得并发)。 - 🔴 不进 husky、不进 CI(用户裁决 U4-d;b/c 未选):
.husky/pre-commit与.github/workflows/**都不接它。理由:① 它要真实构建产物 ⇒ 接进 pre-commit 等于每次提交都付一次构建(数十秒到数分钟),且必须串行(同上);② CI 这边没有执行者:CI checkout 不构建 ⇒ 该仪器在 CI 上恒红(实测:git archive导出树里app/dist不存在、app/node_modules不存在 ⇒ exit 2,报文❌ 找不到产物入口 … 先跑「cd app; npm run build」)。🔴 代价必须写明:本仪器没有自动执行者 —— 全靠改动者按上面的触发条件自觉跑;npm run check:visual绿只说明「本次构建产物在该视口下全过」,不是「门禁已覆盖」。
盲区第 2 组 · 入口面的三条(T24 追加;引用读数时必须与上面五条一起复述):
- 🔴 陈旧产物不报错(P-37 同族:
--dist指向陈旧产物可假绿):仪器只在 stdout /--json里打印dist mtime+树 HEAD(= 跑仪器时的 HEAD),不比较app/src与app/dist的 mtime,也不比较 dist 是哪个 HEAD 构建的 ⇒ dist 陈旧时它照跑,并可能 exit 0。⇒ 🔴 纪律:跑之前必须先npm run build;跑完必须核 stdout 的dist mtime/树 HEAD是否就是本次的态(拿两次读数做差前还要走上面的 §17 受控对比:两侧 dist mtime 与 HEAD 相同)。 - 🔴 无 IPC 的真产物上,两条 markdown 链读不到(具名盲区,不是缺陷):
preview_session_note/refine_workbench/diff_markdown_ops要 IPC ⇒ 真产物里NotePreviewView停在<Loading>、RefineWorkbench失败退出 ⇒ 两链的 DOM 根本不可达。T13 实测(task-13-report.md:280):两链 18 个探针matched全部 0,而同一跑的阳性面正常 —— 顶栏存在=true(Tab8个 · 自然宽613.53)、problems=[]、exit 0⇒ 🔴 不能把「exit 0」读成「这两条链验过了」(同一批探针在 T13 的夹具页上 16/18matched=1⇒ 「0」是可达性,不是选择器写错)。 - 🔴 跨提交不比字节:仪器不做任何产物字节 / 哈希的跨提交对比(那是
check-bundle-budget.mjs的域)⇒ 观感面的「变了吗」只能靠同 dist mtime + 同 HEAD 的两次读数说(否则是非受控对比)。
- 🔴 解算值必须来自
getComputedStyle:每条--probe读数自带viewport/dpr/emulatedMedia三项元数据(缺 ⇒ 不得当判据),并附一条同代码路径的 canary(html的font-size,恒为 px);canary 取不到 px ⇒ 仪器报红(防「把解算值换成读内联element.style」这类假读数)。
前置(三条硬要求):
- 🔴 必须是真实构建产物:先
cd app; npm run build(--no-build语义不适用于本仪器),并在报告里登记app/dist/index.html的 mtime。 - 🔴 独占窗口 + 串行:与全量测试 / 变异体实验不得并发(承 P9);仪器会起本地 HTTP 服务与 headless Edge,并发会让两侧读数互为假红。
- 🔴 §17 受控对比纪律:读数绑定 dist 的时点与树(stdout 与
--json都带dist.entry_mtime+tree_head)⇒ 拿两次读数做差前必须先证明两侧 dist mtime 与 HEAD 相同;否则该差只能作「上界 / 存在性」证据,并显式声明它是非受控对比。
盲区(五条;引用读数时必须逐条复述):
- 🔴 它验的是 WebView2 / Chromium 的渲染引擎,不是 IPC / 窗口层 ⇒ 不可替代真机冒烟:无头引擎不覆盖 Tauri IPC 真链路、窗口装饰、真实字体回退与真机 DPI ⇒ 凡 headless / jsdom 读数一律不得写成「真机验证通过」(U5 沿用「跳过真机」,7 条真机项继续登记、不假装完成)。
- 🔴 headless 默认
prefers-reduced-motion: reduce⇒ 仪器必须显式调Emulation.setEmulatedMedia(默认no-preference;--reduced-motion reduce反测降级路径);不显式设置 ⇒ 动效类读数全部失真(自检里验「实测值 == 参数」)。 - 🔴 headless 滚动条占位 = 0(与真机不同)⇒ 依赖滚动条宽度的读数不可用(姊妹件
review-t1-t6/scrollbar-cdp.mjs4,688 B / 105 行专测此面,批 8 未收编,登记为将来收编对象)。 - 🔴 必须用
Emulation.setDeviceMetricsOverride定视口:--window-size=800实测innerWidth=776;批 8 T12 的 M2 变异体实测--window-size=1024,640⇒innerWidth=1000⇒ 视口自检红(这条自检不是装饰)。 - 🔴
--dump-dom的 stdout 抓不到(实测 0 字节)⇒ 读数只走 CDPRuntime.evaluate或Page.captureScreenshot;且因无window.__TAURI__,各页 IPC 全失败 ⇒ 「整页无横向滚动」只能是参考项。
profile 卫生(硬要求,非选项):browser profile 必须落 $env:TEMP(mkdtempSync 造唯一目录)且跑完删除(finally 里删,异常路径同删)。🔴 落仓内会一次喷进 1,241 文件 / 32.4 MB(批 3 陷阱 #19;批 8 侦察阶段又复现过一次 —— 仪器自己警告过的坑)。tmp/)时 git status 看得见;落在已 gitignore 的目录(如 .superpowers/**)时 git status 看不见 ⇒ 卫生判据必须同时给「仓内文件数前后相同 + 全树 profile 名搜索为 0」(T12 的 V1 判据即为此)。
零安装:本机 Edge 152.0.4191.66 + WebView2 152.0.4191.66 + Node 24 内建 WebSocket 已够用 ⇒ 不得引入任何新依赖、不得 npm install。为什么不用 tauri-driver / Playwright / vitest browser:都要装,且本机有 TLS 拦截史(Cargo.toml:146-148)⇒ cargo install 很可能失败;它们多给的只是 IPC 真链路 = 真机范畴(用户已裁跳过真机)。
判据分层(强度不同,引用时必须带层):① documentElement.scrollWidth <= clientWidth(整页无横向滚动)—— 仅供参考;② 顶栏自然宽 natural_w <= clientWidth —— 判据;③ 逐个 Tab getBoundingClientRect().right <= innerWidth —— 判据;④ 仪器自检(视口 innerWidth === --width · dpr === --dpr · 500px 定块 · 文本哨兵 · 阳性对照已知 id 必须命中 1 · 阴性对照每次现造的随机串必须 0 命中 · emulatedMedia 实测值 == 参数)—— 判据。
- 测试金字塔比例合理(单元 > 集成 > E2E)
- 核心业务逻辑有单元测试
- API 端点有集成测试
- 关键用户路径有 E2E 测试
- 测试命名清晰(函数+场景+期望)
- 测试之间无依赖
- 使用 mock 隔离外部依赖
- 测试数据独立、可重复
- 覆盖率达到最低要求
- CI 中自动运行测试
| 输出物 | 格式 | 存放位置 |
|---|---|---|
| 单元测试 | 代码 | tests/unit/ 或 tests/ |
| 集成测试 | 代码 | tests/integration/ |
| E2E 测试 | 代码 | tests/e2e/ |
| 测试配置 | 配置文件 | vitest.config.ts / jest.config.js |
| 覆盖率报告 | HTML/lcov | coverage/(gitignore) |
| 误区 | 正确做法 |
|---|---|
| 只测 happy path | 边界/异常/空值同样重要 |
| 测试实现细节 | 测试行为和输出 |
| 测试之间有依赖 | 每个测试独立可运行 |
| E2E 测试太多 | E2E 只覆盖关键路径 |
| 追求 100% 覆盖率 | 关注关键逻辑,不追求数字 |
| 先写代码后补测试 | 理想是 TDD,至少同步写 |