Skip to content

自动化测试

本章定位:把「能写出来的 Rust」变成「敢交给别人的 Rust」——用测试证明行为、用文档固化契约、用测量代替直觉优化性能,并在必须触碰 unsafe 时知道边界在哪里。前置知识:所有权与借用泛型与生命周期并发编程

本章目标

  • 能独立写出单元测试、集成测试与文档测试三件套,并解释它们各自能证明什么、不能证明什么。
  • 能熟练使用 cargo test 的过滤、--nocapture--test-threads=1--exact 等参数,并知道测试默认并行带来的副作用。
  • 能用 criterion 搭一个可信的基准测试,识别「被优化掉」「混入 IO」「样本太少」这三类不可信结果。
  • 建立 Rust 性能心智模型:零成本抽象、内存布局、分配代价,并理解「算法 > 内存布局 > 微优化」的优先级。
  • 准确说出 unsafe 的五大超能力、2024 edition 的 unsafe_op_in_unsafe_fn 变化、// SAFETY: 注释规范与 miri 的作用。
  • 能判断一段 unsafe 代码是否必要、是否可被安全代码替代,并为 unsafe impl Send/Sync 给出论证。

单元测试:和代码放在一起的那部分

#[cfg(test)] mod tests

Rust 没有单独的语言级测试框架语法糖,测试就是一个#[test] 属性的普通函数,放在一个用 #[cfg(test)] 标记的子模块里。#[cfg(test)] 的含义是「只在测试配置下参与编译」:平时 cargo build 根本不会编译这段代码,所以它不会进入发布产物,也不会拖慢正常运行。

rust
// src/lib.rs
pub fn add(a: i32, b: i32) -> i32 {
    a + b
}

pub fn div(a: i32, b: i32) -> Option<i32> {
    if b == 0 { None } else { Some(a / b) }
}

#[cfg(test)]           // 这个模块只在 `cargo test` 时编译
mod tests {
    use super::*;      // 子模块不会自动看到父模块的名字,必须显式引入

    #[test]
    fn add_two_numbers() {
        assert_eq!(add(1, 2), 3);
    }

    #[test]
    fn div_by_zero_is_none() {
        assert_eq!(div(1, 0), None);
    }
}

🧠 原理cargo test 会把 crate 用 --test 模式再编译一遍。此时 rustc 自动注入 --cfg test,于是所有 #[cfg(test)] 的模块被保留,#[test] 函数被收集进一个隐式生成的测试入口(需要 main 时由测试框架接管)。

💡 对照:Python 用 pytest 从文件名和函数名里发现测试;Java 用 JUnit 的 @Test 注解 + 反射;Go 把 _test.go 放在同一个包里。Rust 选择了「同文件同模块 + 编译期开关」,好处是测私有函数不需要任何特殊手段——测试模块是同一个 crate 的子模块,可见性规则天然允许它访问父模块的私有项。

四个断言宏

断言内容失败时打印
assert!(expr)exprtrue表达式源码
assert_eq!(a, b)a == b左右两边的 Debug
assert_ne!(a, b)a != b左右两边的 Debug
assert_matches!(expr, pat)expr 匹配模式 pat实际值与模式

assert_eq!/assert_ne! 要求两侧实现了 PartialEqDebug;自定义类型用 #[derive(Debug, PartialEq)] 即可(浮点数不能直接 == 比较,见「常见坑与编译错误」 的坑)。

assert_matches! 的稳定情况值得单独说清楚:它自 Rust 1.96.0 起稳定rustc 1.98.1 直接可用,无需 nightly;doc.rust-lang.org 的宏页面标注 1.96.0)。导入方式是 use std::assert_matches;——把它当宏引入 crate 根命名空间即可;写成 use std::assert_matches::assert_matches; 或调用 std::assert_matches::assert_matches!(...) 都会报 could not find assert_matches in std,因为稳定版把它导出在 crate 根而不是同名模块。若你的 MSRV 低于 1.96,请退回 assert!(matches!(...))matches! 自 1.42 稳定)。下面的写法在 rustc 1.98.1 上实测通过:

