首页
/ rtk CLI 过滤器的测试体系:从单元测试到 Token 精度验证的完整策略

rtk CLI 过滤器的测试体系:从单元测试到 Token 精度验证的完整策略

2026-09-06 11:55:36作者:晏闻田Solitary

rtk(Rust Token Killer)是一个通过过滤、分组、截断和去重来压缩命令输出的 CLI 代理,其核心价值主张——"在常见开发命令上减少 60–90% 的输出"——必须靠一套严密的测试体系来兑现。本文基于仓库内的测试策略规则 cli-testing.md 展开,完整讲解 RTK 的五层测试结构:单元测试、Token 精度测试、跨平台测试、集成测试与性能测试,并给出可直接复制的测试模板、夹具(fixture)捕获工作流与合并前检查清单,帮助你在为 RTK 新增或修改过滤器时,做到"既有可复现的实操步骤,又有源码级依据"。

一、测试金字塔总览:五层测试各司其职

RTK 的测试策略按优先级和触发时机分为五层,每一层对应不同的变更风险:

测试层 优先级 触发时机 测试位置
单元测试(Unit Testing) 关键 所有过滤器变更、输出格式修改 与被测文件同目录的 #[cfg(test)] mod tests
Token 精度测试(Token Accuracy) 关键 所有过滤器实现、token 节省声明 同单元测试模块
跨平台测试(Cross-Platform) 关键 Shell 转义变更、命令执行逻辑 #[cfg(target_os)] 条件编译
集成测试(Integration) 重要 新过滤器、命令路由变更、发布准备 tests/ 顶层文件
性能测试(Performance) 重要 性能相关变更、发布准备 hyperfine / /usr/bin/time 外部基准

