首页
/ Deno tests/specs:用 JSONC 驱动的二进制集成测试及其源码级实现

Deno tests/specs:用 JSONC 驱动的二进制集成测试及其源码级实现

2026-09-06 18:41:56作者:宣利权Counsellor

Deno 仓库的 tests/specs/ 目录是整个 CLI 首选的集成测试体系:每个测试目录下的 __test__.jsonc 文件以声明式方式描述“要执行的 deno 命令 + 期望输出”,测试框架负责运行真实的 deno 二进制并比对 stdout/stderr。本文以 tests/specs/README.md 为主体,完整覆盖测试目录结构、清单文件格式、过滤运行方式与 .out 断言语法,并结合 tests/specs/mod.rs 的解析与执行代码,说明这些声明式字段在运行时如何被翻译为真实的进程调用与断言。读完后你将能够独立编写、运行和排查 Deno 的 spec 测试,并理解其多步骤、多变体、跨平台条件的底层机制。

Spec 测试的定位与目录结构

tests/specs/README.md 开宗明义:

These are integration tests that execute the deno binary. They are the preferred way of writing tests that use the deno binary.

也就是说,spec 测试直接拉起编译好的 deno 可执行文件、在隔离环境中执行子命令,再对输出做匹配——这与 tests/integration/ 下基于 Rust 编程式断言的测试互补,但对“命令 + 输出”这类场景,JSONC 清单比手写 Rust 断言表达力更直接、维护成本更低。

测试必须遵循如下目录结构:

tests/specs/<category_name>/<test_name>/__test__.json

即按“分类/测试名”两级目录组织,每个叶子目录放一个测试清单文件。当前仓库中这一结构实际规模可观:tests/specs/ 下约有 58 个分类目录(runcachecheckfmtlintjsrnpmnodepublish 等),共 2000 余个清单文件与 2800 余个 .out 期望输出文件。从源码结构看,manifest 的实际文件名是 __test__.jsonc(支持注释的 JSONC),这一点由 tests/specs/mod.rs 中的常量确认:

const MANIFEST_FILE_NAME: &str = "__test__.jsonc";

因此 README 中写作 __test__.json 可理解为格式说明,新写测试时应使用 .jsonc 扩展名以享受 schema 校验与编辑器提示。

测试清单的三种组织形式

__test__.jsonc 文件描述“要执行哪些测试、按什么步骤执行”。README 给出了三种典型形态。

形式一:单步测试(最常用)

{
  "args": "run main.js",
  "output": "main.out"
}

语义是:执行 deno run main.js,随后断言实际输出与 main.out 文件中的文本匹配。这是最简单也最高频的形态。仓库真实示例(tests/specs/run/_001_hello/__test__.jsonc):

{
  "args": "run --reload 001_hello.js",
  "output": "001_hello.js.out"
}

形式二:多步骤测试(steps 数组)

{
  "tempDir": true,
  "steps": [{
    "args": "cache main.ts",
    "output": "cache.out"
  }, {
    "args": "run main.ts",
    "output": "error.out",
    "exitCode": 1
  }]
}

步骤按顺序执行,任一步骤失败则测试失败。上例先 deno cache 预缓存模块,再验证 run 因权限等原因以退出码 1 结束。tempDir: true 表示先把测试目录内的非断言文件复制到临时目录、再在其中执行命令(原因与实现见下文“临时目录机制”)。

形式三:一个文件多个测试(tests 对象)

{
  "tests": {
    "ignore_dir": {
      "args": "run script.ts",
      "output": "script.out"
    },
    "some_other_test": {
      "args": "run other.ts",
      "output": "other.out"
    }
  }
}

同一目录下定义多个命名测试,各自独立执行、独立断言。从源码看(tests/specs/mod.rsmap_test_within_file 函数),框架在收集阶段就会检查文件是否含 tests 键:含则反序列化为 MultiTestMetaData 并展开为测试子项(每个子项命名为 父测试::子项名);否则按单/多步测试处理。

顶层属性

README 列出的顶层属性如下,可结合源码中的反序列化结构(SingleTestMetaData / MultiStepMetaData / MultiTestMetaData)理解其完整集:

属性 类型 说明
repeat number 测试重复执行的次数,任一次失败即整体失败
tempDir boolean 将所有非测试文件复制到临时目录并在其中执行。默认测试的当前工作目录就是测试目录本身;但对于会创建 node_modules 等目录的测试,直接污染源码树不可接受,需使用临时目录

