手把手带你写 Rust 测试
Rust 从语言层面内置了一套开箱即用、零第三方依赖的测试框架,原生支持单元测试、集成测试、文档测试三大核心场景,实现了开发即测试、文档即测试的理念。
第一个单元测试用例
我们从最简单的单元测试场景入手,先写一个业务函数,再为它编写对应的测试用例。
打开 src/lib.rs,写入两个基础的业务函数:
rust
/// 两个整数相加
pub fn add(a: i32, b: i32) -> i32 {
a + b
}
/// 两个整数相除
pub fn divide(a: i32, b: i32) -> i32 {
if b == 0 {
panic!("Division by zero is not allowed");
}
a / b
}
在 src/lib.rs 文件末尾,添加 Rust 约定的单元测试模块:
rust
// 测试函数通过 #[test] 属性标记,只有标记了该属性的函数才会被当作测试用例执行
#[cfg(test)]
mod tests {
// 引入父模块的所有内容,可直接访问业务函数
use super::*;
// 标记该函数为测试用例
#[test]
fn add_should_return_correct_result() {
// 执行业务逻辑
let result = add(2, 2);
// 断言:验证结果是否符合预期
assert_eq!(result, 4);
}
}
在项目根目录执行以下命令,运行所有测试:
shell
cargo test
你会看到如下输出,代表测试执行成功:
plaintext
running 1 test
test tests::add_should_return_correct_result ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Rust 内置了三个最常用的断言宏,如下所示:
| 宏 | 作用 | 示例 |
|---|---|---|
assert! |
验证布尔表达式为 true | assert!(add(2, 2) == 4); |
assert_eq! |
验证两个值相等 | assert_eq!(add(2, 2), 4); |
assert_ne! |
验证两个值不相等 | assert_ne!(add(2, 2), 5); |
所有断言宏都支持自定义错误信息,方便调试定位问题:
rust
#[test]
fn add_with_custom_error_msg() {
let result = add(3, 5);
assert_eq!(result, 8, "3 + 5 should be 8, but got {}", result);
}
错误处理场景
在实际开发中,除了正常逻辑,我们还需要测试不可恢复错误、Result 错误等边界场景,Rust 默认提供了相对应的方案。
测试 Panic 场景
当函数在特定场景下会触发 panic 时(比如上面的除零操作),我们可以通过 #[should_panic] 属性标记测试用例,验证 panic 是否会如期发生。
rust
#[test]
// 标记该测试用例预期会触发 panic
#[should_panic]
fn divide_by_zero_should_panic() {
divide(10, 0);
}
执行 cargo test,该测试会顺利通过。但这里有个问题:如果被测试的函数可能会触发多种 panic,我们如何去测试特定的 panic 呢?
为了精准匹配 panic 场景,我们可以添加 expected 参数,指定预期的 panic 信息:
rust
#[test]
#[should_panic(expected = "Division by zero is not allowed")]
fn divide_by_zero_should_panic_with_exact_msg() {
divide(10, 0);
}
只有当 panic 信息包含指定的字符串时,测试才会通过,避免误判。
测试 Result 场景
除了 panic,Rust 中更常用的错误处理方式是 Result<T, E> 类型。Rust 测试原生支持返回 Result 类型,无需 panic,还能使用 ? 运算符简化错误传播,代码更简洁优雅。
首先添加一个新函数 divide_safe,返回 Result 类型:
rust
/// 安全的整数除法,返回 Result 类型
pub fn divide_safe(a: i32, b: i32) -> Result<i32, String> {
if b == 0 {
return Err("Division by zero is not allowed".to_string());
}
Ok(a / b)
}
然后编写对应的测试用例:
rust
#[test]
fn divide_safe_should_return_correct_result() -> Result<(), String> {
// 使用 ? 运算符,出错时直接返回 Err,终止测试
assert_eq!(divide_safe(10, 2)?, 5);
// 验证错误场景
assert!(divide_safe(10, 0).is_err());
// 测试通过,返回 Ok(())
Ok(())
}
这种方式更符合 Rust 的错误处理哲学,也是官方推荐的错误场景测试方案。
第一个集成测试用例
集成测试用于验证库的对外公有 API 的行为是否符合预期,测试用例统一放在项目根目录的 tests 文件夹下,与 src 同级。
在项目根目录创建 tests 文件夹,再创建 tests/integration_test.rs,写入以下代码:
rust
use test_example::*;
#[test]
fn test_add_public_api() {
// 测试公有API的行为
assert_eq!(add(10, 20), 30);
assert_eq!(add(-5, 5), 0);
}
#[test]
fn test_divide_safe_public_api() {
assert!(divide_safe(100, 10).is_ok());
assert!(divide_safe(100, 0).is_err());
}
执行 cargo test,你会看到输出中新增了集成测试的执行结果,同时单元测试也会一起执行。如果只想执行指定的集成测试文件,可以使用以下命令:
shell
# 仅执行 integration_test.rs 中的测试
cargo test --test integration_test
在实际开发中,多个集成测试文件经常需要复用一些通用逻辑(比如环境初始化、数据准备等),而且我们还不想把它们写到业务目录 src 中。
这时候,我们可以创建 tests/common/mod.rs,当然创建为 tests/common.rs 也可行,但不推荐,因为 Cargo 会将该文件视为测试 crate 并尝试运行其中的测试。
示例:创建 tests/common/mod.rs,写入通用逻辑:
rust
pub fn setup_test_env() {
println!("初始化测试环境:加载配置、连接数据库等");
// 通用的测试初始化逻辑
}
在集成测试中引用通用模块:
rust
// 引入通用模块
mod common;
#[test]
fn test_with_env_setup() {
// 调用共享的初始化函数
common::setup_test_env();
assert_eq!(add(5, 5), 10);
}
第一个文档测试用例
Rust 最具特色的测试功能就是原生支持文档即测试,即文档注释中的代码块,会被 cargo test 自动识别为测试用例并执行,解决文档与代码不同步的痛点。
给 add 函数添加带测试的文档注释:
rust
/// 两个整数相加
///
/// # Examples
///
/// ```
/// // 文档测试的代码块会被自动执行
/// use test_example::add;
///
/// assert_eq!(add(2, 3), 5);
/// assert_eq!(add(-1, 1), 0);
/// ```
pub fn add(a: i32, b: i32) -> i32 {
a + b
}
执行 cargo test,你会看到输出中新增了 Doc-tests 部分,这说明文档测试已经被执行。
第三方库推荐
当内置的测试能力无法满足复杂场景时,可以使用社区成熟的第三方库扩展测试能力:
- mockall,强大的 Mock 框架,模拟依赖接口,隔离测试环境
- faker,假数据生成,支持多语言
- criterion,Rust 生态系统中最流行的基准测试工具
- cargo-fuzz,模糊测试工具,挖掘代码中的边界漏洞
- cargo-nextest,新一代的 Rust 测试运行器,能够极大提升测试性能,可以完全替代
cargo test命令。
总结
最后,好的测试不仅能保证代码的正确性,还能倒逼你写出更清晰、更易维护、耦合度更低的代码。希望本文能帮你快速掌握 Rust 测试的核心能力,写出更健壮的 Rust 项目。