rust
use std::assert_matches; // 注意:不要写成 `use std::assert_matches::assert_matches;`

#[derive(Debug, PartialEq)]
enum Cmd {
    Quit,
    Move { x: i32, y: i32 },
    Say(String),
}

fn parse(s: &str) -> Result<Cmd, String> {
    match s {
        "quit" => Ok(Cmd::Quit),
        _ if s.starts_with("say ") => Ok(Cmd::Say(s[4..].to_string())),
        _ => Err(format!("无法解析: {s}")),
    }
}

#[test]
fn parse_result_shape() {
    // 只想断言语义形状、不想给 Cmd 实现 PartialEq 时,assert_matches! 最省事
    assert_matches!(parse("quit"), Ok(Cmd::Quit));
    assert_matches!(parse("say hi"), Ok(Cmd::Say(ref m)) if m == "hi");
    assert_matches!(parse("???"), Err(_));
}

⚠️ 陷阱assert_matches! 只支持「模式」和「模式 + if 守卫」,不支持 assert_matches!(v, Pat => panic!(...)) 这种 JUnit 风格的失败消息语法,写了会得到 no rules expected '=>'。另外,如果项目的最低支持版本(MSRV)低于引入该宏的版本,要么改用 matches! + assert!,要么把 MSRV 提上去。matches!(x, Pat) 从 1.42 起就稳定,任何版本都能用:

rust
#[derive(Debug, PartialEq)]
enum Cmd { Quit }

fn parse(s: &str) -> Result<Cmd, String> {
    if s == "quit" { Ok(Cmd::Quit) } else { Err(format!("无法解析: {s}")) }
}

assert!(matches!(parse("quit"), Ok(Cmd::Quit)));

#[should_panic]

当契约是「遇到错误输入必须 panic」而不是「返回 Err」时,用 #[should_panic] 让「崩溃」变成通过条件。

rust
pub fn parse_port(s: &str) -> u16 {
    let n: u16 = s.parse().expect("端口必须是 0..=65535 的整数");
    assert!(n > 0, "端口不能为 0,收到 {n}");
    n
}

#[test]
#[should_panic(expected = "端口不能为 0")]   // expected 是子串匹配
fn port_zero_panics() {
    parse_port("0");
}

⚠️ 陷阱:不写 expected#[should_panic] 几乎是无价值的测试——它只要求「代码以任何理由 panic」,连 unwrap() 在无关位置失败都会让它通过。规则:所有 #[should_panic] 都必须带 expected,且 expected 要选一句只有该路径才会打印的话。

返回 Result 的测试

测试函数可以返回 Result<(), E>E: Debug),这样就能在测试里用 ?,失败时自动把 Err 打印出来:

rust
#[derive(Debug, PartialEq)]
struct ParseError(String);

fn parse_pair(s: &str) -> Result<(i32, i32), ParseError> {
    let (a, b) = s.split_once(',').ok_or_else(|| ParseError("缺少逗号".into()))?;
    let a = a.trim().parse().map_err(|_| ParseError("左值不是整数".into()))?;
    let b = b.trim().parse().map_err(|_| ParseError("右值不是整数".into()))?;
    Ok((a, b))
}

#[test]
fn parses_padded_pair() -> Result<(), ParseError> {
    let pair = parse_pair(" 1 , 2 ")?;   // 出错时测试失败并打印 ParseError
    assert_eq!(pair, (1, 2));
    Ok(())
}

💡 对照:这相当于 JUnit 里让测试方法 throws Exception,或 Go 里 t.Fatal(err) 的现代替代。差别是 Rust 的 ? 在测试里同样走类型检查,不会漏掉错误类型。

测试私有函数

私有函数可以被测试,这是 Rust 测试设计里最舒服的一点:#[cfg(test)] mod tests 是使用它的那个模块的子模块,而 Rust 的私有规则是「对当前模块及其后代可见」。