此外,源码中还可识别以下顶层字段(README 未逐一展开,写复杂测试时有用):

  • envs(object):注入给所有步骤的环境变量;
  • cwd(string):覆盖步骤的执行目录;
  • ignore(boolean):跳过整个测试;
  • timeout(number):每个步骤的超时秒数,源码注释标明默认 300 秒(5 分钟)
  • variants(object):定义可复用变量集,每个变体展开为一个子测试(见下文“变体机制”);
  • canonicalizedTempDir / symlinkedTempDir(boolean):对临时目录做规范化 / 软链处理,源码注释提醒应谨慎使用(后者仅在 debug 构建允许,用于模拟 CI 的软链环境)。

步骤(Step)属性

README 指出:当只写单步时,步骤属性可以直接放在顶层而不必嵌套进 steps 数组或 tests 对象。步骤支持的全部字段:

  • args:字符串(会按空白拆分为参数数组)或参数数组;
  • output:期望输出。可以是路径(必须以 .out 结尾,指向期望文本文件),也可以是直接内联的文本(与输出做模式匹配);
  • flaky:标记该步骤为 flaky,失败时最多重试 3 次;
  • if"windows" / "linux" / "mac" / "unix"):控制该步骤是否在对应平台运行;
  • exitCode(number):期望退出码。

源码(StepMetaData 结构)中另有若干实战字段:

  • cwd:仅覆盖本步骤的工作目录;
  • commandName:替换被执行的命令名(默认是 deno),可用于测试其它可执行程序;
  • envs:仅本步骤的环境变量,会与顶层 envs 合并(步骤优先);
  • input:喂给子进程 stdin 的文本;若以 .in 结尾,则视为同目录下的输入文件路径。

一个值得注意的实现约束:内联 output 文本长度不得超过 160 字符,否则测试会直接失败,报错信息为 The "output" property in your __test__.jsonc file is too long. Please extract this to an .out file to improve readability.——这是对可读性的硬性规约,长期望输出必须落成 .out 文件。

if 条件的完整取值

README 只列了四个平台条件,而 tests/specs/mod.rsshould_run 函数实际支持更多条件(作用于步骤或测试级):

条件 含义
windows / unix / mac / linux 平台条件
notCI 仅本地运行(未设置 CI 环境变量时)
notMacIntel 排除 macOS x86_64(Intel 虚拟机)
notWindowsArm 排除 Windows on ARM
notWindowsArmOrMacIntel 同时排除以上两类 CI 弱环境
notSlowCiShard 在 CI 分片策略中避开慢速分片

写跨平台测试时,这些条件比在 CI 脚本里做过滤更内聚。

运行与过滤

README 给出的运行方式:

cargo test specs::category_name::test_name

或只做子串匹配(可能命中其它测试):

cargo test test_name

需要查看每个测试的实时输出时追加 -- --no-capture(注意:这会令测试串行执行而非并行):

cargo test test_name -- --no-capture

仓库还封装了更方便的入口 tools/x.ts 中的 test-spec 子命令,其帮助文本说明“每条 spec 测试定义了要执行的 CLI 命令,并对 stdout/stderr 做断言,通配符包括 [WILDCARD][WILDLINE] 等”:

./x test-spec <filter>    Run tests matching the filter
./x test-spec --list      List all available tests

./x test-spec fmt        # 运行名字含 "fmt" 的 spec 测试
./x test-spec run        # 运行名字含 "run" 的 spec 测试

底层等价于 cargo test -p specs_tests --test specs -- <filter>。另外源码 main() 中有一处防误操作保护:当检测到 CLAUDE_CODE_ENTRYPOINT 环境变量(即从 Claude Code 会话中触发)且没有提供过滤参数时,会直接 panic 拒绝全量跑 spec 套件,避免意外执行整个集成测试集。

.out 文件与通配断言语法

.out 文件是输出断言的载体。为容忍路径、内存地址、耗时等不确定内容,文件内支持如下匹配标记:

标记 语义
[WILDCARD] 匹配该处的任意文本
[WILDLINE] 匹配当前行的任意文本
[WILDCHAR] 匹配下一个字符
[WILDCHARS(5)] 匹配接下来任意 5 个字符
[UNORDERED_START][UNORDERED_END] 区间内各行按任意顺序匹配(用于非确定性输出)
[# example] 行内注释,以 [# 开始、] 结束

配合步骤的 exitCode 断言(源码中通过 output.assert_exit_code(step.exit_code) 执行),可以同时锁定“退出状态 + 输出文本”两个维度。

IDE 自动补全(JSON Schema)

README 建议在本地 .vscode/settings.json 中登记 schema 以获得补全与校验。注意其中的 URL 是相对仓库根目录的路径,schema 文件实际位于 tests/specs/schema.json

{
  "json.schemas": [{
    "fileMatch": [
      "__test__.jsonc"
    ],
    "url": "./tests/specs/schema.json"
  }]
}

该 schema 为 draft-07 格式,将单步测试定义为 argsoutput 两个必填项,并对 cwdcommandNameenvsflakycanonicalizedTempDir 等字段给出类型约束,与上文“步骤属性”一节相互印证。

源码级实现走读:从 JSONC 到进程调用

理解以下调用链能解释很多“为什么这样写”的疑问,全部位于 tests/specs/mod.rs

1. 收集阶段(main / map_test_within_file。框架用 TestPerDirectoryCollectionStrategy__test__.jsonc 为锚点扫描 tests/specs/ 下每个目录,再调用 map_test_within_file 判定文件形态:含 tests 键 → 多测试分类;含非空 variants → 展开为变体子测试;否则作为单/多步测试登记。解析入口 deserialize_value 先看有无 steps 键来决定反序列化到 MultiStepMetaData 还是 SingleTestMetaData(后者经 into_multi() 归一化为单步),从而让三种写法在运行期收敛为统一的“步骤列表”模型。

2. 执行环境(test_context_from_metadata。根据元数据构建 TestContexttempDir 为真时改用 use_temp_cwd(),否则 cwd 就是测试目录本身;未指定 base 时,默认注入 JSR、npm 与 compile 相关的环境变量(add_jsr_env_vars 等)。

3. 临时目录的复制规则tempDir: true 时,cwd.copy_to_recursive_with_exclusions 会把测试目录整体拷入临时目录,但通过 resolve_test_and_assertion_files 排除 manifest 本身和所有 .out 断言文件——所以断言文件在临时 cwd 中不存在,而 output 断言时又按原始测试目录拼接路径(cwd.join(&step_output)),保证期望值始终来自源码树。

4. 步骤执行(run_step。按“合并环境变量 → 应用变体替换 → 拼接 args(VecOrString)→ 设置 cwd / commandName → stdin(input)→ 超时(timeout)→ 执行”的顺序组装子进程;输出断言按 output 是否以 .out 结尾分流到 assert_matches_fileassert_matches_text,最后校验 exitCode--no-capture 由全局 NO_CAPTURE 开关映射为 command.show_output(),这就是它导致串行的原因。

5. 变体机制(variantsmap_variants 将每个变体展开为 父测试::变体名 子测试;variant_substitutions 把变体里的字符串值映射成 ${name} → 值 的替换对,随后在 args、output、envs 中统一替换(替换后变空的参数会被剔除)。这使得同一份步骤模板可以在不同参数(如不同权限标志、不同 registry 地址)下复用,而不用复制粘贴多个目录。

6. 稳定性设施flaky 步骤经 run_maybe_flaky_test 重试;CI 环境下所有测试默认按 flaky 处理(metadata.flaky || *IS_CI);main 开头还会对 tests/specstests/utiltests/testdatatests/registry 与相关二进制做 CI hash 校验,未变更时直接跳过整套 spec 测试,加速 CI 反馈。

编写与排查指南小结

综合 README 与源码,编写一个新 spec 测试的完整流程是:

  1. tests/specs/<category>/<test_name>/ 下建目录;
  2. __test__.jsonc(单步写 args + output 即可);会污染 cwd 的测试加 tempDir: true
  3. 长输出落成 .out 文件,不确定内容用 [WILDCARD] / [UNORDERED_START] 等标记,内联断言控制在 160 字符内;
  4. cargo test specs::<category>::<test_name>(或 ./x test-spec <filter>)过滤运行;排障时加 -- --no-capture 看实时输出;
  5. 编辑器按上文配置 tests/specs/schema.json 获得补全。

排查时可按失败形态对照:退出码不符看 exitCode 与实际 stderr;输出不符打开 .out 检查是否漏写 [WILDLINE] 或平台相关路径差异(必要时用 if 条件区分平台);临时目录类失败检查是否误把 .out 文件依赖当成了 cwd 内文件(它们不会被复制进临时目录);超时则显式设置 timeout

这套机制的价值在于:它把“跑什么命令、期望什么输出”沉淀为可读、可 diff、可被 schema 校验的数据文件,让 Deno 两千多个 CLI 行为的回归验证不依赖大量易腐化的 Rust 断言代码——这正是它被定位为使用 deno 二进制的“preferred way”的原因。

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