0%

Rust 权威指南(10):编写自动化测试

程序的正确性用来衡量一段代码的实际行为与设计目标之间的一致程度。Rust 在语言层面内置了编写测试代码、执行自动化测试任务的功能。本章会讨论 Rust 测试工具的运行机制。

如何编写测试

Rust 语言中的测试是一个函数,被用于验证非测试代码是否按照预期的方式运行。

测试函数的构成

最简单的情况下,Rust 中的测试就是一个标注有 test 属性的函数。属性是一种用于修饰 Rust 代码的元数据。将 #[test] 添加到关键字 fn 上的一行便可以将函数转换为测试函数。当测试编写完成之后,使用 cargo test 来运行测试。该命令会构建并执行一个用于测试的可执行文件,该文件在执行过程中会逐一调用所有标注了 test 属性的函数,并生成统计运行成功或者失败的相关报告。

当使用 cargo 新建一个库项目时,它会自动为我们生成一个带有测试函数的测试模块。

1
# cargo new adder --lib
1
2
3
4
5
6
7
8
9
10
11
12
13
14
pub fn add(left: usize, right: usize) -> usize {
left + right
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn it_works() {
let result = add(2, 2);
assert_eq!(result, 4);
}
}

这段代码中,#[test] 将当前函数标记为一个测试,因为在 tests 模块中也可能存在普通的非测试函数。执行 cargo test 命令:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# cargo test
Compiling adder v0.1.0 (/root/code/private/rust/chapter_11/adder)
Finished test [unoptimized + debuginfo] target(s) in 2.60s
Running unittests src/lib.rs (target/debug/deps/adder-211e745081372cd3)

running 1 test
test tests::it_works ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Doc-tests adder

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Doc-tests 开头的部分是文档测试(documentation test)的结果,Rust 能够编译在 API 文档中出现的任何代码示例。这一特性可以帮助我们保证文档总会和实际代码同步。

在 Rust 中,一旦测试触发 panic,该测试就会视为执行失败。每个测试都运行在独立的线程中,主线程在监视测试线程时,一旦发现线程意外终止,就会将对应的测试标记为失败。

接下来列出一些在测试工作中十分有用的宏:

  • assert! 宏由标准库提供,它可以确保测试中某些条件的值为 true
  • 使用 assert_eq!assert_ne! 宏分别断言两个参数相等或不相等,在断言失败时,它们可以自动打印出这两个参数的值。这两个宏分别使用 ==!= 来进行判断,并在断言失败时使用调试输出格式 {:?} 将参数值打印出来。这意味着它们的参数必须同时实现 PartialEq 和 Debug 这两个 trait。一般可以通过在自定义的结构体或枚举的定义上方添加 #[derive(PartialEq, Debug)] 标注来自动实现这两个 trait

添加自定义的错误提示信息

任何在 assert!assert_eq!assert_ne! 宏必要参数之后出现的参数都会一起被传递给 format! 宏。因此可以添加自定义的错误提示信息,将一个包含 {} 占位符的格式化字符串及相对应的填充值作为参数一起传递给这些宏。

使用 should_panic 检查 panic

除了检查代码是否返回了正确的结果,确认代码是否能够按照预期处理错误状况同样重要。可以为测试函数添加一个额外的新属性:should_panic。标记了这个属性的测试函数会在代码发生 panic 时顺利通过,而在代码不发生 panic 时执行失败。

即便函数中发生 panic 的原因与我们预期不同,使用 should_panic 进行的测试也会顺利通过。为了让 should_panic 测试更加精确一些,可以在 should_panic 属性中添加可选的参数 expected,它会检查 panic 发生时输出的错误提示信息是否包含了指定的文字。

使用 Result<T, E> 编写测试

可以使用 Result<T, E> 来编写测试,让测试程序在失败时返回一个 Err 值而不是触发 panic。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
pub fn add(left: usize, right: usize) -> usize {
left + right
}

