测试
测试是确保代码正确性的重要手段。Rust 内置了测试框架,无需额外配置即可编写和运行测试。本章将介绍如何编写和组织测试,以及测试的最佳实践。
测试概述
Rust 的测试哲学可以概括为:通过编写自动化测试来验证代码行为是否符合预期。测试框架是 Rust 语言的核心部分,不需要第三方库支持。
测试类型
Rust 支持三种主要测试类型:
| 测试类型 | 位置 | 用途 | 特点 |
|---|---|---|---|
| 单元测试 | src/ 目录下的 #[cfg(test)] 模块 | 测试单个函数或模块 | 可以测试私有函数 |
| 集成测试 | tests/ 目录 | 测试多个模块的协作 | 只能测试公开 API |
| 文档测试 | 文档注释中的代码块 | 验证文档示例的正确性 | 自动生成和运行 |
编写测试
基本测试
使用 #[test] 属性标记测试函数:
#[test]
fn it_works() {
assert!(true);
}
#[test]
fn another_test() {
assert_eq!(2 + 2, 4);
}
运行测试:
cargo test
输出示例:
running 2 tests
test it_works ... ok
test another_test ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
断言宏
Rust 提供了多种断言宏用于验证测试结果:
#[test]
fn test_assertions() {
// assert! - 验证表达式为 true
assert!(true);
assert!(5 > 3, "5 应该大于 3");
// assert_eq! - 验证两个值相等
assert_eq!(2 + 2, 4);
assert_eq!("hello".to_uppercase(), "HELLO");
// assert_ne! - 验证两个值不相等
assert_ne!(1, 2);
assert_ne!("hello", "world");
}
#[test]
fn test_with_custom_message() {
let result = 2 + 2;
assert_eq!(
result,
4,
"计算结果应该是 4,但实际是 {}",
result
);
}
断言宏对比:
| 宏 | 用途 | 失败时的输出 |
|---|---|---|
assert!(expr) | 验证表达式为真 | 表达式的值 |
assert_eq!(a, b) | 验证两个值相等 | 左值和右值 |
assert_ne!(a, b) | 验证两个值不等 | 左值和右值 |
测试失败
测试失败有两种方式:断言失败或 panic:
#[test]
fn test_failure() {
// 断言失败
assert!(false, "这个测试会失败");
}
#[test]
fn test_panic() {
// panic 导致测试失败
panic!("这个测试会崩溃");
}
测试应该 panic
使用 #[should_panic] 验证代码是否按预期 panic:
#[test]
#[should_panic]
fn test_panic_expected() {
panic!("这个 panic 是预期的");
}
#[test]
#[should_panic(expected = "除数不能为零")]
fn test_divide_by_zero() {
divide(10, 0);
}
fn divide(a: i32, b: i32) -> i32 {
if b == 0 {
panic!("除数不能为零");
}
a / b
}
expected 参数可以检查 panic 信息是否包含特定字符串,使测试更加精确。
使用 Result 的测试
测试函数可以返回 Result<T, E>,这在处理可能失败的操作时更加方便:
#[test]
fn test_result() -> Result<(), String> {
let result = 2 + 2;
if result == 4 {
Ok(())
} else {
Err(String::from("结果不是 4"))
}
}
#[test]
fn test_parse() -> Result<(), std::num::ParseIntError> {
let number: i32 = "42".parse()?;
assert_eq!(number, 42);
Ok(())
}
使用 Result 的好处是可以使用 ? 运算符进行错误传播,使代码更加简洁。
单元测试
单元测试与被测试代码放在同一文件中,使用 #[cfg(test)] 模块组织。
测试模块结构
// src/lib.rs 或 src/main.rs
// 被测试的函数
pub fn add(a: i32, b: i32) -> i32 {
a + b
}
fn internal_function() -> i32 {
42
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_add() {
assert_eq!(add(2, 3), 5);
}
#[test]
fn test_internal() {
// 可以测试私有函数
assert_eq!(internal_function(), 42);
}
}
关键点解释:
#[cfg(test)]:这是一个条件编译属性,告诉 Rust 只在运行cargo test时编译此模块。这样可以避免在正常编译时包含测试代码,减少编译时间和二进制大小。use super::*:导入父模块的所有内容,使测试代码可以访问被测试的函数。- 测试私有函数:单元测试可以访问私有函数,这是单元测试的一个重要优势。
测试示例
pub fn is_even(n: i32) -> bool {
n % 2 == 0
}
pub fn factorial(n: u32) -> u32 {
if n == 0 {
1
} else {
n * factorial(n - 1)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_is_even() {
assert!(is_even(2));
assert!(is_even(0));
assert!(!is_even(1));
assert!(!is_even(3));
}
#[test]
fn test_factorial() {
assert_eq!(factorial(0), 1);
assert_eq!(factorial(1), 1);
assert_eq!(factorial(5), 120);
}
#[test]
fn test_factorial_edge_cases() {
// 测试边界情况
assert_eq!(factorial(0), 1);
assert_eq!(factorial(1), 1);
}
}
集成测试
集成测试放在 tests 目录下,只能测试公开 API。这有助于验证代码的外部行为是否符合预期。
创建集成测试
项目结构:
my_project/
├── Cargo.toml
├── src/
│ └── lib.rs
└── tests/
└── integration_test.rs
// tests/integration_test.rs
use my_project::*;
#[test]
fn test_add() {
assert_eq!(add(2, 3), 5);
}
#[test]
fn test_public_api() {
// 只能测试公开函数
assert!(is_even(4));
}
注意:集成测试文件中不需要 #[cfg(test)] 属性,因为 tests 目录本身就是专门用于测试的。
共享测试模块
在 tests 目录下创建公共模块,供多个测试文件使用:
tests/
├── common/
│ └── mod.rs
└── integration_test.rs
// tests/common/mod.rs
pub fn setup() {
// 测试前的初始化工作
}
pub fn create_test_data() -> Vec<i32> {
vec![1, 2, 3, 4, 5]
}
// tests/integration_test.rs
mod common;
use my_project::*;
#[test]
fn test_with_setup() {
common::setup();
let data = common::create_test_data();
assert_eq!(data.len(), 5);
}
为什么需要 mod.rs?
common 目录下的 mod.rs 文件定义了一个模块。如果不加这个文件,Rust 会将 common 目录视为普通的测试目录,而不是一个可导入的模块。
文档测试
文档注释中的代码示例会被自动测试,这确保了文档与代码的一致性。
基本文档测试
/// 将两个数相加
///
/// # Examples
///
/// ```
/// use my_project::add;
///
/// let result = add(2, 3);
/// assert_eq!(result, 5);
/// ```
pub fn add(a: i32, b: i32) -> i32 {
a + b
}
/// 计算阶乘
///
/// # Examples
///
/// ```
/// use my_project::factorial;
///
/// assert_eq!(factorial(0), 1);
/// assert_eq!(factorial(5), 120);
/// ```
///
/// # Panics
///
/// 输入过大时会 panic
pub fn factorial(n: u32) -> u32 {
if n == 0 {
1
} else {
n * factorial(n - 1)
}
}
文档注释章节
常用的文档注释章节:
| 章节 | 用途 |
|---|---|
# Examples | 代码示例 |
# Panics | 描述可能 panic 的情况 |
# Errors | 描述可能返回的错误 |
# Safety | unsafe 代码的安全说明 |
忽略文档测试
/// 示例代码(不运行测试)
///
/// ```ignore
/// use my_project::some_function;
///
/// // 这个代码块不会被执行
/// some_function();
/// ```
pub fn some_function() {}
/// 编译但不运行
///
/// ```no_run
/// use my_project::long_running;
///
/// // 编译通过但不运行
/// long_running();
/// ```
pub fn long_running() {
loop {}
}
/// 允许编译错误
///
/// ```compile_fail
/// let x: i32 = "hello"; // 编译错误
/// ```
pub fn compile_fail_example() {}
文档测试属性说明:
ignore:完全忽略此代码块,不编译也不运行no_run:编译代码确保没有语法错误,但不运行compile_fail:期望编译失败,用于展示错误用法
运行测试
基本命令
# 运行所有测试
cargo test
# 运行特定测试
cargo test test_name
# 运行名称匹配的测试
cargo test keyword
# 运行单元测试
cargo test --lib
# 运行集成测试
cargo test --test integration_test
# 运行文档测试
cargo test --doc
控制测试行为
# 显示测试输出(包括 println! 等)
cargo test -- --nocapture
# 并行运行测试(默认)
cargo test
# 串行运行测试
cargo test -- --test-threads=1
# 只运行被忽略的测试
cargo test -- --ignored
# 运行所有测试(包括被忽略的)
cargo test -- --include-ignored
忽略测试
#[test]
#[ignore]
fn expensive_test() {
// 耗时的测试
}
#[test]
#[ignore = "需要外部依赖"]
fn requires_external_service() {
// 需要外部服务的测试
}
被标记为 #[ignore] 的测试在正常运行 cargo test 时会被跳过,但可以使用 cargo test -- --ignored 单独运行。
测试组织
测试辅助函数
#[cfg(test)]
mod tests {
use super::*;
// 辅助函数
fn create_test_user() -> User {
User {
name: String::from("test"),
age: 25,
}
}
#[test]
fn test_user_name() {
let user = create_test_user();
assert_eq!(user.name, "test");
}
#[test]
fn test_user_age() {
let user = create_test_user();
assert_eq!(user.age, 25);
}
}
struct User {
name: String,
age: u32,
}
测试夹具(Fixture)
#[cfg(test)]
mod tests {
use super::*;
struct TestContext {
data: Vec<i32>,
}
impl TestContext {
fn new() -> Self {
TestContext {
data: vec![1, 2, 3, 4, 5],
}
}
}
#[test]
fn test_with_context() {
let ctx = TestContext::new();
assert_eq!(ctx.data.len(), 5);
}
}
测试最佳实践
1. 测试命名
好的测试名称应该清晰描述测试的行为:
#[cfg(test)]
mod tests {
use super::*;
// 好的命名:描述测试行为
#[test]
fn add_returns_sum_of_two_numbers() {
assert_eq!(add(2, 3), 5);
}
#[test]
fn is_even_returns_false_for_odd_numbers() {
assert!(!is_even(3));
}
#[test]
fn factorial_returns_1_for_zero() {
assert_eq!(factorial(0), 1);
}
}
2. 测试边界情况
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_normal_case() {
assert_eq!(divide(10, 2), 5);
}
#[test]
fn test_edge_cases() {
// 边界情况
assert_eq!(divide(0, 1), 0); // 零作为被除数
assert_eq!(divide(1, 1), 1); // 相同的数
}
#[test]
#[should_panic(expected = "除数不能为零")]
fn test_error_case() {
divide(10, 0); // 错误情况
}
}
3. 一个测试一个概念
每个测试应该只验证一个概念,这样当测试失败时,更容易定位问题:
#[cfg(test)]
mod tests {
use super::*;
// 好:每个测试只验证一个概念
#[test]
fn is_even_returns_true_for_even_numbers() {
assert!(is_even(2));
assert!(is_even(4));
assert!(is_even(0));
}
#[test]
fn is_even_returns_false_for_odd_numbers() {
assert!(!is_even(1));
assert!(!is_even(3));
}
}
4. 使用有意义的断言消息
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_with_meaningful_messages() {
let result = add(2, 3);
assert_eq!(
result,
5,
"add(2, 3) 应该返回 5,但实际返回 {}",
result
);
}
}
5. 测试错误处理
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_error_handling() -> Result<(), String> {
let result = parse_number("42")?;
assert_eq!(result, 42);
Ok(())
}
#[test]
fn test_invalid_input() {
let result = parse_number("not a number");
assert!(result.is_err());
}
}
fn parse_number(s: &str) -> Result<i32, String> {
s.parse().map_err(|_| format!("无法解析 '{}' 为数字", s))
}
测试覆盖率
使用 cargo-tarpaulin
# 安装
cargo install cargo-tarpaulin
# 运行覆盖率测试
cargo tarpaulin
使用 cargo-llvm-cov
# 安装
cargo install cargo-llvm-cov
# 运行覆盖率测试
cargo llvm-cov
覆盖率工具可以帮助你发现哪些代码没有被测试覆盖,但要注意:高覆盖率并不一定意味着高质量的测试。
小结
本章我们学习了:
- 测试基础:使用
#[test]编写测试 - 断言宏:
assert!、assert_eq!、assert_ne! - 单元测试:与代码放在同一文件,可以测试私有函数
- 集成测试:放在
tests目录,测试公开 API - 文档测试:自动测试文档示例
- 测试组织:命名、边界情况、最佳实践
练习
- 为一个计算器模块编写完整的单元测试
- 编写集成测试测试多个模块的协作
- 为一个函数编写文档测试
- 测试一个可能失败的函数的错误处理