深入 zstd CLI 测试框架:基于 run.py 的命令行端到端回归测试实战
【免费下载链接】mongoThe MongoDB Database项目地址: https://gitcode.com/GitHub_Trending/mo/mongo
导读
本文围绕 MongoDB 仓库内 vendored 的 zstd 源码(zstd 仓库目录)中的 CLI 测试套件 展开,系统讲解其设计定位、测试运行器run.py的全部命令行参数、测试用例的编写规范,以及setup/teardown脚本体系。读完本文,你将掌握如何运行 zstd CLI 的全部或单个回归测试、如何编写一个带有精确输出/glob 输出/忽略输出断言的可执行测试用例,并理解其底层实现原理,从而能够为 zstd 命令行工具(位于 programs/)贡献新的 CLI 测试。
一、CLI 测试的设计定位:只测命令行,不测库
该测试套件的核心定位在 README.md 中讲得很清楚:
- 聚焦 zstd 命令行工具本身:这些测试专注验证
zstdCLI 及其参数行为是否符合文档承诺("as advertised")。 - 测试目标是
programs/目录下的代码:测试只针对 CLI 层,而非压缩库本身(即lib/下的实现)。 - 库代码只获得附带覆盖:库函数在测试过程中会被间接执行,但如果你的目标是触发库内部的某个特定状态,应该使用库级单元测试,而不是 CLI 测试。
这一边界非常重要:它决定了测试的组织方式和断言粒度。CLI 测试关心的是"用户输入什么参数、进程退出码是多少、stdout/stderr 输出什么",而不过问库内部的压缩算法细节。
从源码结构看,CLI 相关实现集中在 zstdcli.c(命令行参数解析与主流程)以及 fileio.c(文件输入输出与压缩/解压调度),这正好对应 README 所述"只测programs/下代码"的边界。
二、测试运行器 run.py:全部用法详解
测试运行器是 run.py,一个纯 Python 3 脚本,无需第三方依赖。其用法在 README 中逐项说明,结合源码我们可以还原每个参数的完整语义。
2.1 默认行为与前置条件
run.py默认针对仓库内构建的zstd和datagen运行测试。从 run.py 的 main 入口 可以看到默认路径推导逻辑:
ZSTD_PATH = os.path.join(PROGRAMS_DIR, "zstd") # programs/zstd ZSTDGREP_PATH = os.path.join(PROGRAMS_DIR, "zstdgrep") # programs/zstdgrep ZSTDLESS_PATH = os.path.join(PROGRAMS_DIR, "zstdless") # programs/zstdless DATAGEN_PATH = os.path.join(TESTS_DIR, "datagen") # tests/datagen因此在运行测试前,必须先构建zstd与datagen。datagen是 zstd 自带的测试数据生成器(源码位于 programs/datagen.c,构建产物默认在tests/datagen),用于生成可复现的随机测试文件。
2.2 核心命令行参数
README 与run.py的参数定义(argparse 部分)共同给出了完整参数表:
| 参数 | 默认值 | 作用 |
|---|---|---|
--zstd /path/to/zstd | programs/zstd | 指定 zstd 二进制路径,会以环境变量ZSTD_BIN形式传递给测试(实际实现中通过符号链接目录ZSTD_SYMLINK_DIR间接引用) |
--exec-prefix 'valgrind -q' | 无 | 设置EXEC_PREFIX环境变量,为每次 zstd CLI 调用增加前缀,典型用途是套上valgrind、qemu等工具做内存检查或跨架构模拟 |
--datagen | tests/datagen | 指定 datagen 二进制路径(对应DATAGEN_BIN环境变量) |
--zstdgrep | programs/zstdgrep | 指定 zstdgrep 二进制路径(对应ZSTDGREP_BIN环境变量) |
--zstdless | programs/zstdless | 指定 zstdless 二进制路径(对应ZSTDLESS_BIN环境变量) |
--preserve | 关闭 | 保留scratch/目录及每个用例的exit/stdout/stderr产物,便于调试 |
--verbose | 关闭 | 输出每个检查项的详细结果与匹配信息 |
--timeout N | 200 秒 | 单用例超时;设为0表示禁用超时 |
--test-dir DIR | 本目录 | 指定测试目录(其中bin/会被加入$PATH,scratch/位于其下) |
--set-exact-output | 关闭 | 为失败的用例自动重写.stdout.exact/.stderr.exact |
位置参数tests | 无 | 仅运行指定测试(文件路径或相对测试目录的名称) |
一个关键细节:--exec-prefix只作用于 zstd 调用。README 明确说明,datagen、zstdgrep等工具不经过EXEC_PREFIX前缀。这从 bin/zstd 包装脚本 的实现可以印证:
if [ -z "$EXEC_PREFIX" ]; then "$ZSTD_SYMLINK_DIR/$zstdname" $@ else $EXEC_PREFIX "$ZSTD_SYMLINK_DIR/$zstdname" $@ fi而 bin/datagen 包装脚本 则直接执行"$DATAGEN_BIN" $@,确实不经过前缀。
2.3 scratch 目录与 --preserve 的作用
每个测试都在独立的临时工作目录中运行,路径模式为scratch/<test>/<name>。例如scratch/basic/help.sh/。目录中的scratch相对于测试目录(--test-dir)生成,默认为tests/cli-tests/scratch/。
- 默认情况下,测试结束后目录会被清理;
- 加上
--preserve后,目录被保留,并且测试的退出码、stdout、stderr 会分别保存为scratch/<test>/<name>/exit、stdout、stderr三个文件。
从 run.py 的实现 可以看到:--preserve时会在_check_output与_check_exit中把实际输出落盘。这在你"编写新测试、更新期望输出"的场景下非常有用——先跑一遍拿真实输出,再对照生成.exact或.glob期望文件。
2.4 运行全部测试
不带任何位置参数时,run.py会递归扫描测试目录(排除bin、common、scratch三个目录,见 EXCLUDED_DIRS),运行所有测试并汇总报告:
./run.py ./run.py --preserve ./run.py --zstd ../../build/programs/zstd --datagen ../../build/tests/datagen(上面最后一个示例假设你把 zstd 构建到了仓库外的build/目录。)
最终汇总逻辑在 run_tests:全部通过输出PASSED all N tests!并返回 0;否则列出失败的测试并返回非零退出码。
2.5 运行特定测试
测试名可以是:
- 测试文件的路径(如
basic/help.sh); - 相对测试目录的测试名(与路径形式相同)。
这在"编写或调试单个用例"时特别有用,搭配--preserve效果最佳:
./run.py basic/help.sh ./run.py --preserve basic/help.sh basic/version.sh ./run.py --preserve --verbose basic/help.sh从 resolve_listed_tests 的实现可以看到:传入的名称先按路径解析,若不存在则拼接--test-dir再尝试,仍不存在则直接报错;解析后会按"套件目录 → 用例文件名"组织执行。
2.6 更新精确输出:--set-exact-output
当测试失败原因是.stderr.exact或.stdout.exact与真实输出不再一致时,可以一键修正:
./run.py --set-exact-output ./run.py basic/help.sh --set-exact-output其实现位于 run.py 的_check_output_exact:当精确匹配失败且设置了set_exact_output时,把真实输出直接写回.exact文件。注意:该机制只作用于精确匹配,.ignore或.glob已存在的用例不受影响(见--set-exact-output的帮助文本)。使用前建议先人工核对差异,避免把错误行为"固化"进期望文件。
三、编写一个测试用例
3.1 用例的本质:任意可执行文件
README 明确:测试用例是任意可执行文件,可以是任何语言,但通常是 shell 脚本。脚本执行后,运行器会依次比对三个维度:
- 退出码(默认期望为 0,可用
.exit文件覆盖); - stderr(默认期望为空,可用
.stderr.exact/.stderr.glob/.stderr.ignore覆盖); - stdout(默认期望为空,可用
.stdout.exact/.stdout.glob/.stdout.ignore覆盖)。
每个用例运行在一个全新且干净的目录中,测试脚本可以在其中自由创建中间文件;目录在测试结束后清理,除非加了--preserve。
3.2 期望文件的三种形态
| 期望文件 | 匹配语义 |
|---|---|
$TEST.{stdout,stderr}.exact | 逐字节精确匹配(byte-for-byte) |
$TEST.{stdout,stderr}.glob | 每行按 glob 通配语法匹配;单独一行...表示"跳过任意行,直到下一行期望内容出现" |
$TEST.{stdout,stderr}.ignore | 完全忽略该输出流 |
.exit文件则包含一个整数,作为期望退出码;不存在时默认期望 0。
从 run.py 的_check_output可以看出期望文件的查找顺序:先找.exact,再找.glob,都没有则视为"忽略"(即期望输出为空——空输出匹配空输出自然通过)。.exact/.glob/.ignore/.exit后缀以及setup、setup_once、teardown、teardown_once、README.md、run.py等文件名都在 EXCLUDED_BASENAMES / EXCLUDED_SUFFIXES 中,不会被误当作测试用例。
...通配行的底层实现在 glob_diff:遇到...\n时,弹出下一行期望内容,然后持续消费实际输出行,直到找到能匹配该下一行期望内容的行;若耗尽实际输出仍未匹配,则判定失败。逐行 glob 匹配则用fnmatch.fnmatchcase(glob_line_matches)。因此*、?等 glob 字符可以直接用于模糊匹配版本号、时间戳、随机字节等不稳定输出。
3.3 通过示例(可直接参考)
退出码断言:默认期望退出码 0,脚本exit 1会失败;若确有非零退出意图,用.exit文件声明:
# exit-1.sh #!/bin/sh exit 1# exit-1.sh.exit 1stdout 精确匹配:
# echo.sh #!/bin/sh echo "hello world"# echo.sh.stdout.exact hello worldstderr glob 匹配(随机数据用 glob 模糊):
# random.sh #!/bin/sh head -c 10 < /dev/urandom | xxd >&2# random.sh.stderr.glob 00000000: * * * * * *多行跳过匹配(...匹配不定行数的中间输出):
# random-num-lines.sh #!/bin/sh echo hello seq 0 $RANDOM echo world# random-num-lines.sh.stdout.glob hello 0 ... world仓库中basic/套件就是很好的活例:help.sh 执行zstd -h/zstd -H/zstd --help,并用 help.sh.stdout.glob 断言"短帮助输出逐行精确、长帮助的 Advanced options 段落用...跳过";version.sh 用 glob 断言版本字符串。而compression/levels.sh、file-stat/下的多个用例(如compress-file-to-dir-without-write-perm.sh、compress-stdin-to-stdout.sh)则使用.stderr.exact精确断言错误消息,progress/下的用例使用.stderr.glob匹配进度条中的动态数据。
3.4 失败示例(帮助理解断言语义)
- 脚本
exit 1但未提供.exit文件 → 期望退出码 0,实际 1,失败; - 脚本
echo "hello world"但未提供任何 stdout 期望文件 → 期望 stdout 为空,实际有输出,失败; - 提供了
.stderr.exact为hello,但脚本输出world→ 精确匹配失败。
这三类失败分别对应退出码、stdout 空期望、stderr 精确期望三种校验路径,是理解断言体系的最直观案例。
四、bin/ 辅助脚本、common/ 公共库与环境变量
4.1 $PATH 前置与辅助命令
运行测试时,run.py会把测试目录下的bin/前置到$PATH(main 中的 env 组装:env["PATH"] = bin_dir + ":" + os.getenv("PATH", ""))。bin/下提供了一系列便于测试的包装脚本:
- bin/zstd:按
$EXEC_PREFIX前缀调用$ZSTD_SYMLINK_DIR下的同名符号链接(注意它是通过basename $0复用,因此unzstd、zstdcat等符号链接调用同一脚本); - bin/datagen:调用
$DATAGEN_BIN; unzstd、zstdgrep、zstdcat、zstdless:同名辅助命令,供测试脚本直接使用;- bin/println:
printf '%b\n' "${*}",输出带换行的文本(配合set -x调试时不会污染真实输出); - bin/cmp_size:比较两个文件大小,支持
-eq/-ne/-lt/-le/-gt/-ge操作符,用于断言压缩率相对关系; - bin/die:向 stderr 打印消息并以退出码 1 终止,用于显式断言"某命令不应成功"。
例如 compression/levels.sh 中就用cmp_size -lt file-19.zst file-1.zst断言"级别越高压缩后越小",用zstd -5000000000 -f file && die "Level too large, must fail"断言超范围级别必须失败。
4.2 符号链接与 zstd 多命令形态
setup_zstd_symlink_dir 会在bin/symlinks/下为zstd、zstdmt、unzstd、zstdcat、zcat、gzip、gunzip、lzma、xz、lz4等名称创建指向真实 zstd 二进制的符号链接(完整列表见 ZSTD_SYMLINKS)。这正是 zstd 支持"一个二进制多种命令形态"的测试基础,zstd-symlinks/套件的 setup 与zstdcat.sh用例专门验证了通过符号链接调用zstdcat的行为。
4.3 环境变量一览
README 指出,测试环境会提供一系列环境变量,可通过run.py --verbose打印(_test_environment中逐条_vlog,见 run.py L288-L298)。结合 main 的组装逻辑,完整列表如下:
| 环境变量 | 含义 |
|---|---|
EXEC_PREFIX | 由--exec-prefix设置,zstd 调用前缀 |
ZSTD_SYMLINK_DIR | bin/symlinks/目录,包装脚本通过它找到真实 zstd |
ZSTD_REPO_DIR | zstd 仓库根目录(tests/cli-tests/../..) |
DATAGEN_BIN | datagen 二进制绝对路径 |
ZSTDGREP_BIN | zstdgrep 二进制绝对路径 |
ZSTDLESS_BIN | zstdless 二进制绝对路径 |
COMMON | common/目录绝对路径,供公共库脚本 source |
PATH | 前置了bin/的完整路径 |
LC_ALL | 固定为C,保证输出区域设置一致 |
值得注意的是,_test_environment会剔除所有以ZSTD开头的宿主环境变量,以保证测试跨环境一致(例如宿主的ZSTD_CLEVEL不会污染用例)。不过用例内部仍可自行设置ZSTD_CLEVEL等变量来测试 CLI 行为——compression/levels.sh 就系统性验证了ZSTD_CLEVEL的取值、非法值回落默认级别、以及命令行参数对它的覆盖优先级。
4.4 common/ 公共脚本库
README 提到的公共库位于common/目录,实际仓库中包括 platform.sh、format.sh、mtime.sh、permissions.sh 等脚本,通过source "$COMMON/xxx.sh"方式引入。例如 format.sh 提供了zstd_supports_format(探测当前 zstd 是否支持某格式)与format_extension(格式名到扩展名映射)两个函数,供compression/format.sh、compression/gzip-compat.sh等用例判断当前构建的特性。README 中示例写作source "$COMMON/library.sh",实际引用时以common/目录下具体脚本文件名为准。
五、setup 与 teardown 脚本体系
5.1 套件级与用例级的两层脚本
测试目录中,每个目录是一个测试套件(test-suite),包含该目录下(不包含子目录)的所有用例。每个套件最多可携带 4 个脚本:
| 脚本 | 执行时机 | 工作目录 |
|---|---|---|
setup_once | 套件内所有用例之前,仅一次 | 套件级 scratch 目录(各用例 scratch 目录的父目录) |
teardown_once | 套件内所有用例之后,仅一次 | 同上 |
setup | 每个用例执行之前 | 该用例的 scratch 目录 |
teardown | 每个用例执行之后 | 该用例的 scratch 目录 |
套件级脚本用于"只做一次"的共享准备工作,以提升测试效率;用例级脚本用于每个用例都需要的前置工作,让用例脚本本身更简洁。从 TestSuite 的实现 可以看到:__enter__执行_setup_once(先清理再重建套件 scratch 目录),test_case上下文管理器在用例前后执行_setup/_teardown,__exit__执行_teardown_once(且仅在未设置--preserve时清理目录)。
5.2 官方示例逐段解读
用例级 setup 的典型用法(为多个用例准备同一批输入文件):
# basic/setup #!/bin/sh # Create some files for testing with datagen > file datagen > file0 datagen > file1# basic/test.sh #!/bin/sh zstd file file0 file1仓库中 compression/setup 与这个示例完全一致:先用datagen生成file、file0、file1,供compression/套件内的basic.sh、multiple-files.sh、multi-threaded.sh等用例直接压缩使用;cltools/setup则先echo "1234" > file再zstd file,为zstdgrep.sh/zstdless.sh准备压缩好的测试数据。
套件级 setup_once + 用例级 setup 的配合(dictionaries/套件):
# dictionaries/setup_once #!/bin/sh set -e . "$COMMON/platform.sh" mkdir files/ dicts/ for seed in $(seq 50); do datagen -g1000 -s$seed > files/$seed done zstd --train -r files -o dicts/0 -qq for seed in $(seq 51 100); do datagen -g1000 -s$seed > files/$seed done zstd --train -r files -o dicts/1 -qq cmp dicts/0 dicts/1 && die "dictionaries must not match!" datagen -g1000 > files/0# dictionaries/setup #!/bin/sh set -e # Runs in the test case's scratch directory. # The test suite's scratch directory that # `setup_once` operates in is the parent directory. cp -r ../files . cp -r ../dicts .这个组合展示了完整的效率设计:setup_once一次性训练两个不同的字典(dicts/0与dicts/1)并断言两者确实不同;setup在每个用例前把字典与文件复制到用例自己的 scratch 目录——既让用例脚本可以直接使用,又确保用例对共享数据的修改不会污染其他用例(从../相对路径可以确认:用例级 scratch 目录正是套件级 scratch 目录的子目录)。
六、仓库内现有测试套件一览
以当前仓库 tests/cli-tests/ 目录为准,现存的套件与主题包括:
| 套件目录 | 覆盖主题 |
|---|---|
| basic/ | 帮助信息(help.sh)、版本(version.sh)、内存限制(memlimit.sh)、输出目录(output_dir.sh) |
| cltools/ | zstdgrep、zstdless这两个配套工具的搜索/分页行为 |
| compression/ | 压缩级别与 clamp、--fast、多线程、多文件、长距离匹配、行匹配查找器、窗口调整、流大小、gzip 兼容、golden 数据 |
| decompression/ | 解压 golden 数据、非 zstd 数据的 pass-through 行为 |
| dict-builder/ | 字典构建对空输入/无输入的错误处理 |
| dictionaries/ | 字典匹配、字典不匹配的错误提示、golden 测试 |
| file-stat/ | 压缩/解压时"文件到文件 / 文件到 stdout / stdin 到文件 / stdin 到 stdout"的完整矩阵,以及无写权限目录的错误处理 |
| progress/ | 进度条输出与--no-progress的行为 |
| zstd-symlinks/ | 通过符号链接形态调用zstdcat等命令 |
其中compression/levels.sh是一个内容密度很高的参考用例:它验证了级别间压缩大小的偏序、--fast与-1等价、-0与默认级别等价、级别 clamp(-99收敛到 19、--fast=200000可正常工作)、超范围级别报错,以及ZSTD_CLEVEL环境变量的完整语义——包括合法值生效、非法值回退默认级别、命令行参数优先于环境变量。想理解 glob 断言的多种写法,progress/progress.sh.stderr.glob 与 compression/verbose-wlog.sh.stdout.glob 都值得通读。
七、运行与调试的推荐工作流
综合 README 与源码实现,一个可复制的开发循环如下:
- 构建前置二进制:按 zstd 标准的 make 流程构建
programs/zstd(以及zstdgrep、zstdless)与tests/datagen;如构建到非默认位置,用--zstd/--datagen/--zstdgrep/--zstdless显式指定。 - 先跑全量:
./run.py确认基线全绿(输出PASSED all N tests!)。 - 调试单个用例:
./run.py --preserve --verbose basic/help.sh,观察每个检查项(check_exit、check_stderr、check_stdout)的 PASS/FAIL 与差异信息;需要时可到scratch/basic/help.sh/下查看保留的exit/stdout/stderr文件。 - 更新期望输出:确认新输出行为正确后,用
./run.py --set-exact-output重写失配的.exact文件(注意审阅 diff,避免固化错误行为)。 - 跨工具验证:需要内存检查时用
--exec-prefix 'valgrind -q';需要跨架构模拟时换成qemu前缀。
八、结语:这套框架带给 CLI 测试的启示
从这份 README 与其实现 run.py 可以看出,zstd 的 CLI 测试框架有四个值得借鉴的设计点:其一,严格划清"CLI 层"与"库层"的测试边界,让测试意图单一明确;其二,期望文件与测试脚本分离,.exact/.glob/.ignore三种匹配粒度覆盖了从字节级到忽略级的全部断言需求,...通配行则优雅地处理了行数不确定的输出;其三,套件级与用例级两层 setup/teardown,在"共享成本"与"隔离性"之间取得平衡;其四,通过符号链接与$PATH前置构造多命令形态与透明执行前缀,让同一套测试天然支持valgrind、qemu等外部工具注入。理解这套机制后,你不仅可以为 zstd 的 CLI 行为编写高质量回归测试,也能为其他命令行工具的测试基础设施设计提供直接参考。
【免费下载链接】mongoThe MongoDB Database项目地址: https://gitcode.com/GitHub_Trending/mo/mongo
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考