rust
// src/lib.rs
fn normalize_key(raw: &str) -> String {   // 私有,外部 crate 无法调用
    raw.trim().to_ascii_lowercase()
}

pub fn lookup<'a>(table: &'a [(String, String)], key: &str) -> Option<&'a str> {
    let k = normalize_key(key);
    table.iter().find(|(t, _)| *t == k).map(|(_, v)| v.as_str())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn normalize_key_is_case_and_space_insensitive() {
        assert_eq!(normalize_key("  Rust "), "rust");   // 直接测私有函数,无需 pub(crate)
    }

    #[test]
    fn lookup_uses_normalized_key() {
        let table = vec![("rust".to_string(), "systems".to_string())];
        assert_eq!(lookup(&table, " RUST "), Some("systems"));
    }
}

🚀 进阶:也可以用 #[cfg(test)] pub(crate) use normalize_key; 把私有函数在测试配置下「提升」到 crate 可见,让 tests/ 下的集成测试也能测它。但更常见的做法是:只测公开 API,把私有函数的正确性交给 doctest 和薄封装——因为私有函数随时可能被重构掉。


集成测试与文档测试

tests/ 目录:每个文件都是独立 crate

tests/ 下的每个 .rs 文件会被 cargo 当成一个独立 crate编译,它只能使用你的库 crate 的 pub API——这正是集成测试的价值:它站在用户视角验证契约,而不是站在实现视角。

my_crate/
├── Cargo.toml
├── src/
│   └── lib.rs
├── tests/                  # 集成测试目录,与 src 平级
│   ├── common/
│   │   └── mod.rs          # 共享辅助代码(不叫 mod.rs 会被当成测试文件!)
│   ├── parser_test.rs
│   └── roundtrip.rs
└── benches/
    └── parse_bench.rs
toml
# Cargo.toml
[package]
name = "my_crate"
version = "0.1.0"
edition = "2024"

[dev-dependencies]        # 只在测试/示例/基准里可用,不会传给下游依赖
pretty_assertions = "1.4"
rust
// tests/common/mod.rs —— 共享辅助模块
// 目录下的 mod.rs 不会被 cargo 当作独立集成测试 crate,这正是我们要的效果
pub fn sample_input() -> &'static str {
    "quit\nsay hello\nmove 1 2\n"
}

pub fn temp_path(name: &str) -> std::path::PathBuf {
    let mut p = std::env::temp_dir();
    p.push(format!("my_crate_{}_{}", std::process::id(), name));
    p
}
rust
// tests/parser_test.rs
mod common;               // 引入 tests/common/mod.rs
use my_crate::parse_line;

#[test]
fn parses_sample_input() {
    let lines: Vec<_> = common::sample_input().lines().collect();
    assert_eq!(lines.len(), 3);
    assert!(parse_line(lines[0]).is_ok());
}

⚠️ 陷阱:如果写成 tests/common.rs,cargo 会把它也当成一个集成测试 crate 去编译,于是你会看到「tests/common.rs 里 0 个测试」这种噪音,还可能因为缺少 #[test] 之外的符号而警告。共享代码必须放在子目录的 mod.rs(或 tests/common/lib.rs 形式的路径)里。

⚠️ 陷阱dev-dependencies 里的 crate 只有单元测试、集成测试、示例(examples/)和基准(benches/)能用src/ 的正常代码不能用。反过来,[dependencies] 里的 crate 在测试里当然也能用。加开发依赖用:

powershell
cargo add --dev pretty_assertions   # 会写入 [dev-dependencies],并解析出当前最新版本

pretty_assertions 做的事很直接:用 use pretty_assertions::assert_eq; 覆盖标准库的 assert_eq!,把失败时的左右值做彩色逐行 diff。对长字符串、长结构体的测试报告提升极大。

rust
use pretty_assertions::assert_eq;   // 覆盖默认 assert_eq!,只会影响本模块