#[cfg(test)]
mod tests {
use super::*;

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

像这样编写返回 Result<T, E> 的测试,就可以在测试函数体中使用问号运算符,这样可以方便地编写一个测试,该测试在任意一个步骤返回 Err 值时都会执行失败。不要在这些测试上标注 #[should_panic],在测试运行失败时应该直接返回一个 Err 值。

控制测试的运行方式

cargo test 会在测试模式下编译代码,并运行生成的测试二进制文件。可以通过命令行参数来改变 cargo test 的默认行为。由于既可以为 cargo test 指定命令行参数,也可以为生成的二进制测试文件指定参数。为了区分两种不同的参数,需要在传递给 cargo test 的参数后使用分隔符 --,并在其后指定需要传递给测试二进制文件的参数。

并行或串行运行测试

运行多个测试时,Rust 会默认使用多线程来并行执行它们。由于测试是同时运行的,所以开发者必须保证测试之间不会相互依赖,或者依赖到同一个共享的状态或者环境上。

可以通过给测试二进制文件传入 --test-threads 标记以及具体的线程数来控制测试时所启动的线程数量,如果将该值设置为 1,程序就不会使用任何并行操作。

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

显示函数的输出

默认情况下,Rust 的测试库会在测试通过时捕获所有被打印到标准输出的消息。只有测试失败时,才能在错误提示信息的上方观察到打印至标准输出的内容。如果你希望在测试通过时也将值打印出来,可以传入 --nocapture 来禁用输出截获功能。

1
cargo test -- --nocapture

只运行部分特定名称的测试

可以通过向 cargo test 中传递测试函数的名称来指定需要运行的测试:

1
cargo test it_works

需要注意,不能指定多个参数来运行多个测试,只有传递给 cargo test 的第一个参数才能生效,运行多个测试需要使用其他方法:我们可以指定名称的一部分来作为参数,任何匹配这一名称的测试都会得到执行。需要注意,测试所在的模块的名称也是测试名称的一部分,所以可以通过模块名来运行特定模块内的所有测试。

1
2
3
4
5
6
7
8
9
10
# cargo test add
Compiling adder_test v0.1.0 (/root/code/private/rust/chapter_11/adder_test)
Finished test [unoptimized + debuginfo] target(s) in 0.29s
Running unittests src/lib.rs (target/debug/deps/adder_test-716d395fb38c5ecc)

running 2 tests
test tests::add1 ... ok
test tests::add2 ... ok

test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 1 filtered out; finished in 0.00s

通过显式指定来忽略某些测试

除了手动将想要运行的测试列举出来,还可以使用 ignore 属性来标记忽略默写测试。对于想要剔除的测试,会在 #[test] 标记的下方添加 #[ignore]。可以使用如下命令单独运行那些被忽略的测试:

1
cargo test -- --ignored

这样我们就可以实现:每次运行 cargo test 都能迅速得到结果,而对于那些由于特别耗时而被忽略的测试,则可以在需要时通过 cargo test -- --ignored 来运行。

测试的组织结构

测试本身是一门复杂的学科,Rust 社区主要从以下两个分类来讨论测试:

