首页
/ career-ops 测试体系完全指南:零框架 test-all 套件与 tests/ 自动发现机制剖析

career-ops 测试体系完全指南:零框架 test-all 套件与 tests/ 自动发现机制剖析

2026-09-07 22:22:03作者:田桥桑Industrious

导读:career-ops 是一个在 AI 编码 CLI(Claude Code、Codex、OpenCode 等)中本地运行的 AI 求职工具链,其测试体系同样"零依赖"——不引入任何测试框架,仅靠 Node.js 原生能力支撑起对数百个 .mjs 脚本、Job 扫描 Provider、Go 仪表盘的数据契约与行为回归检查。本文以 tests/README.md 为主线,结合 test-all.mjstests/helpers.mjstests/providers 下数百个测试文件的真实实现,讲清这套套件的运行入口、发现机制、双风格取舍与编写规范,帮助你快速上手本地验证、新增用例并理解其"绝不让绿 CI 掩盖错误"的守卫设计。

一、设计原则:为什么一个测试套件可以没有测试框架

1.1 "裸克隆 + 纯 Node"的硬约束

test-all.mjs 是仓库根目录下的套件运行器(repo root 的 suite runner)。它的约束不是口味选择,而是一条硬性工程要求:套件必须能在刚 clone 下来的全新副本上,仅凭 Node.js 运行(见 tests/README.md 的 Purpose 一节)。

