自动化测试
本章定位:把「能写出来的 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) | expr 为 true | 表达式源码 |
assert_eq!(a, b) | a == b | 左右两边的 Debug 值 |
assert_ne!(a, b) | a != b | 左右两边的 Debug 值 |
assert_matches!(expr, pat) | expr 匹配模式 pat | 实际值与模式 |
assert_eq!/assert_ne! 要求两侧实现了 PartialEq 与 Debug;自定义类型用 #[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.rstoml
# 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()。unwrap与expect的选择:文档示例是给读者抄的,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 -k或go 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_input、split_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 容器并通过环境变量注入地址。
延伸阅读
- 同一概念的第二种讲法(官方书中文版、Rust 圣经的逐章映射),见 附录 E · 对照阅读与组合学习法。
- 官方文档、中文资料、书单与工具的完整索引,见 附录 D · 学习资源与文档索引。