【免费下载链接】gsd-core
Git. Ship. Done - Core
导读
TESTING-STANDARDS.md是 gsd-core 项目中测试正确性契约的权威基准文档,它定义了每个测试必须满足的六项严格性契约、ADR 456(docs/adr/456-test-rigor-architecture.md)引入的新测试政策,以及配套 ESLint 规则的强制执行矩阵。阅读本文后,你将掌握:如何识别并避免"假测试"(source-grep、空真断言、永远通过的测试)、如何为并发逻辑编写确定性测试(时钟缝模式)、如何利用 Stryker 突变测试与属性测试(fast-check)把测试质量推到 80% 突变分数门槛之上,以及项目中各条 ESLint 规则具体拦截哪些违规形态。
一、定位与配套文档地图
该文档刻意不做重复叙述,而是作为指向现有文档的"地图",其定位关系如下:
- 套件命名、CI 矩阵、每套件的运行脚本→ docs/TESTING-SUITES.md
- 测试运行器导入方式、setup/teardown 模式、fixture 格式化、QA 矩阵→ CONTRIBUTING.md — "Testing Standards"
- 每个需求的落地示例测试→ TEST-EXAMPLES.md
- 可被机器 grep 的谓词(
RULESET.TESTS.*)→ CONTEXT.md — "Test rules and lint" - ADR 456 架构本身→ docs/adr/456-test-rigor-architecture.md
这套分层结构意味着:任何新增或修订的测试,都以本文的六项契约为总纲,以配套文档为执行细节。
二、六项测试严格性契约
本文档的核心是六项适用于所有新测试以及既有测试修订的契约。它们不是建议,而是准入条件。
契约 1:测试真实代码,而非源码或输出文本
测试必须调用被导出的函数,或运行 CLI 并解析结构化输出。禁止用readFileSync读取源码文件后对其文本内容做断言;也禁止在退出码确认之外,对原始 stdout/stderr 字符串做断言。
合规写法(调用函数并解析 JSON 输出):
const { stdout } = await runGsdTools(['plan', '--json']); const result = JSON.parse(stdout); assert.strictEqual(result.phases[0].id, 'plan-1.1');违规写法(源码 grep,绝不这样做):
const src = readFileSync('./bin/lib/plan.cjs', 'utf8'); assert(src.includes('plan-1.1')); // never do this强制执行:local/no-source-grep(ESLint,error级别,由 #3313 提升为仓库级规则)。
从规则源码(eslint-rules/no-source-grep.cjs)可以看到该规则比"禁止读取源码"走得更深:它会对绑定到readFileSync()结果上的变量做最多 3 跳(MAX_TRANSITIVE_HOPS)的传递追踪,识别const b = f(a)、b = a等派生绑定,并通过真实词法作用域(ESLintVariable对象)而非名称字符串解析变量身份,避免把无关或遮蔽作用域中同名绑定误判为被追踪值。规则还覆盖regex.test(tracked)、/lit/.exec(tracked)形态(#3464 阶段 8),并把hooks目录与bin/lib/gsd-core/src一并识别为源码目录。同时它提供**站点级(site-scoped)**豁免注释// allow-test-rule: <reason> (#NNN)——只抑制注释相邻的违规(同一行或上方仅空行/注释行),且可放在 read+search 这一对操作的任意一半附近,而非整文件豁免。
契约 2:禁止空真断言(vacuous-truth)
断言必须能在 SUT(被测系统)存在合理缺陷时失败。若断言左侧无论 SUT 行为如何都恒为真,则该断言不提供任何覆盖价值。
违规写法:
assert(true); assert.ok(output !== undefined); // output is unconditionally set above合规写法:断言一个由 SUT 计算得出、且 SUT 的突变可能改变其值的量。
强制执行:代码评审 +local/no-source-grep(能捕获一个常见的空真子模式)。由于没有自动化规则能覆盖所有形态,代码评审是主要闸门。
契约 3:禁止"永远通过"的测试
一个无论所描述功能是否实现都必然通过的测试,比没有测试更糟:它虚增了测试数量,却提供了虚假信心。测试必须能在功能缺失或损坏时失败。正确做法是先写测试(TDD 红阶段),用桩实现确认它失败,再实现功能。
强制执行:local/no-source-grep、代码评审,以及 Stryker 突变分数(被覆盖路径中存活的突变体即"永远通过测试"的信号)。
契约 4:测试声称的路径(test the claimed path)
测试名称描述一个行为,测试体必须通过实现路径去锻炼该行为,而不是通过一个替换了整个 SUT 的 mock。若测试名写"acquireLock 在 TTL 后过期",测试就必须真正调用acquireLock(而不是一个什么都不做的自写桩),并断言在 T 时刻获取的锁在 T + TTL + 1ms 时刻已过期。
强制执行:代码评审;Stryker 突变分数覆盖未被测试的路径。
契约 5:完整的 mock(mock 依赖,不 mock SUT)
mock 依赖时只能 mock 依赖本身,绝不能 mock SUT 内部的行为。一个从被测函数内部返回硬编码值的 mock,本质上是伪装成 mock 的"永远通过测试"。文件系统、网络、时钟等外部 I/O 才是合适的 mock 范围;SUT 内部的业务逻辑不应被 mock,而应被真实执行。
强制执行:代码评审。
契约 6:负面空间的逆向测试(counter-tests)
对于每一条行为契约,至少有一个测试必须喂给 SUT 一个应当拒绝或以不同于快乐路径方式处理的输入,例如:缺失必需参数、边界值 + 1、恶意输入。具体请参见 CONTRIBUTING.md — "QA Matrix Requirements" 中的十二种案例矩阵,并按变更面应用相关案例。
强制执行:代码评审 +no-only-tests/no-only-testsESLint 规则(通过禁止test.only提交来防止只测快乐路径的合并)。
常设规则:断言"降级裁决",而不只是"没抛异常"
这是契约 6 的重要补充:一个喂给恶意或失败输入的逆向测试,即使满足了契约 6 的字面要求,仍可能毫无价值。如果函数存在错误或回退分支——例如对坏输入宽容降级的守卫、回退到默认根目录的解析器、过期的锁——该分支的测试必须断言分支产生的具体降级裁决,而不能仅仅断言调用没有抛出异常、或返回了某个类型正确的"某个值"。
文档给出了tests/worktree-safety.test.cjs中resolveWorktreeContext超时逆向测试的修复前形态(来自 #3050 发现 2 与 epic #3051 阶段 3 的泛化 #3053 H4):
// A liveness test wearing a correctness test's name — it proves the call // survived a timeout, not that it degraded to the RIGHT shape. test('resolveWorktreeContext handles a timeout', () => { const result = resolveWorktreeContext('/repo/wt', { execGit: makeTimeoutStub() }); assert.doesNotThrow(() => resolveWorktreeContext('/repo/wt', { execGit: makeTimeoutStub() })); assert.strictEqual(typeof result.effectiveRoot, 'string'); // true of ANY string, including the wrong one });而修复后的实际测试(同样位于 tests/worktree-safety.test.cjs)断言的是完整的具体降级形状:
test('returns effectiveRoot=cwd, mode=current_directory, reason=git_timed_out on timeout, not throw', () => { const result = resolveWorktreeContext('/tmp', { execGit: makeTimeoutStub() }); assert.deepStrictEqual(result, { effectiveRoot: '/tmp', // the specific degraded value, not just "a string" mode: 'current_directory', reason: 'git_timed_out', }); });在真实测试文件中,makeTimeoutStub()返回带timedOut: true、signal: 'SIGTERM'、error.code === 'ETIMEDOUT'的 spawnSync 形状(对应 Windows 形态的超时),并配套有专门区分git_timed_out与not_git_repo的 #3050 缺陷回归测试。这与"输入拒绝规则"是两个不同的要求:一个测试可能已满足"喂恶意输入",但如果它从不检查 SUT 的实际响应,仍然违反本规则。明确不在本规则范围内的两类形态(给它们加此类逆向测试是噪声而非信号):
- 重新抛出/传播错误而非产生降级裁决的分支——契约 4 已通过
assert.throws覆盖; - 返回已文档化的结构化错误信号(三态失败关闭策略、dispatch 约定、
{isError: true}返回形状)——结构化字段本身就是裁决,直接断言它即已满足本规则。
为什么这不是一条 lint 规则:在 epic #3051 期间,曾针对本仓库src/*.cts直接测量"宽松形态"的模式扫描,结果是 1 个真阳性对 3 个假阳性(一个文档化的三态策略、一个 dispatch 约定、一个结构化isError返回都被误判为宽松守卫)。宽松裁决形态是模块特有的、无法机械化枚举的,因此 lint 规则既不完备又噪音大。这是一条代码评审期望,而非 CI 闸门——它不会单独让构建失败;只有当评审者(人类或/code-review)让一个"没抛异常"测试顶替了正确性测试时才会失败。
三、ADR 456 新政策
ADR 456(docs/adr/456-test-rigor-architecture.md)为测试纪律引入了以下新政策。
政策 1:禁止时间/耗时断言
不得断言墙钟耗时。此类断言测试的是宿主机而非 SUT,在负载较高的 CI 运行器上会随机失败。
违规写法:
const start = Date.now(); await doWork(); assert(Date.now() - start < 200, 'must complete in 200ms');强制执行:local/no-elapsed-assertion(ESLint,error级别,由 #3331 提升,其前置条件由 #3314 交付:ADR-456 §(a) 修正为覆盖本仓库全部三种时钟控制机制的可达性选择规则,且直接携带真实时间门控逻辑的模块commands.cts、init.cts、io.cts已补上确定性覆盖)。另有no-restricted-syntax对断言中performance.now()比较的禁令。
规则实现(eslint-rules/no-elapsed-assertion.cjs)的正则只匹配计时词:elapsed、duration、took、ms及其 camelCase 变体(elapsedMs、msElapsed、elapsedTime、startMs、endMs),并刻意不匹配params、items、forms、terms、dirnames(无大写边界),也不匹配timeoutMs、cacheTtlMs、staleAfterMs这类确定性配置边界值——它们不是被测量的墙钟耗时,断言其相等是安全的。
政策 2:并发逻辑的时钟缝模式
并发逻辑必须通过三种按可达性选择的机制之一做确定性测试(见 ADR-456 §(a)):
- 可注入时钟缝:用于接受
{clock = Date}的模块; node:test的mock.timers:用于进程内直接读取Date的代码;GSD_TEST_MODE+GSD_NOW_MS子进程固定(经realClock路由):用于 CLI 派生的代码。
真实的 OS 调度器竞争在负载 CI 上是不确定的,无论适用哪种机制,都不允许作为测试模式。
合规模式:
// Production code function acquireLock(resource, { clock = Date } = {}) { const deadline = clock.now() + LOCK_TTL_MS; // ... implementation uses clock.now() } // Test test('lock expires after TTL', (t) => { t.mock.timers.enable(['Date']); t.mock.timers.setTime(0); acquireLock('res'); t.mock.timers.tick(LOCK_TTL_MS + 1); assert.strictEqual(isLockExpired('res'), true); });强制执行:local/no-magic-sleep-in-tests(禁止测试体内出现setTimeout/sleep/delay;ESLint,error级别)。该规则实现(eslint-rules/no-magic-sleep-in-tests.cjs)只作用于*.test.cjs文件,拦截用作 sleep 的Atomics.wait(),以及带数字字面量延迟且出现在await表达式或new Promise(...)中的setTimeout同步模式。代码评审直接捕获竞争模式。
政策 3:属性测试层级(Property-based testing tier)
实现解析、转换、预算/限额逻辑或任何双射契约的模块,必须至少包含一条断言领域不变量的fast-check(fc)属性测试。属性测试与单元测试同放在*.test.cjs文件中,无需单独套件标签。
可考虑的不变式类别:往返(round-trip)、单调性(monotonicity)、边界包含(boundary containment)、幂等性(idempotency)。
阈值:CI 工具不设硬性的每文件阈值;闸门是 Stryker 突变分数(见下文)。属性测试正是把逻辑密集路径的突变分数推到阈值之上的机制。
强制执行:代码评审核实范围内模块存在属性测试;Stryker 突变分数低于 80% 阻止合并。
政策 4:禁止临时超时字面量
禁止在测试调用点写裸数字timeout/timeoutMs选项值。两个独立猜出的相同魔法数字会静默漂移,更糟的是可能精确碰撞产生零余量竞争:bin/check-latest-version.cjs的timeout: 15_000与本套件的独立timeoutMs: 15000可能在完全相同瞬间 SIGKILL 整个进程树,且恰恰在 Windows CI 上失败(PR #4428 修复)。
违规写法:
const r = runHookSeam(WORKER_PATH, [], { timeoutMs: 15000 });合规写法一——与既有类规范同类的子进程:
const { GIT_TIMEOUT_MS } = require('./helpers/timeouts.cjs'); const r = runHookSeam(WORKER_PATH, [], { timeoutMs: GIT_TIMEOUT_MS });合规写法二——真正独立的类,本地声明并对其包裹的对象留出余量(PR #4428 的实际修复——worker 内部的npm view调用由它自己的具名NPM_VIEW_TIMEOUT_MS约束,外层测试导入它并显式加余量,而不是重新猜一个数字):
const { NPM_VIEW_TIMEOUT_MS } = require('../gsd-core/bin/check-latest-version.cjs'); const WORKER_TEARDOWN_MARGIN_MS = 10_000; // real headroom beyond the inner timeout it wraps const r = runHookSeam(WORKER_PATH, [], { timeoutMs: NPM_VIEW_TIMEOUT_MS + WORKER_TEARDOWN_MARGIN_MS });当调用属于同一类子进程时,从 tests/helpers/timeouts.cjs 导入既有类规范常量(PROBE_TIMEOUT_MS、GIT_TIMEOUT_MS、BUILD_TIMEOUT_MS、INSTALL_TIMEOUT_MS);若确实是不同类,则本地声明并加注释说明为何是不同类——详见 CONTRIBUTING.md 的 "Use Centralized Test Helpers" 一节。
该常量文件本身就是"类规范"思想的落地:除文档点名的四个常量外,还按子进程调用形状细分为HOOK_FANOUT_TIMEOUT_MS(嵌套 shell 扇出的 hook 调用,60000ms)、QUICK_SPAWN_TIMEOUT_MS(轻量无真实 git/网络工作的调用,10000ms)、INSTALL_TIMEOUT_MS(完整安装器运行,经负载实测从 60000ms 上调到 120000ms)等十余个常量,每个都带注释说明其"类"的边界与测量依据。值得注意的是,文件中多个数值相同的常量(如SEAM_DEFAULT_TIMEOUT_MS与HOOK_FANOUT_TIMEOUT_MS同为 60000)被刻意保持独立命名,因为"数值巧合"不等于"类相同"——合并会让未来对任一值的独立调整静默地移动另一个。
强制执行:local/no-adhoc-timeout-literal(ESLint,error级别)。非字面量值(Identifier、MemberExpression或CallExpression)被信任;只有可解析的数字字面量被标记。没有标记注释逃生通道——修复方式永远是提取具名常量。没有 allowlist:eslint-rules/no-adhoc-timeout-literal.allowlist.json曾为迁移前的遗留违规做祖父豁免,但引入该规则的 epic(#4445)跨越十七个批次迁移了每一处站点,并在最后一批删除该文件,因此local/no-adhoc-timeout-literal现在零豁免面运行。规则实现(eslint-rules/no-adhoc-timeout-literal.cjs)只匹配timeout/timeoutMs键、只针对tests/**/*.cjs(不触碰生产代码,生产代码中的字面量如execNpm(args, { timeout: 15_000 })是真实子进程的韧性边界)、用与no-unbounded-spawn.cjs相同的evalNumeric(深度上限 20)解析数字表达式(含60 * 60 * 1000这类算术链),并报告失效 allowlist 条目以强制"只降不升"。
政策 5:突变测试——80% 阈值
Stryker 在ubuntu-latest/ Node 24 CI 腿上以增量模式(--since origin/next)运行,作为 PR 门控信号。默认阈值为80% 突变分数(变更范围内杀死突变体数 / 突变体总数)。低于此阈值的 PR 必须要么添加能杀死存活突变体的测试,要么把特定路径加入stryker.config.mjs并附文档化理由。
存活突变体是缺失覆盖的具体规格说明。把它当作失败的测试,而不是一个指标。
从 stryker.config.mjs 可以看到完整的执行细节:
- 测试运行器:
tap(@stryker-mutator/tap-runner),按scripts/mutation-matrix.cjs解析出的分片测试文件逐个进程运行; - 变异范围:
gsd-core/bin/lib/**/*.cjs(ADR-457:这些是src/*.cts编译出的 gitignore 构建产物,直接变异已构建产物以避免每个突变体重跑一次 tsc 全量编译); - 覆盖率分析:
perTest——每个突变体只重跑覆盖它的测试文件而非全部分片;文档特别提醒NoCoverage与Survived计入同一分母,永远不要以排除 NoCoverage 的mutationScoreBasedOnCoveredCode作为门控; - 阈值:
high: 80、low: 60、break由 CI 分片经MUTATION_BREAK环境变量传入(本地未设时回退 60); - 增量模式:
incremental: true,CI 用--incremental --mutate <changed files>只测变更覆盖的模块; - 已知盲区:配置文件明确列出约 48% 的 lib 行(含
state、core、commands、phase、verify等最核心模块)暂被排除在变异范围外,缩小该列表是被跟踪的后续工作——必须先为模块补上*.unit.test.cjs/*.property.test.cjs覆盖,再删除其排除条目。
强制执行:CI 中stryker run --since origin/next;阈值配置在stryker.config.mjs。
政策 6:删除坏测试政策
以下类别的测试在同一 PR 内被删除并替换为合规测试。它们不被注释掉、不跳过、不附加永久// allow-test-rule豁免:
| 类别 | 信号 |
|---|---|
| Pass-always(永远通过) | 无论 SUT 状态如何断言恒为真 |
| Vacuous-truth(空真) | LHS 由与 SUT 输入相同的表达式计算 |
| Source-grep | 对源码文件readFileSync+ 文本断言 |
| Elapsed-time(耗时) | 断言Date.now()差值或performance.now()比较 |
| Real-race(真实竞争) | 测试结果依赖 OS 调度器时序 |
Permanentallow-test-rule | 无追踪 issue 且无截止日期的豁免 |
"替换"意味着:在同一 PR 中,添加一个按类型化表面强制(契约 1)、并在涉及时钟并发时按时钟缝模式、锻炼被删除测试原本意图覆盖的逻辑路径的行为测试。真实的跨进程竞争测试,一旦对应的确定性时钟缝测试覆盖了同一逻辑路径即被删除——不允许永久隔离。
强制执行:ESLint 规则捕获 source-grep、magic-sleep、elapsed-assertion 形态;代码评审是永远通过与空真断言的闸门;delete-bad-tests 清理(单独跟踪)处理 ADR 456 之前的遗留测试积压。
四、ESLint 规则速查表
| 规则 | 严重级别 | 拦截内容 |
|---|---|---|
local/no-source-grep | error(#3313 提升) | 对源码文件readFileSync+ 文本断言;对原始 stdout/stderr 的assert.match/doesNotMatch |
local/no-magic-sleep-in-tests | error | test()/it()/describe()体内的setTimeout/sleep/delay调用 |
local/no-elapsed-assertion | error(#3331 提升,#3314 交付前置条件) | 对Date.now()差值、process.hrtime()、performance.now()比较的断言 |
local/no-adhoc-timeout-literal | error | tests/**/*.cjs中裸数字timeout/timeoutMs选项字面量(PR #4428) |
no-only-tests/no-only-tests | error | 提交到非 scratch 文件的test.only/describe.only/it.only |
no-restricted-syntax(禁令 1) | error | ExpressionStatement中的顶层setTimeout |
no-restricted-syntax(禁令 2) | error | 对test/it/describe的.only成员访问(双保险) |
local/no-source-grep与local/no-magic-sleep-in-tests以error级别交付(由 #3313 提升,吸收原 #453 跟踪的清理工作);local/no-elapsed-assertion现也以error交付(#3331 提升)——#3314 先交付了它的前置条件(ADR-456 §(a) 修正为覆盖三种时钟控制机制的可达性规则;commands.cts/init.cts/io.cts补齐确定性覆盖),与 epic 为其他条目划定的交接边界一致。ADR 456 验收后新增的违规,无论 ESLint 严重级别如何,都属于越界违规。
ESLint 工具链细节见 docs/adr/452-eslint-lint-harness.md。
五、Markdownlint 合规
本文档自身也遵守 Markdown 规范:代码块显式标注语言标签(javascript、text),满足 MD040 要求;所有表格列数一致,满足 MD056 要求。这是 gsd-core 文档自举(dogfooding)测试纪律的体现——测试标准的文档本身也按标准编写。
六、实践要点总结
- 先写测试,用桩确认失败:TDD 红阶段是契约 3 的落地方式,也是避开"永远通过测试"最有效的路径。
- 断言具体形状,而非类型或"没抛异常":
assert.deepStrictEqual(result, {...})的具体降级裁决优于typeof result.x === 'string',参考 tests/worktree-safety.test.cjs 的修复后测试。 - 并发逻辑走时钟缝:可注入
{clock}参数、mock.timers或GSD_TEST_MODE+GSD_NOW_MS三选一,杜绝真实调度器竞争。 - 超时永远提具名常量:同类子进程从 tests/helpers/timeouts.cjs 导入类规范;确属不同类则本地声明并注释理由。
- 把存活突变体当失败测试:Stryker 增量运行于 CI,80% 突变分数是合并硬门槛;存活突变体是覆盖缺失的具体清单。
- 解析/转换/预算类模块至少一条属性测试:用
fast-check断言往返、单调性、边界包含或幂等性不变式,这是把逻辑密集路径推到突变阈值之上的机制。 - 坏测试就地删除并同 PR 替换:注释掉、跳过、永久豁免都不被接受;替换测试必须走类型化表面 + 时钟缝。
这套六契约 + ADR 456 政策的组合,使 gsd-core 的测试体系从"数量导向"转向"信号导向":每一行断言都必须能因 SUT 的真实缺陷而失败,每一条测试都必须覆盖它声称的路径,而突变分数与属性测试则把"缺失覆盖"变成可执行、可追踪、可合并门控的具体工作项。
【免费下载链接】gsd-core
Git. Ship. Done - Core
相关推荐
gsd-core 解析器对抗性测试体系:用 hostile fixtures 守护 frontmatter 与 ROADMAP 解析的确定性契约
gsd core 解析器对抗性测试体系:用 hostile fixtures 守护 frontmatter 与 ROADMAP 解析的确定性契约 导读 在 gs
Agent Zero 测试体系与 tests/AGENTS.md 契约:pytest 回归、安全与契约测试的工程规范
Agent Zero 测试体系与 tests/AGENTS.md 契约:pytest 回归、安全与契约测试的工程规范 本文基于 Agent Zero(Pytho
人工智能大模型AI AgentAgent 框架自主智能体多智能体工具调用MCP 服务浏览器控制一次跑完QQ空间历史说说备份:GetQzonehistory上手笔记
一次跑完QQ空间历史说说备份:GetQzonehistory上手笔记 上次翻手机相册,2018年的照片全挂了,只剩文字还在。QQ空间的旧图链接早就不稳,但内容本
网页爬虫数据分析
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考