Skip to content

编写自动化测试

测试是保证代码正确性的重要手段。Rust 的类型系统和借用检查可以消除内存安全问题,但无法保证代码的逻辑正确性——函数签名正确,并不代表函数逻辑没有问题。写测试,就是为了弥补这一空白。

一个典型的测试函数依次完成以下三件事: 一是准备测试所需的数据或状态;二是运行被测试的代码;三是通过断言验证结果是否符合预期。

测试函数的构成

Rust 中的测试就是用属性(Attribute)标注的普通函数。使用 cargo new --lib 创建库项目时,Rust 会自动生成一个测试模块作为起点:

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 默认不运行它。

运行所有测试:

bash
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 模式的不等断言
rust
assert!(result == 4);      // 布尔断言,为 false 时 panic
assert_eq!(result, 4);     // 相等断言,失败时显示两侧的值
assert_ne!(result, 5);     // 不等断言

使用 assert_eq!assert_ne! 的类型必须实现 PartialEqDebug 特征。对于自定义结构体,通过 #[derive(PartialEq, Debug)] 派生即可。

自定义失败信息

所有断言宏都支持附加格式化的自定义错误信息,语法与 format! 完全一致:

rust
#[test]
fn greeting_contains_name() {
    let result = greeting("Sunface");
    let target = "孙飞";
    assert!(
        result.contains(target),
        "问候语中没有包含目标名字 {},实际结果是: `{}`",
        target,
        result
    );
}

默认失败信息只告知断言失败的位置,自定义信息则能提供更多上下文,对大型项目的 Debug 极有价值。

debug_assert! 系列

debug_assert! 系列只在 Debug 模式下运行,Release 模式下编译器会完全忽略它们,不会产生任何运行开销:

rust
debug_assert_eq!(a, b, "调试时验证: a={}, b={}", a, b);

适合用于开销较大的一致性检查,避免影响发布版本的性能。

使用 #[should_panic] 检查预期的 panic

有时需要验证代码在接受非法输入时能正确地 panic。#[should_panic] 属性标注后,只有当函数内部发生 panic,测试才算通过:

rust
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 信息中必须包含的子字符串:

rust
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:

rust
#[test]
fn it_works() -> Result<(), String> {
    if 2 + 2 == 4 {
        Ok(())
    } else {
        Err(String::from("two plus two does not equal four"))
    }
}

这样做的优势是可以在测试中使用 ? 操作符进行链式调用,让测试代码更简洁:

rust
#[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 默认为每个测试函数启动独立线程并行运行,速度快。但如果多个测试共享同一资源(如同一个文件),并行运行可能产生数据竞争导致测试失败,此时指定单线程顺序运行:

bash
cargo test -- --test-threads=1

显示测试中的打印输出

默认情况下,通过的测试中的 println! 输出会被捕获不显示;失败的测试才会显示其输出。若要强制显示所有输出:

bash
cargo test -- --show-output

按名称运行或过滤测试

通过指定测试函数名(或其中一部分)来过滤运行:

bash
cargo test one_hundred    # 只运行名为 one_hundred 的测试
cargo test add            # 运行所有名称包含 "add" 的测试
cargo test tests::        # 运行 tests 模块下的所有测试

无法同时指定多个完整名称(cargo test a b 不生效),需要用名称子串的方式过滤。

忽略特定测试

#[ignore] 标注耗时较长的测试,cargo test 默认跳过它们:

rust
#[test]
#[ignore]
fn expensive_test() {
    // 这里的代码需要几分钟才能完成
}

单独运行被忽略的测试:

bash
cargo test -- --ignored           # 只运行被忽略的测试
cargo test -- --include-ignored   # 运行所有测试(含被忽略的)

组合过滤

名称过滤与 --ignored 可以组合使用:

bash
# 运行 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 文件末尾。

rust
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 文件都是一个独立的包:

rust
// tests/integration_test.rs
use adder; // 必须显式引入库,只能使用 pub API

#[test]
fn it_adds_two() {
    assert_eq!(4, adder::add_two(2));
}

几点注意:

  • 无需 #[cfg(test)]: tests/ 目录本身已说明其用途,Cargo 自动识别
  • 只能调用 pub API: 集成测试无法访问私有函数
  • 仅适用于库项目: 纯二进制项目(只有 main.rs)无法直接进行集成测试。解决办法是将业务逻辑迁移到 lib.rsmain.rs 只保留主干调用

运行指定集成测试文件:

bash
cargo test --test integration_test

共享辅助模块

若多个集成测试文件需要共享辅助函数(如状态初始化),不要直接创建 tests/common.rs——Cargo 会将其当作测试文件执行,输出一堆 running 0 tests。应创建 tests/common/mod.rs,Rust 不会将子目录中的文件当作独立测试执行:

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 小节下:

rust
/// 将一个整数加一并返回.
///
/// # 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 项目名 生成文档。

详细说明

  1. 文档注释标记 ///

    • 与普通的 // 不同,/// 是文档注释
    • Rust 编译器(rustdoc)会提取这些内容,生成 HTML 格式的项目 API 文档
    • 必须写在要说明的项(如函数)之前
  2. Markdown 格式

    • 注释内部完全支持 Markdown 语法
    • # Examples 是二级标题,在生成的 HTML 文档中会显示为独立栏目
  3. 代码块与隐式 Main

    • 代码块中的代码会被当作独立程序运行
    • Rust 在后台自动将代码包裹进虚拟的 fn main() { ... }
    • my_crate::add_one: 需要像外部用户一样引用包名

代码块控制

语法作用
```rust标准可运行文档测试
```ignore显示代码但不运行
```no_run编译但不运行(用于耗时或需要外部资源的代码)
```should_panic预期代码会 panic
```compile_fail预期代码编译失败(用于演示错误用法)

相关命令

bash
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] 中,不会被打包到发布的产物:

toml
# Cargo.toml
[dev-dependencies]
pretty_assertions = "1"
rust
#[cfg(test)]
mod tests {
    use super::*;
    use pretty_assertions::assert_eq; // 仅在测试中可用,提供彩色差异对比

    #[test]
    fn test_add() {
        assert_eq!(add(2, 3), 5);
    }
}

尝试在非测试代码中使用 [dev-dependencies] 中的包,编译器会报错——这正是它存在的意义。

基于 MIT 协议发布