跳到主要内容

测试

测试是确保代码正确性的重要手段。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描述可能返回的错误
# Safetyunsafe 代码的安全说明

忽略文档测试

/// 示例代码(不运行测试)
///
/// ```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

覆盖率工具可以帮助你发现哪些代码没有被测试覆盖,但要注意:高覆盖率并不一定意味着高质量的测试。

小结

本章我们学习了:

  1. 测试基础:使用 #[test] 编写测试
  2. 断言宏assert!assert_eq!assert_ne!
  3. 单元测试:与代码放在同一文件,可以测试私有函数
  4. 集成测试:放在 tests 目录,测试公开 API
  5. 文档测试:自动测试文档示例
  6. 测试组织:命名、边界情况、最佳实践

练习

  1. 为一个计算器模块编写完整的单元测试
  2. 编写集成测试测试多个模块的协作
  3. 为一个函数编写文档测试
  4. 测试一个可能失败的函数的错误处理

参考资料