这个分层的背后是 RTK 的命令代理架构:main.rs 通过 Clap 的 Commands 枚举把 CLI 命令路由到 src/cmds/*/ 下的专用过滤模块,每个模块执行底层命令并压缩其输出(见 CLAUDE.md 的架构说明)。正因为输出正确性完全依赖各过滤器,"每个过滤器都要被单独验证"才成为测试体系的第一原则。

二、单元测试:与过滤器共置的测试块

基本写法

单元测试采用最朴素的形式:在与过滤器同一文件内放置 #[cfg(test)] mod tests 块,用 assert_eq!/assert! 直接对期望输出做断言:

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_git_log_output() {
        let input = "abc1234 fix: handle empty commit\ndef5678 feat: add filter\n";
        let output = filter_git_log(input);
        assert_eq!(output, "abc1234 fix: handle empty commit\ndef5678 feat: add filter");
    }
}

这一约定在仓库中得到了全面践行:从源码结构看,src/cmds/ 下几乎每个过滤器文件都带有测试模块,例如 git.rs 中就有 3 处 #[cfg(test)] 块,而 cargophpjssystem 等子目录下的 50 余个命令文件基本都有 1–2 处,测试与实现"同文件共置"是整个代码库的统一风格。

两种夹具策略并存

RTK 的单元测试存在两种夹具(fixture)模式,根据过滤器的实际需求选择:

  1. 内联字面量字符串src/cmds/** 单元测试中最常见)——在测试体内直接构造一小段有代表性的字符串,适合快速覆盖某种特定格式或边界情况。src/cmds/git/git.rssrc/cmds/git/gh_cmd.rs 等处广泛使用此模式。
  2. 真实捕获夹具 + include_str!——当原始输出体积大、或格式敏感到内联字符串已经不可读、容易与现实漂移时使用。参考范本是 mvn_cmd.rs:该文件是 include_str! 夹具模式的"标准答案",当前实际包含 40 余处 include_str! 调用(文档写作时记载为 23+,此后仍在增长),覆盖 mvn test 通过/失败/多失败/编译错误、mvnd(Maven Daemon)reactor 通过/失败、expected 快照对比等多种场景。这些夹具位于 tests/fixtures/,例如:

何时使用

  • 每个新过滤器:覆盖常见用例,外加至少一个边界用例(空输入、错误输出);
  • 输出格式变更:过滤器逻辑变化时,同步更新相关的 assert_eq! 期望值;
  • 回归检测:一旦输出体积或格式让手写字符串变得脆弱或失真,就优先切换到真实夹具(include_str!),而不是继续加长内联字符串。

夹具驱动的完整工作流

以新增一个 include_str! 模式的 mvn 测试为例,完整流程是"捕获 → 写测试 → 运行"三步:

# 1. 捕获真实输出作为夹具(仅 include_str! 模式需要)
mvn test > tests/fixtures/mvn_test_example_raw.txt

# 2. 在被测模块内追加测试
cat >> src/cmds/jvm/mvn_cmd.rs <<'EOF'
#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_mvn_test_example() {
        let input = include_str!("../../../tests/fixtures/mvn_test_example_raw.txt");
        let output = filter_mvn_test(input);
        assert!(output.contains("FAILED") || output.contains("PASSED"));
    }
}
EOF

# 3. 运行该测试
cargo test test_mvn_test_example

注意夹具路径是相对源文件的 ../../../tests/fixtures/src/cmds/jvm/ 上跳三级到仓库根),include_str! 会在编译期把夹具内容嵌入二进制,因此新增夹具文件后无需任何运行时配置。

三、Token 精度测试:60% 是发布底线

这是 RTK 测试体系中最具项目特色的一层:所有过滤器都必须用真实夹具验证其 token 节省声明。RTK 的全部产品价值就是压缩率,如果测试只验证"不 panic"而不验证"省了多少",核心价值主张就成了无法证伪的口号。

Token 计数测试模板

#[cfg(test)]
mod tests {
    fn count_tokens(text: &str) -> usize {
        text.split_whitespace().count()
    }

    #[test]
    fn test_git_log_savings() {
        let input = "..."; // 内联字符串或 include_str! 夹具
        let output = filter_git_log(input);

        let input_tokens = count_tokens(input);
        let output_tokens = count_tokens(&output);

        let savings = 100.0 - (output_tokens as f64 / input_tokens as f64 * 100.0);

        assert!(
            savings >= 60.0,
            "Git log filter: expected ≥60% savings, got {:.1}%",
            savings
        );
    }
}

关于 count_tokens 有两个值得注意的实现事实:

  • 该辅助函数在每个测试模块内各自重复定义,而不是放在共享的 tests/common/mod.rs 中——从源码结构看,fn count_tokens 分散在 mvn_cmd.rsgradlew_cmd.rsgh_cmd.rsglab_cmd.rsgit.rssbt_cmd.rs 等多个文件中。编写新测试时不要假设存在共享 helper,直接本地定义即可。
  • 测试侧的 split_whitespace() 词数估算与产品侧的计量口径并不相同:RTK 本身不捆绑分词器,tracking.rsbytes / 4 来估算 token(见 CLAUDE.md)。也就是说测试衡量的是压缩比(词数比),而运行时统计的是字节比,两者方向一致但口径独立。

夹具制作:用真实命令输出,拒绝合成数据

# 捕获真实输出
git log -20 > tests/fixtures/git_log_raw.txt
cargo test 2>&1 > tests/fixtures/cargo_test_raw.txt
gh pr view 123 > tests/fixtures/gh_pr_view_raw.txt
pnpm list > tests/fixtures/pnpm_list_raw.txt

# 然后在测试中使用:
# let input = include_str!("../tests/fixtures/git_log_raw.txt");

tests/fixtures/ 目录现有约 70 个夹具文件,覆盖 mvn/mvnd、gradlew、ctest、glab、phpstan、dotnet、sbt、aws 等生态的真实输出(含 .gz 压缩的大文件和 expected 期望输出对),是这一原则的集中体现。

节省率目标:单一底线,而非逐命令表格

文档对节省率目标做了明确的纪律约束:

  • 不存在逐过滤器的百分比表格,只有一条强制底线:≥60% 节省是发布阻断项(release blocker),与 CLAUDE.md 中"Pre-commit Gate / 性能目标"的约定一致(该项目对 bash 输出声明 60–90% 的压缩区间);
  • 各过滤器通常会大幅超过这条底线,但不要在测试中断言具体的逐命令百分比(例如"gh pr view 达 87%"),除非你已用该过滤器自己的夹具实测过真实数字——断言阈值因过滤器而异,而文档里罗列杜撰数字的表格会立刻腐烂(rot);
  • 发布阻断:任何过滤器的节省率跌破 60%,必须在合并前查明原因并修复。

四、跨平台测试:Shell 转义的三分支断言

RTK 需要同时工作于 macOS(zsh)、Linux(bash)、Windows(PowerShell),三者的 Shell 转义规则不同,因此涉及 Shell 转义与命令执行逻辑的变更必须触发跨平台测试。

cfg 按平台条件编译期望值

测试策略文档给出的模式是:为每个平台定义一份期望 Shell 常量,并在同一测试内按目标 OS 分支断言:

#[cfg(target_os = "windows")]
const EXPECTED_SHELL: &str = "cmd.exe";

#[cfg(target_os = "macos")]
const EXPECTED_SHELL: &str = "zsh";

#[cfg(target_os = "linux")]
const EXPECTED_SHELL: &str = "bash";

#[test]
fn test_shell_escaping() {
    let cmd = r#"git log --format="%H %s""#;
    let escaped = escape_for_shell(cmd);

    #[cfg(target_os = "windows")]
    assert_eq!(escaped, r#"git log --format=\"%H %s\""#);

    #[cfg(not(target_os = "windows")]
    assert_eq!(escaped, r#"git log --format="%H %s""#);
}

这里 escape_for_shell 是规则文档为转义类函数定义的测试模式示例名,用于演示"一个测试函数 + 多个 #[cfg] 断言分支"的写法;从源码结构看,仓库中并没有名为 escape_for_shell 的公开函数(路径转义相关的实现散落在如 tee.rs 的模块内部),落地时请替换为你实际被测的转义函数。

各平台 Shell 差异速查

平台 Shell 引号转义 路径分隔符
macOS zsh '单引号'"双引号" /
Linux bash '单引号'"双引号" /
Windows PowerShell `反引号"双引号" \

测试执行的平台分工

# Linux/macOS(主力平台):本地直接跑
cargo test

Windows 通过 CI 覆盖:信任 CI/CD 流水线的 Windows 作业,或在手边有 Windows 机器时手动验证。CLAUDE.md 也把"过度测试跨平台行为"列为要避开的兔子洞——本地覆盖 macOS + Linux,Windows 交给 CI。

五、集成测试:tests/ 顶层文件与 #[ignore] 真进程测试

顶层集成测试的分工

集成测试以顶层文件形式放在 tests/ 目录(不随 src/ 共置),当前仓库包含:

这些测试验证的是横切行为(search/grep 压缩、guard rails、忠实格式化)而非单个过滤器模块,其中多个测试同时使用 tests/fixtures/ 中真实捕获的 aws/glab/gradlew/mvn/phpstan/dotnet 输出与各自的内联用例。

#[ignore] 真进程测试

真正拉起 RTK 二进制、执行真实命令的测试标记为 #[ignore],避免默认测试跑(它依赖已安装的 rtk 二进制和 git 仓库):

#[test]
#[ignore] // 运行方式:cargo test --ignored
fn test_real_git_log() {
    // 依赖:
    // 1. RTK 二进制已安装(cargo install --path .)
    // 2. 可用的 Git 仓库

    let output = std::process::Command::new("rtk")
        .args(&["git", "log", "-10"])
        .output()
        .expect("Failed to run rtk");

    assert!(output.status.success());
    assert!(!output.stdout.is_empty());

    // 验证输出是压缩后的(而非原始 git 输出)
    let stdout = String::from_utf8_lossy(&output.stdout);
    assert!(stdout.len() < 5000, "Output too large, filter not working");
}

这类断言的精髓在于反向验证压缩生效:不是检查输出等于某段字符串,而是检查"输出必须小于某个体积上界"——一旦过滤器失效、原始输出直通,测试立即失败。

运行顺序

# 1. 本地安装 RTK
cargo install --path .

# 2. 运行全部测试,包含 tests/*.rs 顶层集成测试
cargo test --all

# 3. 运行被忽略的(真进程)集成测试
cargo test --ignored

# 4. 运行指定测试
cargo test --ignored test_real_git_log

此外仓库还提供了 scripts/test-all.sh 作为冒烟脚本(需要已安装的 RTK 二进制),与 CLAUDE.md 中记录的 bash scripts/test-all.sh 用法一致。

何时运行

  • 发布前:始终运行集成测试;
  • 过滤器变更之后:确认真实命令输出下过滤器仍然有效;
  • Hook 变更之后:确认 Claude Code 集成仍正常工作(仓库 hooks/ 目录维护了 claude、copilot、cursor 等各 Agent 的重写钩子,src/hooks/ 则是对应的 Rust 侧实现)。

六、性能测试:<10ms 启动、<5MB 内存

RTK 的性能目标是启动时间 <10ms、内存占用 <5MB、二进制体积 <5MB。这一约束直接影响了架构决策(CLAUDE.md 明确"无 async,单线程设计,启动 <10ms"),因此性能测试不是可选项。

用 hyperfine 基准测试启动时间

# 安装 hyperfine
brew install hyperfine  # macOS
cargo install hyperfine  # 或经 cargo 安装

# 基准对比:RTK vs 原始命令
hyperfine 'rtk git status' 'git status' --warmup 3

# 应显示 RTK 启动 <10ms
# 示例输出:
#   rtk git status    6.2 ms ±  0.3 ms
#   git status        8.1 ms ±  0.4 ms

内存占用测量

# macOS
/usr/bin/time -l rtk git status
# 查看 "maximum resident set size",应 <5MB

# Linux
/usr/bin/time -v rtk git status
# 查看 "Maximum resident set size",应 <5000 kbytes

性能回归检测:before/after 对比法

# 变更前
hyperfine 'rtk git log -10' --warmup 3 > /tmp/before.txt

# 变更后
cargo build --release
hyperfine 'target/release/rtk git log -10' --warmup 3 > /tmp/after.txt

# 对比
diff /tmp/before.txt /tmp/after.txt
# 若启动时间增加 >2ms,需要调查原因

性能目标与验证手段

指标 目标 验证方式
启动时间 <10ms hyperfine 'rtk <cmd>'
内存占用 <5MB time -l rtk <cmd>
二进制体积 <5MB ls -lh target/release/rtk

七、测试组织:目录结构与最佳实践

完整的测试目录结构如下:

rtk/
├── src/
│   ├── cmds/
│   │   ├── git/
│   │   │   ├── git.rs              # 过滤器实现
│   │   │   │   └── #[cfg(test)] mod tests { ... }
│   │   ├── jvm/                    # gradlew、mvn — include_str! 夹具的参考范本
│   │   ├── php/                    # php, artisan, phpunit, phpstan, pest, paratest, ecs, pint
│   │   └── ...
│   ├── core/                       # 共享基础设施
│   ├── hooks/                      # Hook 系统
│   └── analytics/                  # Token 节省分析
├── tests/
│   ├── fixtures/                   # 真实捕获的命令输出
│   │   ├── mvn_test_pass_slice_raw.txt
│   │   ├── gradlew_build_raw.txt
│   │   ├── glab_mr_list_raw.json
│   │   └── ...
│   ├── grep_context_test.rs        # 顶层集成测试
│   ├── grep_faithful_format_test.rs
│   ├── guard_integration_test.rs
│   ├── search_compress_test.rs
│   ├── search_error_test.rs
│   └── search_faithful_test.rs

最佳实践总结

  • 单元测试:嵌入模块内(#[cfg(test)] mod tests),与过滤器共置;
  • 夹具:小而快的用例优先内联字符串;用例变大或格式敏感后切换到 tests/fixtures/include_str! 真实输出——模式参照 mvn_cmd.rs
  • count_tokens helper:目前是各测试模块各自重复定义,不要假设存在共享的 tests/common/mod.rs
  • 集成测试:顶层 tests/*.rs 文件,部分含 #[ignore] 标记的真进程测试。

新增过滤器时的完整检查清单见 src/cmds/README.md 的"Adding a New Command Filter"章节。

八、测试检查清单:从实现到发布

新增或修改过滤器时,按阶段逐项核对:

实现阶段

  • [ ] 在该过滤器自己的 #[cfg(test)] mod tests 块中编写单元测试(内联字符串,或较大/真实输出用 include_str! 夹具)
  • [ ] 添加 token 精度测试(用本地定义的 count_tokens 验证 ≥60% 节省)
  • [ ] 测试跨平台 Shell 转义(如适用)

质量检查

  • [ ] cargo test --all 全部通过
  • [ ] cargo test --ignored 集成测试通过
  • [ ] 用 hyperfine 基准测试启动时间(<10ms)

合并前

  • [ ] 所有测试通过(cargo test --all
  • [ ] token 节省 ≥60% 已验证
  • [ ] 跨平台测试通过(Linux + macOS)
  • [ ] 性能基准通过(启动 <10ms)

发布前

  • [ ] 集成测试通过(cargo test --ignored
  • [ ] 性能回归检查(hyperfine before/after 对比)
  • [ ] 内存占用验证(time -l 下 <5MB)
  • [ ] 跨平台 CI 通过(Linux + macOS + Windows)

这些检查项最终汇入 CLAUDE.md 定义的"Pre-commit Gate"——每次 Rust 文件编辑后必须通过 cargo fmt --all && cargo clippy --all-targets && cargo test --all 三道闸口,clippy 警告零容忍。

九、常见测试模式与反模式

模式一:内联夹具 + Token 精度(小而快的合成用例)

#[cfg(test)]
mod tests {
    use super::*;

    fn count_tokens(text: &str) -> usize {
        text.split_whitespace().count()
    }

    #[test]
    fn test_output_format() {
        let input = "raw command output here";
        let output = filter_cmd(input);
        assert_eq!(output, "expected filtered output");
    }

    #[test]
    fn test_token_savings() {
        let input = "raw command output here";
        let output = filter_cmd(input);

        let savings = 100.0 - (count_tokens(&output) as f64 / count_tokens(input) as f64 * 100.0);
        assert!(savings >= 60.0, "Expected >=60% savings, got {:.1}%", savings);
    }
}

模式二:include_str! 夹具(真实捕获输出)

适用于输出足够大或格式敏感、手写字符串会与现实漂移的过滤器(见 mvn_cmd.rs):

#[test]
fn test_mvn_test_pass() {
    let input = include_str!("../../../tests/fixtures/mvn_test_pass_slice_raw.txt");
    let output = filter_mvn_test(input);
    assert!(output.contains("BUILD SUCCESS"));
}

模式三:边界用例(过滤器健壮性)

#[test]
fn test_empty_input() {
    let output = filter_cmd("");
    assert_eq!(output, "");
}

#[test]
fn test_malformed_input() {
    let malformed = "not valid command output";
    let output = filter_cmd(malformed);
    // 二选一:
    // 1. 返回尽力而为的过滤输出,或
    // 2. 原样返回输入(fallback)
    // 两者都可接受——关键是绝不能 panic!
    assert!(!output.is_empty());
}

#[test]
fn test_unicode_input() {
    let unicode = "commit 日本語メッセージ";
    let output = filter_cmd(unicode);
    assert!(output.contains("commit"));
}

#[test]
fn test_ansi_codes() {
    let ansi = "\x1b[32mSuccess\x1b[0m";
    let output = filter_cmd(ansi);
    // 应去除或保留 ANSI 码,但不能弄坏输出
    assert!(output.contains("Success") || output.contains("\x1b[32m"));
}

"失败时回退、绝不 panic"与 CLAUDE.md 编码规则中的 fallback 模式(过滤器失败时原样执行原始命令)一脉相承。

模式四:集成测试(端到端行为)

#[test]
#[ignore]
fn test_real_command_execution() {
    let output = std::process::Command::new("rtk")
        .args(&["cmd", "args"])
        .output()
        .expect("Failed to run rtk");

    assert!(output.status.success());
    assert!(!output.stdout.is_empty());

    let stdout = String::from_utf8_lossy(&output.stdout);
    assert!(stdout.len() < 5000, "Output too large");
}

反模式清单

❌ 用硬编码合成数据测试(合成数据不能反映真实命令输出)→ ✅ 直接断言期望输出

// ❌ 错误:let input = "commit abc123\nAuthor: John"; 然后对合成输入下结论
// ✅ 正确:
let output = filter_git_log(input);
assert_eq!(output, "expected output");

❌ 跳过跨平台测试(只测当前平台)→ ✅ cfg 覆盖所有平台

// ✅ 正确——同一测试内按平台分支断言
#[test]
fn test_shell_escaping() {
    let escaped = escape("test");

    #[cfg(target_os = "windows")]
    assert_eq!(escaped, "\"test\"");

    #[cfg(not(target_os = "windows"))]
    assert_eq!(escaped, "test");
}

❌ 忽视性能回归(只测功能、不做性能跟踪)→ ✅ 基准并跟踪性能

# ✅ 正确——变更前/后基准对比
hyperfine 'rtk cmd' --warmup 3 > /tmp/before.txt
# 做修改
cargo build --release
hyperfine 'target/release/rtk cmd' --warmup 3 > /tmp/after.txt
diff /tmp/before.txt /tmp/after.txt

❌ 接受 <60% 的 token 节省(无节省率验证的测试毫无意义)→ ✅ 验证节省声明

// ✅ 正确——验证 ≥60% 节省
#[test]
fn test_token_savings() {
    let savings = calculate_savings(input, output);
    assert!(savings >= 60.0, "Expected ≥60%, got {:.1}%", savings);
}

十、小结

RTK 的测试策略可以浓缩为一句话:每个过滤器都要被"格式正确、压缩达标、平台无偏、性能不回归"四个维度独立验证。内联字符串与 include_str! 真实夹具两种策略并存、count_tokens 本地重复定义、集成测试用 #[ignore] 隔离真进程依赖——这些看似"不优雅"的约定,实际上都是为了降低新增过滤器的认知成本:任何贡献者只需要打开 mvn_cmd.rs 这一个参考文件,就能照抄出一套完整的、满足发布标准的测试。对于正在为代理型 CLI 工具设计测试体系的开发者,这套"以核心指标(压缩率)为发布阻断项、以真实夹具为回归基准"的做法尤其值得借鉴。

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