测试与基准测试
一句话理解
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三个必须注意的点
- 用
black_box阻止编译器把计算优化掉- 看趋势不看绝对值:机器的噪声很大,比较两个实现的相对差异才有意义
- 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. 小结
关键判断
- 单元测试放
mod tests(#[cfg(test)]),集成测试放tests/(只走公开 API)- doctest 是免费的回归测试:公开 API 的用法直接写进文档示例
- 测试默认并行,共享资源要隔离(临时目录、随机端口)
- 基准用 criterion +
black_box+ release 构建,看趋势不看绝对值- 覆盖率是参考;优先覆盖错误路径和分支