第14章 - 测试体系与质量门禁
上一章用”登记点”证明了加一条任务有多便宜,也顺手证明了一件事:便宜的前提是每一处登记都有守卫。这一章把这些守卫逐个拆开——不是为了让你会跑 pnpm verify(那只需要一行),而是为了让你在它变红时知道该信哪一条、不该信哪一条。
14.1 六层门禁,各自能证明什么
这个项目没有”测试覆盖率”指标,取而代之的是六层彼此不可替代的证据:
| 层 | 工具 | 数量 | 能证明 | 不能证明 |
|---|---|---|---|---|
| 类型 | vue-tsc --noEmit |
全仓 strict | 契约、注册表、内容形状在编译期自洽 | 运行时的数值与手感 |
| 风格 | eslint 10 flat | 全仓 | 分层边界没被绕过(外壳不 import full、逻辑层不碰时钟) | 逻辑对不对 |
| 逻辑单测 | Vitest(Node 环境) | 91 | 判定、奖励、衰减、迁移、连续天数、返回键 | 画面、手势、真实浏览器 |
| 内容体检 | content:check |
10 条 × 2 语言 | 关卡脚本能过 schema、语义讲得通、英文零漏翻 | 玩法好不好玩 |
| 端到端 | Playwright | 20 passed / 2 skipped | 真浏览器里能点通、存档真的写了、懒加载真的懒 | 低端机帧率、真机 TTS |
| 体积 | check-size.mjs |
7 项预算 | 首屏与单 chunk 没超预算、资源没偷偷变大 | 运行时内存 |
再加一条不在表里但同样重要的:pack:check。它不装 Rust / JDK / Android SDK,只检查”CI 真跑起来时会不会因为少文件、写错 appId、图标缺一张而失败”(第 15 章详述)。
最后还有一层不属于工具的东西:人。content:check 能拦住”文案里出现失败”,拦不住”这一局的隐喻不适合 4 岁”;e2e 能证明十个玩法都通得了关,证明不了孩子第一次看到会不会懵。每加一条自动守卫时都该问两个问题:它会不会假绿?它失败时给出的信息够不够定位?守卫一旦开始骗人,比没有守卫更糟。
这六层的关系不是”越多越安全”,而是越靠前越便宜:类型错误几秒内出结果,e2e 要起服务器把十个玩法各玩一遍。所以顺序被刻意排成”便宜的在前”,而不是”重要的在前”。
14.2 91 个单测分布在哪里
vitest.config.ts 只做一件事:把三个目录里的 *.test.ts 收进来,跑在 Node 环境。
// vitest.config.ts
export default defineConfig({
test: {
// 纯逻辑层(progress / *.logic.ts / 内容校验)不碰浏览器,Node 环境即可
include: [
'packages/**/src/**/*.test.ts',
'games/**/src/**/*.test.ts',
'apps/**/src/**/*.test.ts',
],
environment: 'node',
},
});
实测分布(按 it( 计数,2026-10-19):
| 文件 | 用例数 | describe | 守什么 |
|---|---|---|---|
packages/progress/src/logic.test.ts |
40 | 11 | 奖励、状态、成长、推荐、连续天数、贴纸、迁移、Manager 编排 |
packages/templates/src/logic.test.ts |
33 | 9 | 八个模板的判定与 schema、模板注册完整性 |
packages/i18n/src/messages.test.ts |
8 | 1 | 中英键集一致、无空串、英文无残留中文、占位符替换 |
packages/content/src/localize.test.ts |
5 | 1 | 中英结构一致、零漏翻、loadTask 按语言返回 |
apps/site/src/shell/backButton.test.ts |
5 | 1 | 安卓返回键:先回小屋、再退出;监听只注册一次 |
这五个文件之外刻意没有测试文件:Vue 组件、Phaser 场景、scripts/*.mjs 都由 e2e 与体积门禁覆盖。将来要给 ProgressManager 补一个「并发写」用例,也应该放进 progress/logic.test.ts,而不是新建一个需要 jsdom 的文件——测试文件的位置会反过来塑造代码的耦合方式。
加起来 91。这个数字值得注意的地方不是多少,而是它们全部能在 Node 里跑:没有 jsdom,没有 canvas 桩,没有”先启动浏览器”。能做到这一点,是因为三条架构铁律里的第一条——逻辑与渲染分离——被执行到了底:packages/progress 与八个 *.logic.ts 都不 import Phaser。
反过来说,91 个单测故意不覆盖三样东西:Phaser 场景的绘制、Vue 组件的模板、真实手势的轨迹。前两样在 jsdom 里只能测出”函数被调用了”,测不出”画面对不对”;第三样必须由 Playwright 在真浏览器里按住、拖动、连点。这不是”没来得及写”,而是分层之后的自觉:用错误的工具测,比不测更危险,因为它会给你一份虚假的安全感。
14.3 可复现是单测的入场券
逻辑层最容易写出的坏测试是”依赖真实时间”。比如测”连续 3 天得贴纸”,如果用例里写 Date.now(),它今天绿、明天可能红,而且没人知道为什么。
项目用两条规则堵住它:
- 所有时间从参数进来。
dayKey(now)、nextStreak(lastDay, today, prev)、decayStats(stats, gap)、migrate(raw, now)—— 纯函数签名里都有now或today; - eslint 直接禁止隐式时钟与随机数:
// eslint.config.js(节选)
{
files: ['packages/progress/src/**/*.ts', '**/*.logic.ts'],
rules: {
'no-restricted-properties': [
'error',
{ object: 'Math', property: 'random', message: '纯逻辑层禁止 Math.random,请注入种子。' },
{ object: 'Date', property: 'now', message: '纯逻辑层禁止 Date.now,请注入 now。' },
],
},
},
于是单测里可以写一个固定的”周一早上”:
// packages/progress/src/logic.test.ts(节选)
/** 本地时间 2026-10-19 09:00(周一早上) */
const T0 = new Date(2026, 9, 19, 9, 0, 0).getTime();
const TODAY = '2026-10-19';
日期相关的用例全部围着 T0 做加减(T0 + 3 * DAY),不再有”跑得快一点就跨天”的偶发失败。同理,ProgressManager 的测试注入一个内存 store,并记录写入次数——写盘次数本身就是一个断言对象(打开一次应用不该写十次盘):
function memoryStore(initial: SaveData | null = null): ProgressStore & { current: SaveData | null; writes: number } {
// 读:返回 current;写:current = data,writes += 1
}
有了 writes,”读档时只在跨天/缺当天记录时才落盘”这条优化才变成可验证的规则,而不是注释里的一句愿望。这也是纯逻辑层测试最划算的地方:它能把性能与正确性写成同一个断言。
还有一条经验来自 backButton.test.ts:被测模块在加载时就执行了 ref(parse(window.location.hash)),所以必须先给一个最小 window 桩,再动态 import:
vi.stubGlobal('window', { location: { hash: '' }, addEventListener: () => {} });
const { installBackButtonHandler } = await import('./backButton');
顺序反了就是 ReferenceError: window is not defined。这条路径以前是 0 覆盖——浏览器里没有原生壳,直到真机上误按返回键把游戏关掉才会发现;现在它有了假桥与”按一次返回键”的 press()。
14.4 content:check:把”内容错误”变成构建期错误
pnpm content:check 跑的是 packages/templates/src/cli/check-content.ts,它读 @bamboo/content/full(中英两份完整内容),做完九类检查:
| # | 检查项 | 失败信息示例 |
|---|---|---|
| 1 | 每条任务能过 TaskDetailSchema |
任务详情 count(zh-CN)不符合 schema:… |
| 2 | 关卡脚本能过所选模板的 schema | count(zh-CN)/ learn 脚本 rounds.0.options:Too small… |
| 3 | 模板语义讲得通 | tidy 的玩具 ball-1(ball)没有对应的箱子 / pick 第 2 轮的选项里没有正确答案 |
| 4 | 任务数量与 TASK_IDS 一致 |
任务数量不对:期望 11,实际 10 |
| 5 | 早 / 中 / 晚各至少 2 条 | 早上时段只有 1 个任务(至少 2 个,保证有得选) |
| 6 | 8 个模板都被真实内容用到 | 模板 rhythm 没有被任何任务使用(要么用起来,要么从契约里删掉) |
| 7 | 中英标题各自唯一 | 中文标题重复:"刷牙" 同时属于 brush 与 brush2 |
| 8 | 英文零残留中文 + 标题不等于中文 | count 的英文内容里还有中文:levelScript.rounds[0].question |
| 9 | 文案无伤害性词汇 | 中文禁词 失败 / 惩罚 / 笨蛋 / 输了 / 淘汰;英文 fail / stupid / loser / punish |
第 3 类最值得看源码,因为它是”schema 管形状,这里管讲不讲得通”的分界:
// packages/templates/src/cli/check-content.ts(节选)
if (task.template === 'tidy') {
const bins = script.bins as { kind: string; label: string }[];
const items = script.items as { kind: string; id: string }[];
for (const item of items) {
if (!bins.some((bin) => bin.kind === item.kind)) {
fail(task.id + ' 的玩具 ' + item.id + '(' + item.kind + ')没有对应的箱子');
}
}
if (new Set(bins.map((bin) => bin.kind)).size !== bins.length) {
fail(task.id + ' 的箱子类别重复');
}
}
一个 kind: 'car' 的玩具配了三个球类箱子,schema 完全合法,但这一关必然通不了——这类错只有语义检查能抓。同理:吃饭的 needKinds 必须有对应食物、穿衣的 slot 不能重复、睡觉的步骤 id 不能重复、情绪每轮选项必须包含答案且不重复。
第 8 类检查用的是 packages/content/src/localize.ts 的 findCjk():递归遍历合并后的英文内容,返回仍然含中文的字段路径。它比”人眼扫一遍”可靠的地方在于它会检查 levelScript——英文补丁里只翻了 intro、忘了翻关卡脚本里的 question,是最容易漏的一种。
一个已知的缺口要记住:learn 的 answer 没有上界校验(第 13 章提过)。content:check 目前不查它,所以新内容最好自己补一条断言——门禁的价值在于它拦住的错,也在于你知道它不拦什么。
14.5 Playwright:共享基线 + 四套用例
e2e/shared.ts 是四份配置的共同基线,它只放”证据策略、节奏、超时”,差异留在各自的配置文件里:
// e2e/shared.ts(节选)
export const baseline = {
testDir: './e2e',
// 每条用例自己开 page/context;实测 2 个 worker 会放大长按/时序类断言的不稳定
fullyParallel: false,
workers: 1,
expect: { timeout: 20_000 },
reporter: [['list']],
use: {
baseURL: devBaseURL,
trace: 'retain-on-failure',
screenshot: 'only-on-failure',
},
};
为什么坚持 workers: 1:这里的用例大多数在等 Phaser 的 6 秒自动通关、等 3 秒长按、等 420ms 的 popIn 动画。并发不是加速,而是把”偶发红”变成常态。串行反而更好定位问题,这是踩过之后的选择,不是默认值。retries 则相反:本地 0、CI 2(process.env.CI ? 2 : 0)。本地不重试是刻意的——偶发失败应该被看见并修掉,而不是被重试掩盖;CI 上重试两次是给「真的偶发」留余地。重试是为了发布不被卡住,不是为了假装稳定。
四套用例的分工:
| 文件 | 用例数 | 证明什么 | 关键手法 |
|---|---|---|---|
smoke.spec.ts |
12 | 首页可达;10 个玩法 autoplay 全通关、任务卡打勾、存档计数 +1;刷新后进度保留 | 数据驱动:for (const id of TASK_IDS) |
flows.spec.ts |
3 | 真实指针也能通关:刷牙(按住来回刷 + 冲水)、整理(全部玩具拖进对应箱子)、睡觉(先点错再按序) | 坐标来自 layout.ts 与内容 JSON,不写魔法数字 |
parent.spec.ts |
3 | 短按标题不进家长页、长按 3 秒进;声音开关写 bamboo.muted 且刷新记得;清档后回初始值且还能玩 |
longPress(page, locator, 3200) |
language.spec.ts |
2 | 英文标题/副标题/角色名/任务卡与时长单位都切;?lang=en 刷新保持;切回中文即时生效;英文玩法仍能通关 |
localStorage 'bamboo.locale' |
preview.spec.ts |
2 | 生产产物里 10 个玩法各有独立 chunk、首屏不引用任何 game-* 与 vendor-phaser;进哪个玩法才下载哪个 chunk |
由 playwright.preview.config.ts 在 5191 跑 |
12 + 3 + 3 + 2 + 2 = 22。pnpm e2e 的结果是 20 passed / 2 skipped:preview.spec 只在 baseURL 含 :5191 时执行,否则 test.skip()——因为拿 dev server 的请求去断言 /assets/ 产物只会得到一堆毫无意义的红。跳过是显式的,不是”没跑就算过”。
smoke.spec.ts 里有两个细节值得抄:
/** autoplay 打完一局并等外壳收尾 */
async function playAutoplayToEnd(page: Page, id: TaskId): Promise<void> {
await gotoPlay(page, id, 'autoplay=1');
await expect(page.getByTestId('play-view')).toBeVisible({ timeout: 30_000 });
await waitTaskComplete(page, id, 40_000);
await waitEventPrefix(page, 'session:exit:', 20_000);
await waitPlayClosed(page, 20_000);
}
- 只等
task:complete不够:外壳还要写存档、发奖励、卸载 PlayView。先等session:exit,再等play-view消失,才去读存档——否则会读到完成前的那份快照,出现”偶发少 1 次完成”的假失败; - 完成标记认两种实现(专门的勾
task-done-<id>或星级文案task-stars-<id>),但星级文案必须真的含 1–3 或星形字符——”元素存在”不算数。
flows.spec.ts 的三条用例则专门守”不算太早完成”:
| 用例 | 中途断言 | 防的是什么 |
|---|---|---|
| 刷牙 | 刷干净但还没冲水时,events 里不许出现 task:complete:brush |
真实操作退化成看动画 |
| 整理 | 拖到第 3 件时还不许完成(模板要求全部归位) | “拖一件就算完成”的假阳性 |
| 睡觉 | 先点最后一步,必须出现 task:retry 且不许完成 |
点错被当成进度推进 |
这三条比”能通关”更值钱:能通关只证明有一条路,中途断言证明的是唯一那条路。而它们的坐标全部来自 @bamboo/templates/layout 与内容 JSON,布局改了只需要改一处。
14.6 调试桥:让 e2e 不靠”运气点得准”
window.__bamboo 是这个项目对自动化最友好的一处设计。形状固定为三样:
// apps/site/src/shell/debug.ts
export interface BambooDebugBridge {
/** 事件序列:'session:start' / 'scene:enter:<scene>' / 'task:complete:<id>' … */
events: string[];
/** 最新存档快照(每次存档变化都换成一个新对象) */
save: SaveData;
/** 最近一次载入失败的技术详情 */
lastError?: string;
}
三条纪律:
- 启动即挂(
initDebugBridge),因为 e2e 可能在用户点任何东西之前就读它; - 事件是扁平字符串(
'task:complete:brush'),断言不用关心 payload 类型,也不受SaveData迁移影响; - 存档每次变更换成新对象,界面自动重渲染,测试也能直接比较”前一份 / 后一份”。
生产环境也开着它——它只有事件名与存档快照,不含个人信息,也不需要联网。真正需要防的是调试开关,所以 createGameContext.ts 里有一道门:
// apps/site/src/shell/createGameContext.ts(节选)
function debugAllowed(): boolean {
if (import.meta.env.DEV) return true;
return typeof navigator !== 'undefined' && navigator.webdriver === true;
}
没有这道门,一个带 ?autoplay=1 的分享链接就能自动通关把星星写进存档。同一个开关既是测试基础设施,也是产品漏洞,这种地方必须显式判断环境。
等待事件用的是 page.waitForFunction 而不是 expect.poll,原因写在注释里:轮询在 Node 侧,一次往返可能上百毫秒;像”刷牙来回刷”这种节奏,几次 poll 的间隔就足以让场景自己推进过去。把轮询放进页面(50ms 一次),往返只有一次,才能读到真正的时间点。
坐标换算也是同一条思路:内容 JSON 的 0–1 先按布局常量换成设计坐标(750×1334),再由 toScreen 换算成鼠标坐标:
const scale = Math.min(box.width / DESIGN.width, box.height / DESIGN.height);
const ox = box.x + (box.width - DESIGN.width * scale) / 2;
const oy = box.y + (box.height - DESIGN.height * scale) / 2;
return { x: ox + x * scale, y: oy + y * scale };
它还处理了一个真实的偶发问题:boundingBox() 偶尔会读到 0×0 或 null(元素刚被替换、Phaser 正在重建 canvas、letterbox 还在过渡),所以最多重试 20 次、每次 100ms,仍然拿不到才报错——报错信息里带着 box 的实际值,比”偶发找不到 canvas”好查得多。
14.7 verify.mjs:七阶段,前六并行、size 必须最后
// scripts/verify.mjs(节选)
const STEPS = [
{ name: 'typecheck', command: 'pnpm typecheck' },
{ name: 'lint', command: 'pnpm lint' },
{ name: 'test', command: 'pnpm test' },
{ name: 'content:check', command: 'pnpm content:check' },
{ name: 'pack:check', command: 'pnpm pack:check' },
{ name: 'build', command: 'pnpm build' },
];
const results = await Promise.all(
STEPS.map((step, index) => run(step.name, step.command, COLORS[index % COLORS.length])),
);
// size 读 dist,必须在 build 之后单独跑
const size = await run('size', 'pnpm size', COLORS[0]);
两个决策都写进了注释:
- 为什么并行:这六步之间没有任何依赖,串起来只是白等(CHAGS 实测 65.8s → 35s)。并行不放松纪律——任一步失败,整体非 0;
- 为什么 size 必须串行:体积断言的对象是构建产物。与 build 并行只会读到上一轮的 dist,那是最坏的一种假绿:旧产物不超标,新产物已经超了。
输出保留了各自前缀([typecheck] / [size]),失败时会打印:
✗ 门禁失败:typecheck (exit 2)、content:check (exit 1)
以上步骤互不依赖,可以只重跑失败的那一项定位问题。
这句话是给人看的操作建议:全量重跑一遍是最常见的时间浪费。
14.8 体积门禁的七项口径
scripts/check-size.mjs 把技术方案里的预算表变成了会红的断言:
| 预算 | 上限 | 口径 |
|---|---|---|
| 首屏 JS + CSS(gzip) | 60 KB | index.html 里 src / href 指向的 js/css + 内联脚本样式文本 |
| 首屏 JS + CSS(原始) | 180 KB | 桌面 / 安卓壳是本地加载,不过 gzip |
| 单个懒加载 chunk(gzip) | 260 KB | 最大的是 Phaser 裁剪版(实测 220.6 KB) |
| 单个懒加载 chunk(原始) | 1000 KB | 同上(实测 856.3 KB) |
| dist 原始大小 | 2400 KB | 整体产物(实测 1,245,469 B) |
| 单个非代码资源 | 200 KB | 目前为 0(全部几何绘制 + 合成音效) |
| 非代码资源合计 | 600 KB | 目前为 0 |
第一项的”内联也必须计入”是刻意的:否则把入口改成内联 bootstrap、真入口改成动态 import,就能把首屏偷换成懒加载 chunk,预算形同虚设。
还有两处工程细节:
- 首屏引用靠正则从
index.html里抽,所以产物结构一旦变化(比如换成内联<script type="module">),脚本会先报”index.html 里没有任何 js/css 引用 —— 产物结构可能变了,先人工确认”,而不是默默算成 0 KB 然后通过; - 脚本不替你构建:找不到
apps/site/dist/index.html直接报”先 pnpm build”,因为”体积通过了”和”体积没测”是两件事。
读这份报告时要知道两件事。第一,首屏口径只统计 index.html 显式引用的文件(含 modulepreload),本机实测是 index.js 52.1 KB + index.css 4.7 KB ≈ 56.8 KB;而共享的 common-*.js(108.7 KB 原始 / 32.4 KB gzip)由懒加载 chunk 静态引用,被正确地算进”按需加载”。第二,预算贴着裁剪版 Phaser 定(260 KB / 1000 KB),所以新的共享大 chunk 一出现就会红——这正是希望它做的事。
14.9 pack:check:不装工具链也能体检打包
pnpm pack:check 的存在理由很实际:Rust、JDK、Android SDK 不是每个人都装,但打包配置写坏了应该在本地就红,而不是等一个 tag 推上去、十分钟后 CI 告诉你图标少一张。
它检查的东西都很朴素,却全是真实失败过的:
| 对象 | 检查 |
|---|---|
tauri.conf.json |
build.frontendDist === '../../site/dist'、identifier 非空、bundle.icon 列出的文件都存在、bundle.active === true |
Cargo.toml |
同时引用 tauri 与 tauri-build |
capacitor.config.ts |
appId 存在、webDir: '../site/dist' |
AndroidManifest.xml |
screenOrientation="portrait"、allowBackup="false" |
app/build.gradle |
namespace 与 appId 一致、支持 BAMBOO_VERSION_NAME / BAMBOO_VERSION_CODE 注入、引用 keystore.properties |
values/strings.xml 与 values-en/strings.xml |
中文名与英文名都在 |
release.yml |
含 windows-latest / assembleRelease / tauri build / softprops/action-gh-release / upload-artifact |
package.json |
icons / keystore / dist:win / dist:apk 四个脚本都在 |
最后一项最像”仪式”,其实最有用:docs/打包与分发.md 里写的命令必须真的存在,否则文档就是错的。
它也有明确的边界:只体检配置,不验证构建。pnpm pack:check 不会替你编译 Rust,也不会跑 Gradle;真正确认”能产出可运行的 exe / apk”仍然要靠 pnpm dist:win / pnpm dist:apk 或一次 Release 运行。把这两件事分开,是为了让 90% 的配置错误在 1 秒内被发现,而把昂贵的验证留给真正要发版的那一刻。
14.10 CI:三个 job,失败证据必须留得住
.github/workflows/ci.yml 只做三件事:
verify(ubuntu) pnpm verify —— 类型 / lint / 单测 / 内容 / 打包体检 / 构建 / 体积
├─ e2e(ubuntu) needs: verify —— pnpm e2e(开发服务器)
└─ preview(ubuntu)needs: verify —— pnpm e2e:dist(生产产物懒加载)
e2e 与 preview 都 needs: verify:门禁没过时,跑端到端只是浪费十分钟。
发布流水线(.github/workflows/release.yml)对这条纪律更严格:release job 的 needs 里包含 e2e,玩法门禁没过就不发版。同时 windows 与 android 只依赖 web(站点产物 + 质量门禁),不依赖 e2e——这样 e2e 慢一点不会把两个平台的构建也堵住,但发版这一步一定等它。失败时上传 playwright-report/ 与 test-results/,保留 7 天。证据策略本身也有成本权衡:trace: 'retain-on-failure' 与 screenshot: 'only-on-failure' 只在失败时保留,平时不占 artifact 空间——全量保留会让每次 CI 产出几百 MB,而失败时没有证据的代价更高。“平时不占空间、出事一定有” 是这条配置的全部意思。
还有一个容易忽略的细节:CI 里用 pnpm install --frozen-lockfile 而不是 pnpm install。前者在锁文件与 package.json 不一致时直接失败,后者会悄悄改写锁文件——在 CI 上「悄悄修好」是最坏的结果,因为本地与线上从此跑的不是同一组依赖。
本地复现的顺序建议照抄 CI:
pnpm install --frozen-lockfile # 锁文件不一致时不要用普通 install 掩盖
pnpm verify
pnpm e2e
pnpm e2e:dist # 会先 build,再在 5191 上跑 preview.spec
如果只有 preview 红,九成是”某个玩法被静态引进了首屏”或者”chunk 命名规则被改了”——先去 apps/site/vite.config.ts 的 chunkFileNames 看那三条正则,再去看 preview.spec.ts 的断言是不是在找 game-<id>-*.js。
14.11 小结与下一章
这套门禁的核心不是”多”,而是每一层都知道自己不该证明什么:单测不假装能验证手感,e2e 不假装能验证帧率,体积门禁不假装能验证首屏体验。反过来,它们各自把最容易腐化的那部分钉死了——契约靠类型,数值靠纯函数,内容靠体检,真实浏览器靠调试桥,体积靠七项预算。
这些门禁最终服务于同一件事:把 apps/site/dist 交给两个原生壳。下一章进入构建与分发:Vite 切出 41 个文件意味着什么、base: './' 为什么能让同一份产物同时活在博客子目录、GitHub Pages 与 exe/apk 里,以及 Tauri 与 Capacitor 各自的体积与签名账。