测试与基准测试

一句话理解

Cargo 把测试做进了工具链本身:#[test] 写单元测试、tests/ 放集成测试、文档示例自动变成测试(doctest)、cargo bench 跑基准。

关键习惯:单元测试写在同文件的 mod tests 里(可以测私有函数),集成测试只走公开 API。

1. 单元测试

// src/lib.rs 或任意模块文件末尾
pub fn add(a: i32, b: i32) -> i32 { a + b }
 
fn is_positive(n: i32) -> bool { n > 0 }   // 私有函数
 
#[cfg(test)]
mod tests {
    use super::*;   // 引入父模块所有内容,包括私有项
 
    #[test]
    fn add_works() {
        assert_eq!(add(2, 3), 5);
    }
 
    #[test]
    fn private_is_testable() {
        assert!(is_positive(1));   // ✅ 同 crate 内可以测私有函数
    }
}

#[cfg(test)] 保证这段代码只在 cargo test 时编译,不进产物。

断言宏

宏用途
assert!(cond) / assert!(cond, "msg {}", x)断言为真
assert_eq!(a, b) / assert_ne!(a, b)相等 / 不等(要求 Debug + PartialEq)
panic! / unreachable!()主动失败 / 标记不应到达的分支
debug_assert!只在 debug 构建生效,用于热路径

断言消息要带上下文

assert_eq!(got, want) 在失败时会打印两边的值,已经够用;但涉及索引、路径、ID 时加一句说明会省很多调试时间:assert!(ok, "解析 {path} 失败")。

期望 panic

#[test]
#[should_panic(expected = "除数不能为零")]
fn divide_by_zero_panics() {
    let _ = 1 / 0;   // 消息必须包含 expected 子串
}

expected 强烈建议写——否则任何 panic 都会让测试通过。

返回 Result 的测试

比一堆 unwrap() 更干净,能用 ?:

#[test]
fn parse_config() -> Result<(), Box<dyn std::error::Error>> {
    let cfg: Config = toml::from_str("name = \"x\"")?;
    assert_eq!(cfg.name, "x");
    Ok(())
}

忽略与筛选

#[test]
#[ignore = "需要网络"]
fn slow_network_test() { }
cargo test                     # 全部(跳过 ignore)
cargo test -- --ignored        # 只跑 ignore 的
cargo test -- --include-ignored
cargo test parse               # 按名字子串筛选
cargo test -- --test-threads=1 # 串行(默认并行)
cargo test -- --nocapture      # 显示 println 输出

测试默认并行

多个测试同时操作同一个临时文件、端口、环境变量时会互相干扰。要么给每个测试独立的临时目录(tempfile),要么显式串行。不要靠固定文件名。

2. 集成测试

放在与 src/ 同级的 tests/ 目录,每个文件是独立的 crate,只能访问公开 API:

my-crate/
├── src/lib.rs
└── tests/
    ├── api_test.rs
    └── common/mod.rs      # 共享辅助代码(子目录不会被当成测试文件)
// tests/api_test.rs
use my_crate::add;
 
#[test]
fn public_api_works() {
    assert_eq!(add(1, 1), 2);
}

为什么 tests/common/mod.rs 要放子目录

tests/ 下的每个 .rs 文件都会被编译成一个独立测试二进制。共享辅助代码放进子目录(tests/common/mod.rs)就不会被当成测试入口。#[path] hack 也可以,但子目录更清晰。

二进制 crate(有 main.rs)的集成测试需要 use 包名::...,因此推荐把逻辑放到 lib.rs,main.rs 只做参数解析和调用。

3. 文档测试(doctest)

/// 把两个数相加。
///
/// ```
/// use my_crate::add;
/// assert_eq!(add(2, 3), 5);
/// ```
pub fn add(a: i32, b: i32) -> i32 { a + b }

cargo test 会编译并运行这些示例。这是 Rust 生态文档质量普遍较高的原因之一。

三个标记:

标记作用
```编译并运行
```no_run编译但不运行(如需要网络的示例)
```ignore完全跳过(尽量避免)
```should_panic期望 panic

doctest 是免费的回归测试

公开 API 的每个主要用法都写进文档示例,等于同时拿到了文档和测试。注意 doctest 编译较慢,大量示例会拖长 cargo test。

4. 依赖与测试工具