#[test]
fn long_text_diff_is_readable() {
    let expected = "alpha\nbeta\ngamma\n";
    let actual = "alpha\nBETA\ngamma\n";
    assert_eq!(expected, actual);   // 输出带 +/- 的逐行对比,而不是两坨整串
}

doctest:/// 里的代码真的会跑

文档注释里的 Rust 代码块默认会被 cargo test 编译并执行(通过 rustdoc)。这不是文档装饰,而是可执行的契约:API 改了、文档没改,测试就会红。

rust
/// 把一行命令解析成 [`Cmd`]。
///
/// # Examples
///
/// ```
/// use my_crate::{parse_line, Cmd};
///
/// # let expected = 3;                  // 以 `# ` 开头的行会被 rustdoc 隐藏,但仍然参与编译
/// assert_eq!(parse_line("move 1 2").unwrap(), Cmd::Move { x: 1, y: 2 });
/// assert_eq!(expected, 3);
/// ```
///
/// 需要真实 IO、跑起来很慢的例子用 `no_run`:只编译不执行。
///
/// ```no_run
/// use my_crate::write_report;
/// write_report("report.txt").unwrap();
/// ```
///
/// `compile_fail` 要求这段代码**编译失败**,用来钉住「不安全的用法必须报错」。
///
/// ```compile_fail
/// use my_crate::parse_line;
/// // 下一行把 &str 当成 i32 用,必须编译失败
/// let n: i32 = parse_line("quit");
/// ```
///
/// `should_panic` 要求运行时 panic。
///
/// ```should_panic
/// panic!("演示用");
/// ```
///
/// `ignore` 让 rustdoc 完全跳过(只编译不运行都没有)。**尽量避免**,因为它会悄悄腐坏。
///
/// ```ignore
/// 这里可以写伪代码,但要清楚它永远不会被发现已经过时
/// ```
pub fn parse_line(s: &str) -> Result<Cmd, String> {
    match s.split_whitespace().collect::<Vec<_>>().as_slice() {
        ["quit"] => Ok(Cmd::Quit),
        ["move", x, y] => Ok(Cmd::Move {
            x: x.parse().map_err(|e| format!("{e}"))?,
            y: y.parse().map_err(|e| format!("{e}"))?,
        }),
        ["say", rest @ ..] => Ok(Cmd::Say(rest.join(" "))),
        _ => Err(format!("无法解析: {s}")),
    }
}

关于 doctest 的几条实用规则:

  • 隐藏行 # :以 # 开头的行 rustdoc 不显示给读者但会编译。它让你能在示例里写 let mut v = Vec::new(); 这类「准备代码」而不污染文档。想显示一个以 # 开头的内容(比如属性),用 ##
  • ? 需要 main 返回 Result:doctest 默认把你的代码塞进 fn main() {},所以顶层不能用 ?。要么手动包一层 fn main() -> Result<(), Box<dyn std::error::Error>>,要么在示例里改用 .unwrap()/.expect()
  • unwrapexpect 的选择:文档示例是给读者抄的,unwrap() 会传递「这里不会失败」的错误暗示。更负责的写法是 expect("...") 说明前置条件,或者干脆用 ? 展示真实调用者的错误处理路径。
  • doctest 里必须显式 use:rustdoc 为每个示例生成独立 crate,并自动 extern crate 你的库,但不会自动 use 你的类型。每个示例写全 use my_crate::...;。若库名里有连字符(my-crate),代码里要写成下划线(my_crate)。