  • 单元测试:小而专注,每次只单独测试一个模块或者私有接口
  • 集成测试:完全位于代码库外,和正常从外部调用代码库一样使用外部代码,只能访问公共接口,并且在一次测试中可能会联用多个模块

单元测试

单元测试的目的在于将一小段代码单独隔离出来,从而迅速地确定这段代码的功能是否符合预期。一般将单元测试与需要测试的代码存放在 src 目录下同一个文件中。同时也约定俗称地在每个源文件中都新建一个 tests 模块来存放测试函数,使用 #[cfg(test)] 来对该模块进行标注。

tests 模块上标注 #[cfg(test)] 可以让 Rust 只在执行 cargo test 命令时编译和运行该部分测试代码,而执行 cargo build 时则剔除它们,这样正常编译时不包含测试代码。由于单元测试和业务代码并列放在同一个文件中,只有通过这个标注才能将单元测试的代码排除在编译产物之外。

cfg 属性是 configuration 的缩写,它告知 Rust 接下来的条目只有在处于特定配置时才需要被包含进来,而 test 就是 Rust 中用来编译、运行测试的配置选项。因此通过 #[cfg(test)] 只有在 cargo test 才会将测试代码纳入编译范围。这一约定不止针对那些标注了 #[test] 属性的测试函数,还针对该模块内的其余辅助函数。

测试私有函数

Rust 通过 私有性规则 的设计,允许测试私有函数,如下代码就直接测试了私有函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
pub fn add(left: usize, right: usize) -> usize {
println!("add called");
internal_add(left, right)
}

fn internal_add(left: usize, right: usize) -> usize {
left + right
}


#[cfg(test)]
mod tests {
use super::*;

#[test]
fn internal() {
assert_eq!(4, internal_add(2, 2))
}
}

集成测试

在 Rust 中,集成测试是完全位于代码库之外的。集成测试调用库的方式和其他代码调用方式没有任何不同。因此也只能调用对外公开提供的接口。集成测试的目的在于验证库的不同部分能否协同起来正常工作。

为了创建集成测试,首先需要创建一个 tests 目录,该目录位于项目的根目录下,与 src 文件并列。Cargo 会自动在该目录下寻找集成测试文件。可以在该目录下创建任意多个测试文件,Cargo 在编译时会将每个文件都处理为一个独立的包。

1
2
3
4
5
6
7
// tests/integration_test.rs
use adder;

#[test]
fn it_adds_two() {
assert_eq!(4, adder::add_two(2));
}
  • 由于 tests 目录下每个文件都是一个单独的包,因此需要将目标库导入每一个测试包中
  • 不需要为集成测试中的任何代码标注 #[cfg(test)],Cargo 对 tests 目录做了特殊处理,它只会在执行 cargo test 命令时编译这个目录下的文件

直接执行 cargo test 会同时运行单元测试、集成测试、文档测试。仍然可以在 cargo test 命令中指定测试函数名称作为参数,来运行特定的集成测试函数。另外可以使用 --test 并指定文件名,可以单独运行某个特定集成测试文件下的所有测试函数。

1
cargo test it_adds_two
1
cargo test --test integration_test

如果想在 tests 目录下创建公共模块,由于 tests 目录的特殊性,该公共模块即使没有包含任何测试函数,也没有任何地方调用过公共模块中的函数,也依然会在运行测试后的测试输出中观察到该公共模块所对应文件的相关测试输出。为了避免这些无意义的输出出现在测试结果中,可以创建 tests/common/mod.rs,通过这种特殊的文件命名方式,Rust 就不会再将 common 模块视为一个集成测试文件了。之后 tests 子目录中文件不会被视为单独的包进行编译,也不会在测试输出中拥有自己的区域。

1
2
3
4
// tests/common/mod.rs
pub fn setup() {
println!("setup called")
}
1
2
3
4
5
6
7
8
9
10
// tests/integration_test.rs
use adder;

mod common;

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

如果项目是一个只有 src/main.rs 而没有 src/lib.rs 文件的二进制包,那么无法在 tests 目录下创建集成测试,也无法使用 use 语句将 src/main.rs 中定义的函数导入作用域。只有代码包(library crate)才可以将函数暴露给其他包来调用,而二进制包只被用于独立执行。

这也是为什么 Rust 的二进制项目经常将项目逻辑写在 src/lib.rs 文件中,而只在 src/main.rs 文件中进行简单调用。这种组织结构使得集成测试可以将我们的项目视为一个代码包,并能够使用 use 访问包中的核心功能。只要核心功能正常,src/main.rs 中的少量胶水代码就能够工作,无需测试。