[dev-dependencies]
pretty_assertions = "1"      # 断言失败显示彩色 diff
insta = "1"                  # 快照测试
proptest = "1"               # 属性测试
mockall = "0.13"             # mock trait
assert_cmd = "2"             # 命令行端到端测试
predicates = "3"             # assert_cmd 的断言
tempfile = "3"               # 临时目录
criterion = "0.5"            # 基准测试

dev-dependencies 不进产物,可以放心引重依赖。

快照测试(insta)

适合比较结构化输出(JSON、渲染文本、CLI 帮助):

#[test]
fn snapshot_output() {
    let out = render(&config);
    insta::assert_snapshot!(out);
}

首次运行会生成 .snap.new,用 cargo insta review 确认。改动的可读 diff 是它最大的价值。

属性测试(proptest)

不是枚举用例,而是描述”对任意输入都应成立的性质”:

use proptest::prelude::*;
 
proptest! {
    #[test]
    fn encode_decode_roundtrip(s in ".*") {
        let bytes = encode(&s);
        prop_assert_eq!(decode(&bytes), s);
    }
}

失败时它会**自动缩小(shrink)**到最小反例——这是手写用例很难做到的。

mock(mockall)

use mockall::automock;
 
#[automock]
trait Storage {
    fn get(&self, k: &str) -> Option<String>;
}

适合隔离外部依赖(网络、数据库)。但不要为了可 mock 而把设计拆碎——优先用 trait 边界清晰的架构。

CLI 端到端(assert_cmd)

use assert_cmd::Command;
use predicates::prelude::*;
 
#[test]
fn cli_runs() {
    Command::cargo_bin("my-tool").unwrap()
        .arg("--version")
        .assert()
        .success()
        .stdout(predicate::str::contains("my-tool"));
}

5. 基准测试

criterion

[dev-dependencies]
criterion = { version = "0.5", features = ["html_reports"] }
 
[[bench]]
name = "parse_bench"
harness = false
// benches/parse_bench.rs
use criterion::{criterion_group, criterion_main, Criterion, black_box};
 
fn bench_parse(c: &mut Criterion) {
    let input = "a=1,b=2,c=3";
    c.bench_function("parse", |b| {
        b.iter(|| parse(black_box(input)))
    });
}
 
criterion_group!(benches, bench_parse);
criterion_main!(benches);
cargo bench

三个必须注意的点

  1. 用 black_box 阻止编译器把计算优化掉
  2. 看趋势不看绝对值:机器的噪声很大,比较两个实现的相对差异才有意义
  3. criterion 会自动做统计显著性检验,报告里出现 change: ... (p = ...) 时,p 大就不要下结论

更轻的替代

临时比较两段代码时,std::time::Instant 足够:

let t = std::time::Instant::now();
let r = work();
eprintln!("{:?}", t.elapsed());

但要 release 构建(cargo run --release)——用 debug 测性能是最常见的错误。

6. 覆盖率与运行器

cargo install cargo-llvm-cov
cargo llvm-cov --html          # 生成 HTML 覆盖率报告
 
cargo install cargo-nextest
cargo nextest run              # 更快的测试运行器,输出更清晰

nextest 的额外能力:每个测试独立进程(避免全局状态串扰)、自动重试 flaky 测试、按失败重跑。

覆盖率是参考,不是目标

追求 100% 覆盖率常常导致一堆只调用不验证的测试。优先覆盖分支与错误路径,尤其是 Result::Err 的每个变体。

7. 写测试的实践建议

  • 测行为,不测实现:重构内部结构不应该挂一堆测试
  • 一个测试一个失败点:失败时能立刻知道是什么坏了
  • 用 helper 构造 fixture,别在每个测试里重复 20 行准备代码
  • 不要 sleep 等异步:用可控时钟(tokio::time::pause)或轮询到条件成立
  • 错误路径要测:文件不存在、解析失败、超时、空输入、极大输入
  • 测试数据放 tests/data/ 或 testdata/,在 Cargo.toml 里用 include_str! / include_bytes! 打包
  • CI 里跑:cargo test --workspace + cargo clippy -- -D warnings + cargo fmt --check

8. 小结

关键判断

  1. 单元测试放 mod tests(#[cfg(test)]),集成测试放 tests/(只走公开 API)
  2. doctest 是免费的回归测试:公开 API 的用法直接写进文档示例
  3. 测试默认并行,共享资源要隔离(临时目录、随机端口)
  4. 基准用 criterion + black_box + release 构建,看趋势不看绝对值
  5. 覆盖率是参考;优先覆盖错误路径和分支

相关笔记