Rust快速入门(9) - 自动化测试

1. 自动化测试简介

测试是确保程序正确性的重要手段。虽然测试不能证明程序完全没有 bug,但可以有效地发现 bug 的存在。Rust 设计时高度关注程序的正确性,其类型系统和所有权规则承担了大部分这一负担,但并不能捕获所有问题,因此 Rust 包含了编写自动化测试的支持。

2. 如何编写测试

在 Rust 中,测试是标记有特殊属性 #[test] 的函数。测试函数通常执行以下三个操作:

  1. 设置所需的数据或状态
  2. 运行被测试的代码
  3. 断言结果符合预期

2.1 测试函数剖析

一个基本的测试函数需要添加 #[test] 属性。当运行 cargo test 命令时,Rust 会构建一个测试运行器,运行所有带有此属性的函数,并报告每个测试是否通过。

创建新项目时,Cargo 会自动生成一个包含测试函数的测试模块。

2.2 断言宏

Rust 提供了多种断言宏用于测试:

  • assert! - 当表达式为 true 时通过测试
  assert!(larger.can_hold(&smaller));
  • assert_eq! - 测试两个参数是否相等
  assert_eq!(4, add_two(2));
  • assert_ne! - 测试两个参数是否不相等
  assert_ne!(5, result);

这些宏在测试失败时会提供有用的错误信息。断言宏还可以添加自定义错误消息:

assert!(
    result.contains("Carol"),
    "Greeting did not contain name, value was `{}`", 
    result
);

2.3 使用 should_panic 检查异常

#[should_panic] 属性用于测试代码是否按预期发生 panic:

#[test]
#[should_panic]
fn greater_than_100() {
    Guess::new(200);
}

为了使 should_panic 测试更精确,可以添加 expected 参数指定 panic 消息中应该包含的文本:

#[test]
#[should_panic(expected = "less than or equal to 100")]
fn greater_than_100() {
    Guess::new(200);
}

2.4 在测试中使用 Result

测试函数也可以返回 Result<T, E>,这样可以在测试中使用 ? 运算符:

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

3. 控制测试的运行方式

3.1 并行或顺序执行测试

默认情况下,测试是并行运行的。如果测试之间有依赖关系或共享状态,可以使用 --test-threads 参数控制线程数:

$ cargo test -- --test-threads=1

3.2 显示函数输出

默认情况下,通过的测试中打印到标准输出的内容会被捕获而不显示。如果要查看所有输出,可以使用 --show-output 参数:

$ cargo test -- --show-output

3.3 运行测试的子集

按名称运行测试

可以通过在 cargo test 命令后指定测试函数名来运行特定测试:

$ cargo test one_hundred

也可以指定测试名称的一部分,所有匹配的测试都会运行:

$ cargo test add  // 运行所有名称中含有 "add" 的测试

3.4 忽略测试除非特别指定

对于耗时较长的测试,可以使用 #[ignore] 属性标记,使其在一般的测试运行中被跳过:

#[test]
#[ignore]
fn expensive_test() {
    // 耗时较长的测试代码
}

要运行被忽略的测试,使用:

$ cargo test -- --ignored

要运行所有测试(包括被忽略的),使用:

$ cargo test -- --include-ignored

4. 测试的组织结构

Rust 中的测试主要分为两类:单元测试和集成测试。

4.1 单元测试

单元测试的目的是在隔离环境中测试每个代码单元,迅速定位问题所在。单元测试与被测试的代码放在同一个文件中,通常在名为 tests 的模块内,并用 #[cfg(test)] 注解标记。

4.1.1 tests 模块和 #[cfg(test)]

#[cfg(test)] 注解告诉 Rust 只在运行 cargo test 时编译和运行测试代码,而不是在 cargo build 时,这样可以节省编译时间和编译结果的空间。

4.1.2 测试私有函数

Rust 允许测试私有函数。测试模块是普通模块,遵循常规的可见性规则。由于测试模块是内部模块,要测试外部模块的代码需要将其引入作用域:

#[cfg(test)]
mod tests {
    use super::*; // 引入父模块的所有内容
    
    #[test]
    fn internal() {
        assert_eq!(4, internal_adder(2, 2));
    }
}

4.2 集成测试

集成测试完全外部于库,以与其他代码相同的方式使用库,只能调用库的公共 API。它们的目的是测试库的多个部分是否正确地协同工作。

4.2.1 tests 目录

集成测试放在项目根目录下的 tests 目录中。Cargo 会将该目录中的每个文件编译为单独的 crate。

// tests/integration_test.rs
use adder;

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

4.2.2 集成测试中的子模块

为了组织多个集成测试,可能希望在 tests 目录创建多个文件。要创建共享的辅助函数而不让它们被视为测试文件,应创建 tests/common/mod.rs 而不是 tests/common.rs

tests/
├── common/
│   └── mod.rs  // 包含共享函数
└── integration_test.rs

然后在测试中使用:

// tests/integration_test.rs
use adder;
mod common;

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

4.2.3 二进制 crate 的集成测试

对于只有 src/main.rs 而没有 src/lib.rs 的二进制 crate,无法创建直接测试 main.rs 中函数的集成测试。这是 Rust 项目通常将主要逻辑放在 src/lib.rs 中并在 src/main.rs 中调用的原因之一。

5. 总结

Rust 的测试功能提供了一种指定代码应该如何工作的方式,确保在进行更改时代码继续按预期运行。单元测试单独测试库的不同部分,可以测试私有实现细节;集成测试检查库的多个部分是否正确协同工作,并以外部代码使用库的方式测试库的公共 API。

虽然 Rust 的类型系统和所有权规则有助于防止某些类型的错误,但测试对于减少与代码预期行为相关的逻辑错误仍然非常重要。


好好学习,天天向上