cargo test 开箱即用,但真正难的是三件事:测试怎么组织才不臃肿、怎么用少量用例覆盖大输入空间、以及怎么证明一次改动没有让性能倒退。前两件靠 proptest 与快照测试,第三件靠 criterion 的统计基准。
本文按「写测试 → 造输入 → 测性能 → 卡门禁」的顺序展开,代码都能直接放进项目跑。错误类型设计对测试可写性的影响,可先看 Rust 错误处理与测试 打底。
测试的三层组织
| 层级 | 位置 | 能访问 | 适合 |
|---|---|---|---|
| 单元测试 | src/*.rs 的 #[cfg(test)] mod tests | 私有项 | 纯函数、内部不变量 |
| 集成测试 | tests/*.rs | 仅公开 API | 端到端流程、CLI |
| 文档测试 | /// 代码块 | 公开 API | 保证文档示例可运行 |
// src/parser.rs
pub fn parse(input: &str) -> Result<Ast, ParseError> { /* ... */ }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_empty_input() {
assert!(parse("").is_ok());
}
#[test]
#[should_panic(expected = "unreachable")]
fn panics_on_invalid_utf8_boundary() { /* ... */ }
}
集成测试每个文件编译成一个独立 crate,共享代码放 tests/common/mod.rs(用 mod.rs 而非 common.rs,后者会被当成测试目标):
// tests/common/mod.rs
pub fn spawn_server() -> ServerHandle { /* ... */ }
// tests/api.rs
mod common;
#[test]
fn health_endpoint_returns_ok() {
let srv = common::spawn_server();
assert_eq!(srv.get("/health"), 200);
}
测试专用依赖写在 [dev-dependencies],不会进入发布产物:
[dev-dependencies]
rstest = "0.23"
insta = { version = "1", features = ["yaml"] }
proptest = "1"
criterion = { version = "0.5", features = ["html_reports"] }
pretty_assertions = "1"
参数化与快照
rstest 用 fixture 和表格减少重复:
use rstest::rstest;
#[rstest]
#[case("1+1", 2)]
#[case("2*3", 6)]
#[case("10-4", 6)]
fn evaluates(#[case] expr: &str, #[case] expected: i64) {
assert_eq!(eval(expr).unwrap(), expected);
}
#[rstest]
fn with_fixture(#[from(db_pool)] pool: PgPool) { /* ... */ }
insta 把输出快照存成文件,改行为时用 cargo insta review 逐条确认:
#[test]
fn renders_report() {
insta::assert_yaml_snapshot!(render(&report));
}
快照测试特别适合「输出结构复杂但变化可枚举」的场景(模板渲染、序列化结果、错误消息)。
属性测试:proptest
单元测试枚举你想到的输入,属性测试让框架生成你没想过的输入。核心是「描述输入怎么生成」(strategy)和「描述什么恒成立」(property):
use proptest::prelude::*;
proptest! {
#[test]
fn encode_decode_roundtrip(v: Vec<u8>) {
let encoded = encode(&v);
prop_assert_eq!(decode(&encoded).unwrap(), v);
}
#[test]
fn sort_is_idempotent(mut xs: Vec<i32>) {
xs.sort();
let once = xs.clone();
xs.sort();
prop_assert_eq!(once, xs);
}
#[test]
fn never_panics_on_arbitrary_bytes(data in prop::collection::vec(any::<u8>(), 0..4096)) {
let _ = parse(&data); // 只要求不 panic
}
}
自定义 strategy
use proptest::prelude::*;
prop_compose! {
fn user_strategy()
(name in "[a-zA-Z]{3,16}", age in 0u8..130, email in "[a-z]{3,8}@[a-z]{3,8}\\.com")
-> User
{
User { name, age, email }
}
}
proptest! {
#[test]
fn valid_users_serialize(u in user_strategy()) {
prop_assert!(serde_json::to_string(&u).is_ok());
}
}
反例最小化
proptest 失败时会自动把反例「缩小」到最小规模,这是它比随机测试强的地方:
minimal failing input: xs = [0, 0, 1]
若某个已知失败用例暂时不想修,用 proptest! { #![proptest_config(...)] } 配置或 prop_assume! 跳过不合法输入:
#[test]
fn division(a: i32, b: i32) {
prop_assume!(b != 0); // 跳过 b == 0 的输入
prop_assert_eq!((a / b) * b + a % b, a);
}
持久化回归用例
proptest 会把失败用例写进 proptest-regressions/ 目录,必须提交进版本库——它保证曾经发现的 bug 不会回归。
| 对比 | proptest | quickcheck |
|---|---|---|
| 生成策略 | 组合子丰富,支持 prop_compose! | 类型类,定制较繁琐 |
| 最小化 | 默认开启,效果好 | 支持 |
| 生态 | 更活跃 | 更老牌 |
| 建议 | 新项目首选 | 已用 quickcheck 的项目继续用 |
基准测试:criterion
标准库的 #[bench] 需要 nightly,criterion 用稳定版就能做统计严谨的基准,输出置信区间并检测性能回归。
[[bench]]
name = "parse"
harness = false # 关键:关掉内置 harness
// benches/parse.rs
use criterion::{black_box, criterion_group, criterion_main, Criterion, BenchmarkId};
fn bench_parse(c: &mut Criterion) {
let small = "x".repeat(64);
let large = "x".repeat(4096);
let mut group = c.benchmark_group("parse");
for (name, input) in [("small", &small), ("large", &large)] {
group.bench_with_input(BenchmarkId::from_parameter(name), input, |b, input| {
b.iter(|| parse(black_box(input))) // black_box 防优化掉
});
}
group.finish();
}
criterion_group!(benches, bench_parse);
criterion_main!(benches);
black_box 为什么必须
编译器看到 parse(&s) 的结果没被使用,可能整段删掉。black_box 告诉编译器「这个值可能被外部使用」,阻止死代码消除:
b.iter(|| {
let out = expensive(black_box(&input));
black_box(out) // 输入输出都要包
});
运行与配置
cargo bench # 全跑
cargo bench -- parse # 按名字过滤
cargo bench -- --sample-size 50 # 缩短采样,加快迭代
cargo bench -- --save-baseline main # 存基线
cargo bench -- --baseline main # 与基线对比
criterion 会输出 change: [-1.2% +0.4% +2.1%] 这样的区间,并判定 No change / Change within noise threshold / Performance has regressed。只有落在区间外才算真回归,别被单次数字波动带偏。
回归门禁
cargo install critcmp
# CI 中:先存基线,再对比
cargo bench -- --save-baseline base
# 改动后
cargo bench -- --save-baseline pr
critcmp base pr # 表格化对比,>5% 回归可设 exit code
配合 GitHub Actions 的 Rust CI 可以在 PR 上贴出基准对比表,把性能回归挡在合并之前。
基准测试必须跑在固定频率的专用机器上。共享 CI runner 的噪声经常超过 5%,此时应放宽阈值或只在 nightly 跑全量基准。
覆盖率与执行速度
cargo-llvm-cov
cargo install cargo-llvm-cov
cargo llvm-cov --workspace --html
cargo llvm-cov --workspace --lcov --output-path lcov.info # CI 上传
cargo llvm-cov --workspace --fail-under-lines 80 # 低于 80% 直接失败
--fail-under-lines 是让覆盖率真正起作用的关键:没有阈值的覆盖率报告只是一张图。注意覆盖率衡量的是「执行过」,不是「验证过」,高覆盖率不等于高正确性,属性测试往往能用更少的行数覆盖更多分支。
nextest
cargo test 的测试是并行线程共享一个进程,一个测试崩溃会带走整批。nextest 每个测试独立进程,隔离更好、速度更快:
cargo install cargo-nextest
cargo nextest run --workspace
cargo nextest run --retries 2 # 自动重试疑似 flaky 的用例
cargo nextest run -E 'test(parser)' # 表达式过滤
| 维度 | cargo test | cargo nextest |
|---|---|---|
| 进程模型 | 线程共享进程 | 每测试独立进程 |
| 崩溃隔离 | 差(会带走同进程用例) | 好 |
| 速度 | 基线 | 通常更快 |
| 过滤语法 | 子串 | 表达式(-E) |
| doctest | 支持 | 不跑 doctest,需另跑 cargo test --doc |
nextest 不执行文档测试,CI 里要单独补一条 cargo test --doc。
小结
- 三层测试各有边界:单元测私有逻辑、集成测公开契约、文档测试防示例腐化;共享代码放
tests/common/mod.rs。 rstest去重复、insta管复杂输出快照,两者能显著压缩测试体积。- 属性测试用 strategy 描述输入空间、用
prop_assume!排除非法输入;proptest-regressions/必须入库。 - criterion 基准务必用
black_box包住输入输出,靠--save-baseline+critcmp做回归门禁,且只在低噪声机器上跑。 - 覆盖率要有
--fail-under-lines才有约束力;nextest提速但别忘了补cargo test --doc。
测试与基准的完整工具链(cargo fmt/clippy/audit 与 CI 缓存)见 Rust 工具链精讲
,把测试、覆盖率、基准串成一条流水线,才能让每次提交都有据可依。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。