这意味着三件事:

  • 不使用 Jest / Mocha / Vitest 等任何需要 npm install 第三方依赖的框架;
  • 连 Node 自带的 node:test 都不使用(README 中注明为 issue #1440 的决策),因为测试作者不想让套件强依赖某个最低 Node 版本中的测试运行器行为;
  • 断言与统计全部由仓库自带的 tests/helpers.mjs 提供。

运行器(test-all.mjs)的执行策略分为两层:

  1. 内联核心检查(inline core checks):语法(syntax)、脚本执行(scripts)、仪表盘构建(dashboard)、数据契约(data contract)、个人数据泄漏(personal data)、绝对路径(paths)等直接写在 test-all.mjs 文件内的分节断言;
  2. 自动发现的测试文件:递归扫描 tests/ 目录下所有 *.test.mjs 并逐一执行。

1.2 为什么新测试要"进文件、不进分节"

README 与 CONTRIBUTING.md 反复强调一个约定:新增测试放到 tests/ 下独立的 {name}.test.mjs 文件里,绝不要在 test-all.mjs 里新增编号分节test-all.mjs 头部注释给出了一次真实的教训:2026 年 8 月有 6 位贡献者同时为内联分节取号,不约而同都选了 60a,导致每次 merge 都要强制其余 5 人 rebase——大约十五次 rebase 和六次串行 CI 只为六行测试代码。而新增一个文件与任何人都不冲突,PR 可以完全并行合入。

从源码看,分节编号在演进中也确实被淘汰了:test-all.mjs 顶部写明 "NEW TESTS GO IN A FILE OF THEIR OWN, NOT IN A SECTION HERE"(新测试放入自己的文件,而不是这里的某个分节)。

二、运行方式与命令行参数

2.1 三种典型用法

node test-all.mjs                            # 完整套件 —— push 前必跑
node test-all.mjs --quick                    # 完整套件,跳过 dashboard 构建(更快)
node test-all.mjs --only providers/themuse   # 只运行路径中包含该子串的 tests/ 文件

三种用法的语义差异由 test-all.mjs 中的标志解析逻辑实现:

  • --only <substring>:从 tests/ 相对路径中做子串过滤,例如 --only providers/themuse 只会命中 tests/providers/themuse.test.mjs
  • --quick:跳过第 4 节"Dashboard build"(Go 编译器编译 dashboard/ 的验证),见 test-all.mjsif (!QUICK) 分支。
  • 无参数:完整套件。先跑全部内联核心分节,再执行 tests/ 下所有自动发现的测试。

2.2 反"假绿"设计

--only 有两个精心设计的失败语义(test-all.mjs):

  1. 无匹配即退出 1:如果过滤后 tests/ 下没有任何文件命中,立即打印 ❌ no test files matched --only "..." 并以退出码 1 终止。这样路径拼写错误永远不可能把 CI 变绿
  2. 缺值即报错--only 提供了但后面没有值,会被判定为用法错误直接退出,而不会悄悄退化为"无过滤跑全量"——那会让请求子集的开发者误以为子集全绿(test-all.mjs)。

README 中的警告值得原文保留:--only 只是开发便利,不是 PR 门禁。它会跳过 test-all.mjs 中每一个内联核心分节,一次"绿色"的 --only 运行绝不等于套件通过——push 前永远要跑完整的 node test-all.mjs

2.3 确定性发现机制

自动发现不依赖任何 glob 库或注册表,而是递归 readdirSync + 字典序排序test-all.mjs):递归遍历 tests/,目录优先跳过嵌套检出(isNestedCheckout,防止把 git worktree 里的第二份仓库套件误当成当前树的测试),文件按 *.test.mjs 后缀收集,每层条目按名字字典序排序——保证每次运行、每个操作系统上的执行顺序完全一致(跨平台确定性,issue #1440)。根目录下的独立 *.test.mjs 文件(例如 set-status-tests.mjstracker-columns-tests.mjs不属于该目录发现机制,而是被 test-all.mjs 第 2 节的脚本执行列表显式收录(见 tests/README.md 的 Layout 一节与 test-all.mjsscripts 数组)。

三、目录布局与双套件边界

tests/README.md 的 Layout 一节把目录职责划得很清楚:

tests/
├── helpers.mjs                    # 共享断言辅助与计数器(pass/fail/warn…)
├── providers/                     # 每个 scanner provider 一个测试文件
│   ├── themuse.test.mjs
│   ├── greenhouse.test.mjs
│   ├── ats-ssrf-hardening.test.mjs   # 跨 Provider 的共享测试
│   ├── _html-entities.test.mjs       # 下划线前缀 = 共享辅助模块的测试
│   └── …
└── *.test.mjs                     # 覆盖根目录脚本(stats、tracker、scan…)

关键区分如下:

  • providers/{name}.test.mjs:一个 Job 扫描 Provider(providers/ 下 100+ 个抓取器)对应一个测试文件;另有跨 Provider 的共享测试如 ats-ssrf-hardening.test.mjs。具体写法模板见 providers/ADDING_A_PROVIDER.md 的 Tests 一节(其中明确:"One file: tests/providers/{name}.test.mjs"、"Auto-discovered (tests/**/*.test.mjs) — nothing to register in test-all.mjs")。
  • 下划线前缀文件(如 _html-entities.test.mjs_html-to-text.test.mjs_http.test.mjs_profile-keywords.test.mjs):测试的是 providers/ 下对应的共享辅助模块。
  • 本层其他 *.test.mjs:覆盖根目录脚本(例如 stats.test.mjsmerge-tracker.test.mjsscan-*.test.mjs 一族)。

3.1 核心与 Web 的刻意分治

README 用一个专门的段落声明:Web 测试不住在这里web/(Next.js 前端)在自己的目录里跑 npm test,glob 发现 web/tests/**/*.test.mjs(详见 web/README.md 的 Tests 一节)。两个套件连风格都刻意不同:

维度 核心套件(本目录) Web 套件(web/tests/
运行入口 node test-all.mjs npm test
断言风格 自研 pass/fail 辅助函数 Node 自带 node:test + node:assert/strict
文件后缀 .mjs .mjs(不使用 .ts,因为没有测试框架与 TS loader)
覆盖对象 根目录脚本、Provider、数据契约 web/src/ 下的模块,路径镜像

README 明确禁止把任一方风格带过界:核心套件坚持 "not even node:test"(#1440),因为必须在无框架裸克隆上运行;而 Web 套件则必须用 node:test

唯一的例外web-test-layout.test.mjs:它守卫的恰恰是 Web 的目录布局规范,却刻意放在根目录套件里。原因(README 原文)是 web-ci.yml 的检查"只报告、不阻塞"(informative by design,永不拦 merge),而 test-all.mjs 作为 required check 在每一个 PR 上都会跑——把布局守卫放进必跑套件,才真正保证 web/src/ 之外不混入测试文件等规范被执行。这其实揭示了本仓库的一个通用哲学:真正要紧的约束要挂在"必跑的"那一环上

四、helpers.mjs:零框架断言的引擎

tests/helpers.mjstest-all.mjs 中"verbatim"迁移而来(issue #1440),是全套件唯一共享的断言底座。它导出的 API 可以分为四类。

4.1 断言、计数与收尾

export function pass(msg) { console.log(`  ✅ ${msg}`); passed++; }
export function fail(msg) { console.log(`  ❌ ${msg}`); failed++; }
export function warn(msg) { console.log(`  ⚠️  ${msg}`); warnings++; }
export function results() { return { passed, failed, warnings }; }
export function finish() { /* 打印汇总并按计数器决定 process.exit 码 */ }

核心设计:

  • fail 不中断执行,只递增计数器,让后续断言继续跑完,从而一次性暴露全部问题集;
  • warn 用于"本地环境合理缺失"的场景(如干净仓库里没有用户数据),保持可见但不红 CI;
  • 模块顶部导出常量 ROOT(仓库根目录)、QUICK(是否带 --quick)、NODE(当前 Node 可执行文件路径);
  • finish() 输出 📊 Results: N passed, M failed, K warnings 后,按 failed > 0 → 退出 1、有警告 → 退出 0 且黄灯提示、全绿 → 打印 "🟢 All tests passed — safe to push/merge"。

4.2 可执行文件的"白名单 run"

run(cmd, args, opts) 是子进程调用的唯一通道,安全设计极其严格:

  • 只允许 nodebashgitgowsl 这几个白名单字面量,外加 Windows 下按候选路径解析出的 Git Bash / cygpath(tests/helpers.mjs);
  • 永远走 execFileSync + 参数数组,绝不经 shell 拼接字符串,因此参数永不被 shell 二次解析,也堵死了把任意可执行文件注入测试的路径;
  • 成功返回 trim 过的 stdout,失败返回 null 并记录诊断,由 lastRunFailure()formatRunFailure() 输出(后者对超长流"两头都保留",因为失败套件既可能把关键栈放在最前,也可能把汇总放在最后)。

4.3 跨平台与可靠性工具

  • rmSync:默认包装 maxRetries: 10, retryDelay: 100 的重试删除。Windows 上子进程刚退出时(杀毒软件会拉长窗口)文件句柄可能未释放,裸 rmSync 会以 EPERM 失败——Node 恰好会对 EPERM/EBUSY 这类错误做线性退避重试,所以全局默认它;
  • getBash() / toBashPath():在 Windows 上按"Git for Windows 字面路径 → WSL → PATH → 未解析"的顺序惰性探测 bash(探测可能启动 WSL 虚拟机,故做进程级 memoize),路径转换优先 cygpath(/c/...)而非 wslpath(/mnt/c/...),避免与 Git Bash 挂载方案不匹配;
  • walkFiles():字典序递归收集,遇到嵌套检出目录跳过;
  • hermeticGitEnv() / makeUpdaterRepo():为 updater 系测试构造与宿主环境完全隔离的临时 git 仓库,用 GIT_CONFIG_COUNT=0 + 空 excludes 文件等"pin"杜绝全局 gitignore、gpgsign、hooksPath 泄漏进测试。

4.4 时间边界与跨 UTC 日竞态

utcDay()daysSpanned()runAcrossUtcDay() 解决了一类很隐蔽的 flaky:测试若只在开始/结束时各读一次 UTC 日期,一旦子进程恰好跨过 UTC 午夜,两次读取就会不一致,导致同一份代码在个别 runner 上红、其他 runner 上绿。runAcrossUtcDay 在调用前后各取一天并返回区间内所有可能的日期,使断言"子进程看到的日期属于该区间"——断言不再依赖任何超时或时刻。

五、test-all.mjs 内联核心分节一览

把 18,000+ 行的 test-all.mjs 的编号分节(// ── N. ...)抽取出来,可以得到套件"门禁内容"的全貌:

分节 验证内容 代表性做法
1. Syntax checks 仓库全部 .mjs 语法合法 复用 collectMjsFiles(ROOT)(与 npm run lint 共享同一遍历,防止范围漂移);8 个 worker 组成的 node --check 有界进程池,结果按收集顺序回填,日志与串行版逐字节一致
2. Script execution 数十个根目录脚本可执行 把整个仓库复制进 ROOT 下临时目录(排除 node_modules.gitdatareports),脚本在副本内运行,绝不改动真实用户数据;--self-test/--dry-run 模式为主;含 30s 默认预算与 SLOW_SCRIPT_WARN_FRACTION=0.75 的"接近超时"警告
3. Liveness classification 职位页"存活性"分类逻辑 针对 liveness-core.mjs/liveness-api.mjs 注入各类真实页面文本:过期职位不得被页脚 "Apply" 复活、403 反爬页面归为 uncertain 而非 expired、WTTJ 排版弯引号与法语变音符横幅都能正确识别、Workday 多段 jobPath 的 SSRF 守卫等
4. Dashboard build Go 仪表盘可编译 go build 到临时目录;无 Go 编译器则 warn 跳过;--quick 时整体跳过
5. Data contract 系统文件与各 CLI 技能入口完整 检查 CLAUDE.mdmodes/_shared.md 等一长串文件存在;并比较 .agents/skills/career-ops/SKILL.md 与各 CLI(.claude/.cursor/.kimi/…)入口的 git blob——既允许真实 symlink(mode 120000),也允许物化出的同内容普通文件(mode 100644),但内容必须是规范 bloba
6. Personal data leak check 个人数据不泄漏 git grep 只扫已跟踪文件(untracked/被 gitignore 的文件不会误报),对维护者姓名、邮箱、路径等模式逐一扫描,落在授权文件列表外才告警
7. Absolute path check 代码中无本机绝对路径 同样走 git grep /Users/ 思路,只查已跟踪的代码文件
7b+ PDF 渲染等待条件、verify-cv-facts / verify-ats 回归、CLI 标志契约等 内联脚本化断言不断追加
8–15+ Mode 文件完整性、本地解析器契约、Portal 配置校验、AGENTS.md / CLI wrapper 完整性、技能符号链接完整性、版本文件、Follow-up 节奏逻辑等 见第 14430 行之后的 20+ 个编号分节

值得注意的两点实现细节:

  1. 脚本执行从"一次性副本"运行:第 2 节把仓库整体复制到临时目录再逐个跑脚本,因此 normalize-statuses.mjsmerge-tracker.mjs 等会原地写 data/ 的脚本在测试中不会污染真实用户工作区;
  2. allowFail 的语义是"预期非零退出",而不是"任何结果都行"cv-sync-check.mjs 在没有 cv.md 的干净仓库中退出 1 是正常的;但若它死于未捕获异常(有 Error: 开头且带栈的 stderr),则判为"crashed before running"并 fail——这正是 #3440 修掉的陷阱:一个在模块作用域抛 ReferenceError 的脚本曾被安静地当成"预期失败"。

六、自动发现测试的三种运行形态

runDiscovered()test-all.mjs)把发现的套件分成三类处理,每一类背后都是一个真实踩过的坑:

  1. 普通 pass/fail 套件:在进程内 await import() 直接执行,共享本套件的计数器;
  2. node:test 套件:通过在源码里探测 from 'node:test' 识别(例如 tests/reply-matcher.test.mjsimport test from 'node:test')。它们必须在子进程里用 node --test——因为 node:test 走的是 Node 自己的 runner,不触碰上面的计数器,且测试在 import 解析完成后异步执行,若直接 import 进来,finish()process.exit() 会在它们跑完前杀掉进程、丢弃结果。曾实测:把一个故意失败的 node:test 套件丢进 tests/,结果打印 "All tests passed" 且退出码 0;
  3. 违规套件:源码中出现 process.exit(finish() 的套件会被直接拒绝导入并 fail——因为被发现的套件是"客人"而非"共同主办方",擅自 exit 会伪造整套件的最终判决并斩断排在后面的所有测试。

同样地,单个套件 import 时抛出未捕获异常,会被 try/catch 包裹成一条失败并继续运行后面的套件,而不是让整个 test-all 无提示终止。

七、新增一个测试:从文件到全绿

7.1 三步接入(无需注册)

tests/README.md 的 Adding a test 一节给出了完整的增量流程:

  1. tests/ 下新增一个 {name}.test.mjs,例如 tests/my-script.test.mjs——自动被发现,无需注册,也绝不要给 test-all.mjs 加分节
  2. 用相对路径导入辅助函数:
import { pass, fail, ROOT } from './helpers.mjs';    // tests/*.test.mjs
import { pass, fail, ROOT } from '../helpers.mjs';   // tests/providers/*.test.mjs
  1. 本地开发循环用 node test-all.mjs --only my-script,push 前跑完整 node test-all.mjs。完整的贡献流程见 CONTRIBUTING.md(其中第 170–199 行同样强调这三个命令与 "--only 不是 PR 门禁")。

导入路径的差异是刻意为之:辅助模块位于 tests/helpers.mjs,因此 tests/ 本层文件写 ./helpers.mjstests/providers/ 子层文件写 ../helpers.mjs

7.2 Provider 测试的解剖

providers/ADDING_A_PROVIDER.md 第 3 节定义了 Provider 测试模板:tests/providers/themuse.test.mjs 是很好的范本,其结构为:

  • ID 断言themuse.id === 'themuse'
  • 纯解析函数单测:RSS/HTML Provider 应导出纯解析函数(如 normalizeMuseJob),直接喂入单条 job 对象断言字段映射(name → titlerefs.landing_page → urlcompany.name → companylocations[0].name → location)与边界(空标题丢弃、相对 URL 丢弃、空 locations 返回空串、公司缺失回退 "The Muse"、非对象入参返回 null);
  • fetch() 行为:用 mock 的 ctx 注入样例结果,断言输出行数、去重与网络错误分支;
  • 共享安全守卫:SSRF 硬化的跨 Provider 测试集中在共享文件(ats-ssrf-hardening.test.mjsprivate-address-guard.test.mjs),单个 Provider 无需重复。

各阶段对应关系在 ADDING_A_PROVIDER.md 中有一张对照表(如 "Simple JSON API, no pagination" 对应 providers/greenhouse.mjs + tests/providers/greenhouse.test.mjs),可直接照抄体例。

7.3 编写时必须遵守的守卫约定

tests/README.mdtest-all.mjs 的源码注释可以提炼出若干"硬约束",违反即红:

  • 被发现的套件不得调用 process.exit(),也不得调用 finish()(只有 test-all.mjs 有权打印全局汇总与决定退出码);
  • 套件允许使用 node:test 风格(会被放到子进程跑),但不得在同一文件混用计数风格;
  • 需要真实 git 行为的用例,应使用 makeUpdaterRepo/hermeticGitEnv 构造隔离仓库,而非依赖 CI 宿主环境;
  • 触发"预期失败"的 allowFail 只能用于预期退出码,未捕获异常必须按崩溃处理。

八、常见排查手段

结合源码与 README,遇到"红了但看不懂"时可以按此定位:

  1. 区分"崩溃"与"断言失败":第 2 节每条失败都会带上子进程的退出码与 stdout/stderr 片段(formatRunFailure()),先看是 crashed 还是普通 exit;
  2. 怀疑 shell 而非被测代码:Windows 上若命令实际跑在 WSL/PATH 兜底 shell 里,脚本可能死于 node: command not found(exit 127),而断言看到的是空输出——helpers 会打印一行 [shell] this command ran under ... 提示你怀疑 shell;
  3. --only 过滤为空的退出码 1 是特性不是 bug:这说明子串没匹配到任何 tests/ 文件,检查路径拼写;
  4. node:test 风格套件单独直跑node --test tests/reply-matcher.test.mjs 可看其独立报告,再回跑全量确认子进程折叠逻辑正常;
  5. 本地跑完整套件node test-all.mjs,注意 dashboard 分节需要 Go 编译器(没有会以 warn 跳过),脚本执行分节会在仓库内创建临时副本,属正常行为且结束即清理。

九、小结

career-ops 用最朴素的工具实现了极高的工程质量门槛:没有框架、没有注册表、没有 glob 库,只靠 tests/ 的字典序递归发现 + pass/fail 计数器 + 白名单子进程通道,就把语法、脚本执行、数据契约、个人数据泄漏、Provider 行为、跨语言(Go 仪表盘、Node 前端、shell 脚本)的回归全部挂到了每个 PR 必跑的 test-all.mjs 上。它真正值得借鉴的设计,不是某个断言技巧,而是处处"宁可失败得响亮,也不要绿得虚假"的守卫哲学——--only 无匹配必须退出 1、allowFail 不掩盖未捕获异常、node:test 套件的失败码被折叠进汇总、嵌套检出与网络竞态被显式隔离。理解了这些约定之后,向这套体系新增一个测试的成本几乎只剩"写断言本身"。

登录后查看全文
热门项目推荐
相关项目推荐