项目:vitest-playground | 版本:v1.1 | 生成日期:2026-10-06 | 依据:源码分析
本项目对"测试"有两个身份:它教测试(课程内容),也测自己(工程自测)。本篇讲后者——自测体系的全景、每个测试文件的逐文件解析、CI 双 job 的门禁逻辑、以及目前还没有被测到的缺口。runner-security.test.ts 的逐用例安全视角已在《08-安全与权限设计》§4 展开,check-lessons 的流程走读在《05-核心流程》流程六,本篇聚焦"体系如何组装、门禁如何闭环、缺口在哪里"。
自测体系由三层组成,各层针对不同尺度的回归风险,共同覆盖"一行改动可能破坏什么"的全部半径:
| 层 | 载体 | 被测对象 | 失败意味着 | 速度 |
|---|---|---|---|---|
| 第一层:纯函数单测 | tests/runner-security.test.ts、tests/progress.test.ts、tests/lessons.test.ts | 白名单、装配矩阵、信号量、徽章模型、数据完整性 | 逻辑被改坏 | 毫秒级 |
| 第二层:真跑集成 | tests/runner-security.test.ts 的 runInSandbox/handleRunVitest/bench 组 | 真实 vitest 子进程、沙箱目录、超时 kill、全链路契约 | 运行环境/防线行为被破坏 | 秒级(30-60s 预算/条) |
| 第三层:课程级质检 | scripts/check-lessons.ts(CI lessons job) | 56 个课时 × 装配规则的可运行性 | 课程数据损坏 | 分钟级 |
三层的分工哲学可以概括为:单元层锁"规则"、集成层锁"行为"、课程层锁"内容"。第一层不需要 mock 任何东西——因为运行器的核心函数被刻意导出为无 IO 或可注入 IO 的纯单元(设计意图见《01-系统架构》§3.8);第二层不做任何假替身,直接 spawn 真实 vitest(这是该项目与其他"mock 万物"测试文化的最大差异,代价是 CI 时长、收益是防线的行为证据);第三层以与产品完全相同的装配规则逐课时实测,保证"接口契约 × 56 课时数据"这个笛卡尔积始终可运行(契约锁定视角见《03-接口设计》§9)。
测试的基础设施由 vitest.config.ts 决定:环境 happy-dom、globals: true、setupFiles 指向 tests/setupTests.ts、include 收敛到 src/pages/vitest-learn/** 与 tests/** 两个位置(注释说明是为了排除 Ant Design Pro 模板残留文件干扰)、coverage 用 v8 provider(text/json/html 三种 reporter、include src)、testTimeout: 15000(比默认 5s 宽,因为集成用例重)、passWithNoTests: true。注意 include 的约束是硬性的:新测试文件放在这两处之外,pnpm test 根本不会拾取。
运行器防线的全集,双层验证(安全视角逐用例解析见《08-安全与权限设计》§4)。从测试工程角度再补三个观察:其一,describe 组的排列就是加固方案(docs/superpowers/plans/2026-09-27-runner-hardening.md)的任务实施史——从 sanitizeEntryName 到 runInSandbox 再到 limiter/handleRunVitest,最后是「最终审查修复波(内存 DoS 加固)」「coverage 回归(修复前恒 undefined)」「sanitizeEntryName 追加保护」等补丁组,文件本身就是一份可读的加固编年史;其二,集成组的每条用例都同时断言"行为结果"与"资源后果"(如死循环用例既断言超时文案、又断言 RUNS_ROOT 为空),这是防线测试区别于功能测试的关键特征;其三,HTTP 层测试用 PassThrough 伪造请求/响应(postRequest/collectRes helper),零网络依赖地覆盖了 400/429/200 全链路,还显式补齐了 setHeader 替身接口——这是测 Node 中间件的标准技巧,值得沿用到未来新增的 HTTP 分支测试。
课程数据的结构契约,五条测试:每章至少一个课时(附失败消息 ${c.key} 课时为空)、章节 key 全局唯一、课时 key 全局唯一(以 chapter/lesson 组合判定)、每课时 code || grader 非空(同样带定位消息)、第九章与第十六章关键章节存在性(历史里程碑回归)。这个文件的特点是"轻但关键"——它守的是路由寻址与进度索引的根基(进度键 ${chapterKey}:${lessonKey} 依赖 key 唯一性,见《04-数据模型》§3)。它不做内容校验(那是 check-lessons 的职责),也不校验 solution 可运行性(同样是 check-lessons 的职责),边界划得干净。
两个 describe:computeBadges 的七条用例覆盖五徽章的解锁边界(零进度、1 课时、10 课时、整章攻克只看 chaptersCompleted 不受课时数影响、全通关、零课时特例),其中「整章攻克只看完整通关章数」用两个构造场景(10 课时但零整章 vs 1 课时但恰是唯一课时的整章)精确锁定了 hint 展示与判定逻辑的分离;「学习进度持久化」四条用例在 beforeEach clearProgress 下验证初始态、标记通过、代码存档、清空。这个文件是纯函数 + happy-dom 的 localStorage 内存替身(tests/setupTests.ts)的配合范本——不启动任何浏览器组件就能测持久化逻辑。
happy-dom 环境的"最小浏览器面"垫片:内存版 localStorage(vi.fn 包装的 getItem/setItem/removeItem/clear,供进度测试隔离与断言)、URL.createObjectURL(导出功能用)、MockWorker(Monaco 相关导入在测试环境不真建 worker)、MessageChannel polyfill(setTimeout 异步回环实现)、matchMedia(antd 响应式断点用)、ResizeObserver 空实现(antd 组件挂载用)。它的设计取向是"让组件级导入不炸",而不是完整模拟浏览器——因此目前没有组件渲染测试(见 §5 缺口 1)。
mermaid.ts(内容 export default {})与 raw.ts(内容 export default 'mock-md-content')在 src/ 中找不到任何对应引用(无 mermaid 导入、无 ?raw 导入),且 vitest.config.ts 未配置任何 alias 指向 __mocks__,两者是 Ant Design Pro 模板时代的遗留死代码(已列入《13-技术债务与演进建议》)。注意它们不会被 vitest 自动加载(vitest 的 __mocks__ 自动 mock 机制要求配置或 vi.mock 显式调用),因此无害但无价值。
脚本全貌与流程见《05-核心流程》流程六与《06-部署运行》§5.3,此处提炼它作为"质量门禁"的判定矩阵——理解这五类判定,就能读懂它的汇总输出并正确响应失败:
| 判定 | 触发条件 | 处置含义 |
|---|---|---|
| PASS | solution 实测全绿;或普通课时 starter code 实测全绿 | 内容健康 |
| WARN | starter code 运行出"断言级失败"(有 JSON、有 failed 用例、无套件级失败) | 教学性失败起点,属预期——普通课时的初始代码本来就该是"差一步通过"的状态 |
| FAIL | solution 未全绿;starter code 套件级崩溃(加载/收集错误);无合法 JSON 输出;无 solution 课时语法门禁不过 | CI 拦截,必须修 |
| SYN | 无 solution 课时,code/grader/hiddenGrader/extraFiles 全部通过 esbuild transform 语法门禁 | 不实测运行(补 solution 属内容决策),只保证"至少能编译" |
| skipped | 计数恒为 0(保留位,当前无使用场景) | - |
两个容易误读的细节:其一,broken(numFailedTestSuites > 0 && numFailedTests === 0)单独识别"套件级失败"——语法错误会让整个文件加载失败、一个断言都没跑,这种必须 FAIL 而不能因为 failed===0 放行;其二,脚本刻意剔除 CI/GITHUB_ACTIONS 环境变量(产品运行器的 env 白名单本就不含它们),否则 CI 上 vitest 进入 CI 模式会让 skip-only 与快照课时出现"本地没有的假失败"(源码注释原话)。bench 课时与产品一致用 vitest bench + 相同表格行正则,保证质检判定与用户实际看到的结果同构。
CI 在 push master 与所有 PR 上触发,两个 job 相互独立、可并行,都是 Node 22 + pnpm(版本取自 package.json 的 packageManager 字段,pnpm/action-setup@v4 自动读取)+ pnpm 缓存 + --frozen-lockfile 强校验安装。
**job 1 quality(工程质量门禁)**四步:pnpm tsc(strict 全量类型检查——前后端共享 types.ts 使得字段改名会被这里全量暴露,这是"类型即契约"的门禁化);pnpm test(含真 spawn vitest 的集成用例,是 CI 时长的主要消耗者);pnpm build(顺带验证 manualChunks 三分无体积告警,chunkSizeWarningLimit: 2500 越线即红)。
job 2 lessons(课程内容门禁)一步:pnpm check:lessons 逐课时实测 56 个课时。它独立成 job 的意图是解耦两类评审:改课程内容(data.ts)不应被工程问题阻塞,改工程也不该等几分钟的课时实测——两个绿灯合起来才是完整的"可发布"信号。
本地复现 CI 的等价命令序列:pnpm install --frozen-lockfile && pnpm tsc && pnpm test && pnpm build && pnpm check:lessons。提交前跑一遍可避免绝大多数 CI 往返。
| 时机 | 门禁 | 覆盖 | 依据 |
|---|---|---|---|
| 编辑时 | Monaco 实时类型诊断(vitestGlobalsDts 精简版) | 课时代码书写体验 | monacoSetup.ts |
| 提交前(人工) | pnpm tsc + pnpm test + pnpm check:lessons [key](可传课时 key 过滤) | 全部三层 | package.json scripts |
| push/PR(自动) | CI quality + lessons 双 job | 同上,机器强制 | .github/workflows/ci.yml |
| 部署前(人工) | 发布核对清单九项(含 preview curl 实测、COEP/COOP、nginx 限流验证) | 运行环境 | 《06-部署运行》§10 |
| 运行时 | 防线常量(超时/并发/白名单)+ 用户即时结果反馈 | 单次执行 | 《08-安全与权限设计》§3 |
以下缺口按"暴露风险 × 补测成本"排序,均为源码确认的现状而非猜测:
chapter.tsx(471 行,含状态机、答案切换、自动重跑)、CodeEditor.tsx(markers effect、结果面板)、Quiz.tsx(判分)完全没有 React Testing Library 用例;tests/setupTests.ts 的垫片(MockWorker、matchMedia、ResizeObserver)已经为组件测试铺好了路,却只被纯逻辑测试消费。建议优先补三组:handleRunTest 的状态机(success 记进度/error 不记/查看答案时提交 solution 文本)、markers 的 setModelMarkers 调用参数、Quiz 提交后锁定与正确答案展示。风险点在"看答案跑通记通关"这类产品语义(见《08》§6-2),目前无任何测试表达该行为的期望,改坏了也没有红灯。createRunLimiter 的 RUNNER_BUSY 语义有测试,但 handleRunVitest 的 429 分支(排队满 → HTTP 429)未被直接断言——现有 HTTP 测试没有构造满载前置条件。补法:用 createRunLimiter(0,0) 语义或先灌满模块 limiter 再发请求,断言 statusCode 429 与文案。parseVitestJson 的"进程退出码非 0"提示分支因 finishRun 硬编码传 0 而不可达(《07-模块详解》§1.4)。修法是 child.on('close', (code) => ...) 传递真实退出码,并补一条"套件级失败无断言失败"的 output 断言;不修则应删掉死分支避免误导。loadProgress 的容错(损坏 JSON → 空 Map)没有用例;saveProgress 无 try/catch 的配额溢出行为(见《08》§6-1)既无处理也无测试。补 localStorage.setItem 抛异常时 setLessonProgress 不应中断调用方的用例(需要先决定是否加防御,见《13》)。parseBenchTable 把 mean(秒,亚毫秒小数)塞进 duration 字段,前端按毫秒渲染(Math.round(t.duration)ms)——9.7 benchmark 课时显示的毫秒数实际是"秒值四舍五入",多数为 0 或 1ms(⚠️ 按 vitestServerRunner.ts 第 235-237 行与 CodeEditor.tsx 第 251 行推导)。建议补一条契约用例明确单位约定,或修正为毫秒换算。runInSandbox 并发执行、互不串目录的断言(Promise.all 双跑 + 各自 success)能更直接地锁住"每请求独立目录"不变量(《05-核心流程》不变量 1)。gui-test-screenshots/ 存有 10 张按用例编号归档的截图(t1_home 到 badge_earned_state),但驱动脚本未随仓库提交(.gitignore 的 .playwright-mcp/ 条目暗示由 Playwright 驱动的外部过程产出)⚠️——这些证据无法在 CI 里回归,只能人审。建议把关键旅程(进课时 → 运行 → 通过 → 徽章点亮)固化为 Playwright 冒烟测试。.editorconfig 与 progressPage.tsx 里一条 eslint-disable-next-line 注释残留),CI 的 quality job 因此缺一个"风格与 Hooks 依赖数组"检查层(详见《14-配置与环境详解》§6)。把三层门禁串成操作手册(与《03-接口设计》§9 的契约修改顺序一致):先在对应测试文件写下新期望(红)→ 改实现(绿)→ 若改动触及课程数据或装配规则,跑 pnpm check:lessons(改单个课时传 key 过滤)→ 最后全量 pnpm tsc && pnpm test && pnpm build 三绿再提交。两条实用提醒:runner-security 的集成用例在机器高负载时偶发超时,重跑即可,不要急于改代码;新测试文件必须放进 src/pages/vitest-learn/** 或 tests/**,否则 include 收敛规则会让它静默失效。