测试与基准:criterion 与 proptest

Rust 测试与性能基准工程化:单元/集成/文档测试的组织与 dev-dependencies、rstest 参数化与 insta 快照、proptest 属性测试与反例最小化、criterion 基准与 black_box、baseline 与 critcmp 回归检测、cargo-llvm-cov 覆盖率与 nextest。

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 不会回归。

对比proptestquickcheck
生成策略组合子丰富,支持 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 testcargo 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 工具链精讲 ,把测试、覆盖率、基准串成一条流水线,才能让每次提交都有据可依。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「rust」更多文章

  1. 性能剖析与优化
  2. Serde 与序列化生态
  3. 并发原语与无锁编程