编写自动化测试
测试是保证代码正确性的重要手段。Rust 的类型系统和借用检查可以消除内存安全问题,但无法保证代码的逻辑正确性——函数签名正确,并不代表函数逻辑没有问题。写测试,就是为了弥补这一空白。
一个典型的测试函数依次完成以下三件事: 一是准备测试所需的数据或状态;二是运行被测试的代码;三是通过断言验证结果是否符合预期。
测试函数的构成
Rust 中的测试就是用属性(Attribute)标注的普通函数。使用 cargo new --lib 创建库项目时,Rust 会自动生成一个测试模块作为起点:
pub fn add(left: usize, right: usize) -> usize {
left + right
}
#[cfg(test)] // 只在 `cargo test` 时编译此模块
mod tests {
use super::*; // 引入父模块的所有内容
#[test] // 标记这是一个测试函数
fn it_works() {
let result = add(2, 2);
assert_eq!(result, 4);
}
}三个关键属性:
#[cfg(test)]: 条件编译,告诉 Rust 只在cargo test时编译该模块,cargo build时完全忽略。好处是减小发布包体积、加快正常编译速度。集成测试文件不需要这个标注。#[test]: 将某个函数标记为测试函数,测试执行器会发现并运行它。测试模块中也可以有非测试的辅助函数,必须用#[test]区分。#[ignore]: 跳过某个耗时的测试,cargo test默认不运行它。
运行所有测试:
cargo test断言宏
Rust 提供了两组断言宏:
| 宏 | Debug 模式 | Release 模式 | 用途 |
|---|---|---|---|
assert!(expr) | ✅ | ✅ | 断言布尔表达式为 true |
assert_eq!(a, b) | ✅ | ✅ | 断言两值相等 |
assert_ne!(a, b) | ✅ | ✅ | 断言两值不相等 |
debug_assert!(expr) | ✅ | ❌ | 仅 Debug 模式的断言 |
debug_assert_eq!(a, b) | ✅ | ❌ | 仅 Debug 模式的相等断言 |
debug_assert_ne!(a, b) | ✅ | ❌ | 仅 Debug 模式的不等断言 |
assert!(result == 4); // 布尔断言,为 false 时 panic
assert_eq!(result, 4); // 相等断言,失败时显示两侧的值
assert_ne!(result, 5); // 不等断言使用 assert_eq! 和 assert_ne! 的类型必须实现 PartialEq 和 Debug 特征。对于自定义结构体,通过 #[derive(PartialEq, Debug)] 派生即可。
自定义失败信息
所有断言宏都支持附加格式化的自定义错误信息,语法与 format! 完全一致:
#[test]
fn greeting_contains_name() {
let result = greeting("Sunface");
let target = "孙飞";
assert!(
result.contains(target),
"问候语中没有包含目标名字 {},实际结果是: `{}`",
target,
result
);
}默认失败信息只告知断言失败的位置,自定义信息则能提供更多上下文,对大型项目的 Debug 极有价值。
debug_assert! 系列
debug_assert! 系列只在 Debug 模式下运行,Release 模式下编译器会完全忽略它们,不会产生任何运行开销:
debug_assert_eq!(a, b, "调试时验证: a={}, b={}", a, b);适合用于开销较大的一致性检查,避免影响发布版本的性能。
使用 #[should_panic] 检查预期的 panic
有时需要验证代码在接受非法输入时能正确地 panic。#[should_panic] 属性标注后,只有当函数内部发生 panic,测试才算通过:
pub struct Guess {
value: i32,
}
impl Guess {
pub fn new(value: i32) -> Guess {
if value < 1 || value > 100 {
panic!("Guess value must be between 1 and 100, got {}.", value);
}
Guess { value }
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[should_panic]
fn greater_than_100() {
Guess::new(200); // 期望这里 panic,测试才通过
}
}精确匹配 panic 信息
一段代码可能在不同情况下触发不同的 panic,为了确保触发的是预期的那个,使用 expected 参数指定 panic 信息中必须包含的子字符串:
impl Guess {
pub fn new(value: i32) -> Guess {
if value < 1 {
panic!("Guess value must be >= 1, got {}.", value);
} else if value > 100 {
panic!("Guess value must be <= 100, got {}.", value);
}
Guess { value }
}
}
#[test]
#[should_panic(expected = "must be <= 100")] // 只需是实际信息的子字符串
fn greater_than_100() {
Guess::new(200);
}在测试中使用 Result<T, E>
测试函数也可以返回 Result<T, E>。失败时返回 Err 变体,而不是触发 panic:
#[test]
fn it_works() -> Result<(), String> {
if 2 + 2 == 4 {
Ok(())
} else {
Err(String::from("two plus two does not equal four"))
}
}这样做的优势是可以在测试中使用 ? 操作符进行链式调用,让测试代码更简洁:
#[test]
fn file_exists() -> Result<(), std::io::Error> {
let content = std::fs::read_to_string("Cargo.toml")?;
assert!(content.contains("[package]"));
Ok(())
}使用
Result<T, E>的测试函数不能同时使用#[should_panic]。如果需要断言某个操作返回Err,使用assert!(value.is_err())。
运行测试
cargo test 命令将代码编译为一个可执行文件并运行。命令行参数分为两部分,用 -- 分隔:
cargo test [Cargo 参数] -- [测试程序参数]--左边: 由 Cargo 处理,控制编译行为(如--lib、--release)--右边: 由测试可执行文件处理,控制运行行为(如线程数、输出方式)
并行与顺序执行
Rust 默认为每个测试函数启动独立线程并行运行,速度快。但如果多个测试共享同一资源(如同一个文件),并行运行可能产生数据竞争导致测试失败,此时指定单线程顺序运行:
cargo test -- --test-threads=1显示测试中的打印输出
默认情况下,通过的测试中的 println! 输出会被捕获不显示;失败的测试才会显示其输出。若要强制显示所有输出:
cargo test -- --show-output按名称运行或过滤测试
通过指定测试函数名(或其中一部分)来过滤运行:
cargo test one_hundred # 只运行名为 one_hundred 的测试
cargo test add # 运行所有名称包含 "add" 的测试
cargo test tests:: # 运行 tests 模块下的所有测试无法同时指定多个完整名称(
cargo test a b不生效),需要用名称子串的方式过滤。
忽略特定测试
用 #[ignore] 标注耗时较长的测试,cargo test 默认跳过它们:
#[test]
#[ignore]
fn expensive_test() {
// 这里的代码需要几分钟才能完成
}单独运行被忽略的测试:
cargo test -- --ignored # 只运行被忽略的测试
cargo test -- --include-ignored # 运行所有测试(含被忽略的)组合过滤
名称过滤与 --ignored 可以组合使用:
# 运行 tests 模块中所有被忽略的测试
cargo test tests -- --ignored
# 运行名称包含 "run" 且被忽略的测试
cargo test run -- --ignored常用命令速查
| 命令 | 说明 |
|---|---|
cargo test | 运行所有测试 |
cargo test --lib | 只运行库单元测试 |
cargo test --test integration_test | 运行指定集成测试文件 |
cargo test --doc | 只运行文档测试 |
cargo test -- --test-threads=1 | 单线程顺序运行 |
cargo test -- --show-output | 显示通过测试的输出 |
cargo test -- --ignored | 只运行被忽略的测试 |
cargo test -- --include-ignored | 运行全部测试(含忽略的) |
测试的组织
单元测试
单元测试的目标是隔离地验证单个函数或模块的逻辑正确性。约定: 将测试模块直接写在被测试代码所在的 .rs 文件末尾。
pub fn add_two(a: i32) -> i32 {
internal_adder(a, 2)
}
fn internal_adder(a: i32, b: i32) -> i32 { // 私有函数
a + b
}
#[cfg(test)]
mod tests {
use super::*; // 引入父模块,包括私有函数
#[test]
fn test_public() {
assert_eq!(add_two(2), 4);
}
#[test]
fn test_private() {
assert_eq!(internal_adder(2, 2), 4); // ✅ 可以直接测试私有函数
}
}得益于 use super::*,单元测试可以访问父模块的私有函数,这是集成测试做不到的。
集成测试
集成测试像"外部用户"一样调用代码,目的是验证多个模块协同工作是否正确。集成测试文件存放在项目根目录的 tests/ 目录下(需手动创建,与 src/ 同级)。每个 .rs 文件都是一个独立的包:
// tests/integration_test.rs
use adder; // 必须显式引入库,只能使用 pub API
#[test]
fn it_adds_two() {
assert_eq!(4, adder::add_two(2));
}几点注意:
- 无需
#[cfg(test)]:tests/目录本身已说明其用途,Cargo 自动识别 - 只能调用
pubAPI: 集成测试无法访问私有函数 - 仅适用于库项目: 纯二进制项目(只有
main.rs)无法直接进行集成测试。解决办法是将业务逻辑迁移到lib.rs,main.rs只保留主干调用
运行指定集成测试文件:
cargo test --test integration_test共享辅助模块
若多个集成测试文件需要共享辅助函数(如状态初始化),不要直接创建 tests/common.rs——Cargo 会将其当作测试文件执行,输出一堆 running 0 tests。应创建 tests/common/mod.rs,Rust 不会将子目录中的文件当作独立测试执行:
pub fn setup() {
// 初始化测试状态
}
// tests/integration_test.rs
mod common; // 声明引入共享模块
#[test]
fn it_adds_two() {
common::setup();
assert_eq!(4, adder::add_two(2));
}对比
| 特性 | 单元测试 | 集成测试 |
|---|---|---|
| 存放位置 | src/*.rs 文件末尾 | tests/*.rs 独立文件 |
需要 #[cfg(test)] | ✅ 需要 | ❌ 不需要 |
| 访问私有函数 | ✅ 可以 | ❌ 不可以 |
| 测试粒度 | 细粒度(单个函数/模块) | 粗粒度(功能/对外接口) |
| 编译方式 | 与源码一起编译 | 编译为独立二进制文件 |
| 适用项目 | 库和二进制项目 | 仅库项目 |
文档测试
文档测试(Doc-tests)是写在代码注释中的可运行代码示例,cargo test 会自动执行它们。核心思想是文档即测试: 如果 API 发生变化但文档示例没有同步更新,cargo test 会直接报错,防止文档误导用户。
基本用法
文档测试写在 /// 注释的 Markdown 代码块中,通常放在 # Examples 小节下:
/// 将一个整数加一并返回.
///
/// # Examples
///
/// ```
/// let result = my_crate::add_one(5);
/// assert_eq!(result, 6);
/// ```
pub fn add_one(x: i32) -> i32 {
x + 1
}Rust 在后台会自动将代码块包裹进 fn main() { ... } 中执行。调用时需使用完整的包路径(如 my_crate::add_one),因为文档测试像外部用户一样调用代码。
文档测试默认用于库项目(lib),二进制项目(
main.rs)需要使用cargo doc --bin 项目名生成文档。
详细说明
文档注释标记
///- 与普通的
//不同,///是文档注释 - Rust 编译器(rustdoc)会提取这些内容,生成 HTML 格式的项目 API 文档
- 必须写在要说明的项(如函数)之前
- 与普通的
Markdown 格式
- 注释内部完全支持 Markdown 语法
# Examples是二级标题,在生成的 HTML 文档中会显示为独立栏目
代码块与隐式 Main
- 代码块中的代码会被当作独立程序运行
- Rust 在后台自动将代码包裹进虚拟的
fn main() { ... }中 my_crate::add_one: 需要像外部用户一样引用包名
代码块控制
| 语法 | 作用 |
|---|---|
```rust | 标准可运行文档测试 |
```ignore | 显示代码但不运行 |
```no_run | 编译但不运行(用于耗时或需要外部资源的代码) |
```should_panic | 预期代码会 panic |
```compile_fail | 预期代码编译失败(用于演示错误用法) |
相关命令
cargo test --doc # 只运行文档测试
cargo doc --open # 生成并打开 API 文档
cargo doc --open --document-private-items # 包含私有项的文档
cargo doc默认只为公开项(pub)生成文档。使用--document-private-items参数可将私有项的文档注释也包含进去,适合查阅内部实现细节。
开发依赖(dev-dependencies)
只在测试场景中使用的外部库,放在 Cargo.toml 的 [dev-dependencies] 中,不会被打包到发布的产物:
# Cargo.toml
[dev-dependencies]
pretty_assertions = "1"#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq; // 仅在测试中可用,提供彩色差异对比
#[test]
fn test_add() {
assert_eq!(add(2, 3), 5);
}
}尝试在非测试代码中使用 [dev-dependencies] 中的包,编译器会报错——这正是它存在的意义。