mcpField
R
rust-write-tests

rust-write-tests

github.com/pproenca测试与质量218244 次安装11 周前更新
代码仓库

关于 rust-write-tests

面向 Rust 开发者的测试编写技能,要求测试围绕明确不变量构建,并关注边界情况、确定性断言及通过事件同步排查 flaky 测试。

主要功能

  1. 01围绕不变量设计测试
  2. 02检查边界与平台差异
  3. 03使用类型化样例与对象比较
  4. 04采用确定性测试数据
  5. 05按事件排查不稳定测试
  6. 06测试命名与审查清单

适用场景

  1. 01为 Rust 代码设计回归测试
  2. 02替换脆弱的字符串断言
  3. 03排查依赖时序的异步测试

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/pproenca/dot-skills --skill rust-write-tests

安装后在 Claude Code 中输入/rust-write-tests确认已加载。

附带文件AGENTS.mdmetadata.json

详细介绍

Rust 测试编写技能

编写能捕获真实错误的测试。每个测试都必须守护一项特定的不变量,而不只是证明代码“能运行”。

“可能出什么问题?”框架

编写任何测试之前,先回答以下四个问题:

  1. 这段代码维护了什么不变量?(例如:“反序列化后的配置始终包含默认配置文件”)
  2. 什么边界情况会破坏它?(例如:“空 TOML 表、缺少键、多余的未知键”)
  3. 什么平台差异可能会暴露问题?(例如:“路径分隔符、大小写敏感性、符号链接行为”)
  4. 未来的重构可能会意外破坏什么?(例如:“向结构体添加字段,却未更新 Display 实现”)

如果你只能回答第 1 个问题,那么你的测试只是一个正常路径测试。回答完所有四个问题,你就拥有了一套回归测试。

5 种测试转换

每种转换都展示一种表面化的测试模式,以及对应的专家级替代方案。

转换 1:弱断言 -> 比较整个对象
// BEFORE: proves nothing about the rest of the struct
let result = parse_config(input)?;
assert!(result.is_ok());

// AFTER: catches any unexpected field change
use pretty_assertions::assert_eq;
let result = parse_config(input)?;
assert_eq!(result, Config {
    name: "default".into(),
    timeout: Duration::from_secs(30),
    retries: 3,
    verbose: false,
});
转换 2:单个正常路径测试 -> 有针对性的测试套件
// BEFORE: one test, one path
#[test]
fn test_parse_config() {
    let cfg = parse("valid input").unwrap();
    assert!(cfg.is_valid());
}

// AFTER: 3-6 tests covering happy, error, edge, platform
#[test]
fn parse_config_returns_defaults_for_minimal_input() { .. }
#[test]
fn parse_config_rejects_negative_timeout() { .. }
#[test]
fn parse_config_preserves_unknown_fields_as_extensions() { .. }
#[test]
fn parse_config_handles_empty_string_gracefully() { .. }
#[cfg(windows)]
#[test]
fn parse_config_normalizes_backslash_paths() { .. }
转换 3:内联测试模块 -> 同级 _tests.rs 文件
// BEFORE: tests pollute the production file diff
// foo.rs
pub fn compute() -> u32 { 42 }
#[cfg(test)]
mod tests {
    use super::*;
    #[test]
    fn it_works() { assert_eq!(compute(), 42); }
}

// AFTER: production code and test code in sibling files
// foo.rs
pub fn compute() -> u32 { 42 }
#[cfg(test)]
#[path = "foo_tests.rs"]
mod tests;

// foo_tests.rs
use super::*;
#[test]
fn compute_returns_expected_value() { assert_eq!(compute(), 42); }

对于 mod.rs 模块,请使用 mod_tests.rs。