🧠 原理cargo test 实际会依次跑三类目标——单元测试(--lib)、集成测试(每个 tests/*.rs)、doc 测试(rustdoc 的隐藏 --test 模式)。你看到的输出里 Doc-tests my_crate 那一段就是 doctest。

只跑你想跑的那一类

powershell
cargo test                      # 全部:lib 单元测试 + tests/ + doctests
cargo test --lib                # 只跑 src/ 里的单元测试
cargo test --doc                # 只跑 doctest
cargo test --test parser_test   # 只跑 tests/parser_test.rs 这个目标
cargo test --bins               # 只跑 src/bin 下的二进制 crate 测试
cargo test --all-targets        # lib + bins + tests + benches(不含 doctest)
cargo test parse                # 名字(含模块路径)里包含 "parse" 的都跑
cargo test parse_line -- --exact   # 精确匹配函数名,不做子串匹配

💡 对照cargo test <filter> 相当于 pytest -kgo test -run。区别是 Rust 的子串匹配同时作用于模块路径和函数名(例如 tests::parser::parse_line 会匹配 parser),而 --exact 要求写全路径的最后一段必须完全相等。


测试的组织与工具

-- 之后的参数是给测试二进制自己的

cargo test [cargo 参数] -- [测试二进制参数]-- 之前归 cargo,之后归生成的测试可执行文件。

powershell
cargo test -- --nocapture          # 不要捕获 println! 输出(默认会被吞掉,只在失败时显示)
cargo test -- --test-threads=1     # 串行执行,方便读交错日志 / 避开共享资源冲突
cargo test -- --show-output        # 成功用例的 stdout 也打印出来
cargo test -- --list               # 只列出会跑哪些测试,不执行
cargo test -- --ignored            # 只跑被 #[ignore] 标记的慢测试
cargo test -- --include-ignored    # 全都跑,包含 ignore 的
rust
#[test]
#[ignore = "需要 10 秒,CI 的 nightly job 才跑"]
fn very_slow_roundtrip() {
    // ...
}

⚠️ 陷阱println! 在测试里默认被测试框架捕获,只有失败的用例才会显示。很多人以为自己的日志没打印是代码有问题,其实加上 -- --nocapture 就能看到。

测试命名:让失败信息自己说话

两种主流风格,选一种并全项目统一:

  • given_when_then(BDD 风)given_empty_input_when_parse_then_returns_err。适合行为复杂、有前置状态的场景。
  • 描述式(推荐默认)parse_rejects_empty_inputsplit_at_mut_returns_two_disjoint_halves。短、可读、在测试列表里能一眼看懂。

Rust 的惯例是不用 test_ 前缀(#[test] 已经说明了),并且名字写成一个可读的英文短句。衡量标准很简单:cargo test 的失败列表只给函数名,这个名字必须能让人不看代码就知道坏了什么。

表驱动测试

当同一个逻辑要验证很多组输入时,别复制 10 个几乎一样的 #[test],用数据驱动的循环:

rust
fn classify(n: i32) -> &'static str {
    match n {
        i32::MIN..=-1 => "negative",
        0 => "zero",
        1..=i32::MAX => "positive",
    }
}

#[test]
fn classify_covers_all_cases() {
    // (输入, 期望输出):新增用例只需加一行
    let cases: Vec<(i32, &str)> = vec![
        (-1, "negative"),
        (-100, "negative"),
        (0, "zero"),
        (1, "positive"),
        (i32::MAX, "positive"),
    ];

    for (input, expected) in cases {
        assert_eq!(classify(input), expected, "classify({input}) 结果不符");
    }
}

注意断言里第三个参数:它会被格式化进失败信息。表驱动测试的缺点也正在这里——一个用例失败会中断循环,后面的用例不再执行,所以最好在断言里带上 input,方便一眼定位。

参数化、属性测试与 mock:一次说清各自定位

工具一句话定位什么时候用
rstest用属性宏写参数化测试和 fixture,等价于 pytest 的 @pytest.mark.parametrize表驱动测试写腻了、需要共享 fixture 时
proptest属性测试:自动生成随机输入并收缩(shrinking)到最小反例,等价于 Haskell 的 QuickCheck / Python 的 Hypothesis想验证「对所有合法输入都成立」的不变量时
quickcheck更早、更轻的属性测试库,API 更朴素依赖少、只需要 Arbitrary 随机生成时
mockall#[automock] 自动生成 trait 的 mock 对象,支持期望调用次数与返回值需要隔离外部依赖(网络、时钟、DB)时
rust
// Cargo.toml: [dev-dependencies] rstest = "0.27"
use rstest::rstest;

#[rstest]
#[case("1,2", Ok((1, 2)))]
#[case(" 3 , 4 ", Ok((3, 4)))]
#[case("1", Err("缺少逗号"))]
#[case("a,2", Err("左值不是整数"))]
fn parses_pairs(#[case] input: &str, #[case] expected: Result<(i32, i32), &str>) {
    let got = parse_pair(input).map_err(|e| e.0.as_str());
    assert_eq!(got, expected);
}
rust
// Cargo.toml: [dev-dependencies] proptest = "1.11"
use proptest::prelude::*;

proptest! {
    // 不变量:编码后再解码应还原原值(对任意 Vec<i32> 都成立)
    #[test]
    fn encode_decode_roundtrip(values in proptest::collection::vec(any::<i32>(), 0..64)) {
        let encoded = encode(&values);
        prop_assert_eq!(decode(&encoded).unwrap(), values);
    }
}
rust
// Cargo.toml: [dev-dependencies] mockall = "0.15"
use mockall::{automock, predicate::eq};

#[automock]
trait Clock {
    fn now_secs(&self) -> u64;
}

fn is_expired(clock: &dyn Clock, issued_at: u64, ttl: u64) -> bool {
    clock.now_secs() > issued_at + ttl
}

#[test]
fn expiry_uses_clock_abstraction() {
    let mut clock = MockClock::new();
    clock.expect_now_secs().with().times(1).returning(|| 1_000);
    assert!(is_expired(&clock, 900, 50));
}

🚀 进阶:属性测试的收缩能力才是它比随机测试值钱的地方。proptest 发现失败后会反复最小化输入,最后给你一个「删无可删」的反例。写不出不变量(invariant)时,属性测试会退化成「随机跑一跑」,价值有限——先想清楚不变量,再引入工具

cargo-nextest、覆盖率与 CI

powershell
cargo install cargo-nextest --locked   # 一次安装
cargo nextest run                       # 更快、每个测试独立进程、输出更清晰
cargo nextest run --retries 2           # 对 flaky 测试重试

cargo-nextest 的核心差异是每个测试跑在独立进程里:一个测试 panic 或泄漏内存不会污染下一个,并且默认并行度更高、输出是逐测试一行的进度报告。代价是它不跑 doctest(要另跑 cargo test --doc),且 -- --nocapture 这类参数语义不同(用 --no-capture)。

覆盖率:

powershell
cargo install cargo-llvm-cov --locked
cargo llvm-cov --html            # 基于 LLVM 源码级覆盖率,输出 target/llvm-cov/html
cargo llvm-cov --fail-under-lines 80   # 低于阈值让 CI 失败

cargo-llvm-cov 用 rustc 自带的 LLVM instrumentation 插桩,是当前推荐方案;cargo-tarpaulin 是更早的替代品,在 Linux 上基于 ptrace,Windows 支持不佳。覆盖率是发现「没测到的代码」的工具,不是质量分数——100% 覆盖率的代码照样可以是错的。

CI 里跑测试的最小骨架(GitHub Actions,其他平台同理):

yaml
# .github/workflows/ci.yml
name: ci
on: [push, pull_request]
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: dtolnay/rust-toolchain@stable     # 固定工具链,避免 CI 突然换版本
        with:
          components: clippy, rustfmt
      - uses: Swatinem/rust-cache@v2            # 缓存 ~/.cargo 与 target,省时间
      - run: cargo fmt --all --check
      - run: cargo clippy --all-targets -- -D warnings
      - run: cargo test --all-targets
      - run: cargo test --doc                   # nextest 不跑 doctest,这一步别漏

⚠️ 陷阱:CI 上的测试必须没有外部依赖(网络、外部数据库、用户目录里的文件),否则会间歇性失败。需要外部资源时用 #[ignore] 单独跑,或在 CI 里起 service 容器并通过环境变量注入地址。


延伸阅读


内容以 rustc 1.98.1 · Rust 2024 edition 为基准