转换 4:基于字符串的测试数据 -> 构造类型化结构体
// BEFORE: silent breakage when fields change
let input: Config = serde_json::from_str(r#"{"name":"test","timeout":30}"#)?;

// AFTER: compile-time safety for field additions/renames
fn make_config(name: &str, timeout_secs: u64) -> Config {
    Config {
        name: name.to_string(),
        timeout: Duration::from_secs(timeout_secs),
        retries: 0,
        verbose: false,
    }
}
let input = make_config("test", 30);

领域对象的工厂函数可让每个测试只构造所需的测试样例,不使用共享可变状态,也不在测试运行时解析 JSON。

转换 5:测试样例中使用 HashMap -> 使用 BTreeMap 确保确定性
// BEFORE: test passes 99% of the time, flakes in CI
let mut map = HashMap::new();
map.insert("b", 2);
map.insert("a", 1);
assert_eq!(format!("{map:?}"), r#"{"a": 1, "b": 2}"#); // order not guaranteed

// AFTER: deterministic iteration order
let mut map = BTreeMap::new();
map.insert("b", 2);
map.insert("a", 1);
assert_eq!(format!("{map:?}"), r#"{"a": 1, "b": 2}"#); // always this order

只要输出顺序会影响断言或快照,就使用 BTreeMap。

测试抖动排查流程

这是 Bolin 最常见的模式(在 30 多个提交中出现 97 次以上)。测试出现抖动时,请严格遵循以下流程:

  1. 找出竞态窗口——阅读事件发射代码,找出时序假设失效的位置。定位测试在没有证据的情况下假定事件已到达或状态已改变的具体代码行。
  2. 用事件驱动同步替代时序假设——等待特定事件,而不是休眠或假定顺序。切勿将 sleep 用作同步原语。
  3. 让断言不依赖顺序——对收集到的值排序、使用集合,或按内容而非位置匹配。事件顺序不确定并非错误,断言事件顺序才是。
  4. 对修复进行压力测试——使用以下准确命令运行:cargo nextest run -p <crate> -j 2 --no-fail-fast --stress-count 50 --status-level leak
  5. 在提交信息中说明非确定性——解释时序假设为何错误,以及用什么同步机制取代了它。
// BEFORE (timing-dependent):
tokio::time::sleep(Duration::from_millis(100)).await;
assert_eq!(events.len(), 2);
assert_eq!(events[0].type_name, "item.create");
assert_eq!(events[1].type_name, "audio.delta");

// AFTER (event-driven, order-independent):
wait_for_event(&rx, |e| e.type_name == "item.create").await;
wait_for_event(&rx, |e| e.type_name == "audio.delta").await;
// OR: collect, sort, compare
let mut types: Vec<_> = events.iter().map(|e| &e.type_name).collect();
types.sort();
assert_eq!(types, vec!["audio.delta", "item.create"]);

需要留意的常见抖动来源:

  • turn/started 会在状态实际就绪前乐观地发出
  • 异步通道(mpsc、broadcast)中的事件顺序
  • 序列化输出中的 HashMap 迭代顺序
  • 测试框架的 Drop 与子进程关闭发生竞态(先关闭 stdin,再等待,最后终止)

基于意图的断言

将对完整命令字符串的精确匹配替换为基于意图的语义匹配。检查测试观察到的是否是正确的意图(操作 + 目标),而不是会因平台或重构而变化的特定命令格式。

// BEFORE: brittle -- breaks if command formatting changes
assert_eq!(cmd.to_string(), "rm -rf /tmp/workspace/build");

// AFTER: intent-based -- asserts the operation and target
assert_eq!(cmd.operation(), Operation::Remove);
assert!(cmd.target().ends_with("workspace/build"));
assert!(cmd.is_recursive());

无法避免使用精确字符串时,应断言语义上有意义的部分(路径后缀、标志是否存在),而不是完整的格式化字符串。

测试命名约定

模式:{subject}_{scenario}_{expected_outcome}

失败的测试名称必须能直接说明问题。当测试在 CI 中失败时,仅凭名称就能知道出了什么问题。名称应当像一条规范:测试失败时,你能确切知道违反了哪项不变量。

优秀示例:

sandbox_detection_requires_keywords
sandbox_detection_ignores_non_sandbox_mode
aggregate_output_rebalances_when_stderr_is_small
parse_config_rejects_negative_timeout
permissions_profiles_reject_writes_outside_workspace_root
permissions_profiles_allow_network_enablement
legacy_sandbox_mode_config_builds_split_policies_without_drift
under_development_features_are_disabled_by_default
usage_limit_reached_error_formats_free_plan
unexpected_status_cloudflare_html_is_simplified
root_write_plus_carveouts_still_requires_platform_sandbox
explicit_unreadable_paths_prevent_auto_approval_for_external_sandbox
denied_hosts_take_priority_over_allowed_hosts_glob

反面示例:test_parse、it_works、test_config_1、happy_path。

测试技术栈速查

场景工具
HTTP 模拟wiremock::MockServer
文件系统隔离TempDir(tempfile crate)
异步测试#[tokio::test]
UI / 输出快照insta::assert_snapshot!
结构体比较pretty_assertions::assert_eq
枚举变体检查assert_matches!
确定性集合使用 BTreeMap 而非 HashMap
压测不稳定测试cargo nextest run --stress-count 50
各工具的适用场景
  • wiremock:任何可能访问真实 HTTP 端点的测试。使用 Mock::given().respond_with() 挂载响应。测试后断言请求正文。
  • TempDir:任何会访问磁盘的测试。绝不修改进程环境。绝不硬编码 /tmp 或 C:\。
  • insta:TUI 组件、CLI 输出、错误消息——任何对文本精确度有要求的内容。渲染到缓冲区,并使用 assert_snapshot! 创建快照。
  • pretty_assertions:所有 assert_eq! 调用的默认选择。失败时会显示彩色差异。在每个测试文件顶部导入。
  • nextest 压测:修复任何不稳定测试后运行。始终使用 -j 2 --no-fail-fast --stress-count 50 运行,以确认修复在并发情况下仍然有效。

自我审查清单

编写测试后,逐项检查。提交测试前修复所有问题。

[ ] Uses pretty_assertions::assert_eq (not std assert_eq)
[ ] Compares entire objects, not individual fields
[ ] Each test guards a specific invariant (not just "it works")
[ ] Test names follow {subject}_{scenario}_{expected_outcome}
[ ] Test names encode the invariant being guarded
[ ] TempDir for any filesystem tests (no hardcoded paths)
[ ] No process environment mutation (no std::env::set_var)
[ ] Error paths tested (not just happy path)
[ ] At least 3 tests for any non-trivial function
[ ] BTreeMap used where iteration order affects assertions
[ ] Test file is a sibling _tests.rs, not inline mod tests {}
[ ] No timing-dependent assertions (no sleep -> assert)
[ ] Order-independent where event order is non-deterministic
[ ] String assertions use intent matching, not exact format

评论

支持 Markdown