Appearance
35.2 测试
单元测试
Rust 内置了一套单元测试框架。单元测试是一种目前业界广泛使用的,可以显著提升代码可靠性的工程管理手段。Rust 里面的单元测试代码可以直接和业务代码写在一个文件中,非常有利于管理,方便更新。执行单元测试也非常简单,一条cargo test
命令即可。
一般情况下,如果我们新建一个 library 项目,cargo 工具会帮我们在src/lib.rs
中自动生成如下代码:
rust
#[cfg(test)]
mod tests {
#[test]
fn it_works() {
}
}
这就是最基本的单元测试框架。下面详细介绍一下这里面的各个要素。
首先,Rust 里面有一个特殊的 attribute,叫作#[cfg]
,它主要是用于实现各种条件编译。比如 #[cfg(test)]
意思是:the configuration option is test,这部分代码只在 test 这个开关打开的时候才会被编译。它还有更高级的用法,比如
rust
#[cfg(any(unix, windows))]
#[cfg(all(unix, target_pointer_width = "32"))]
#[cfg(not(foo))]
#[cfg(any(not(unix), all(target_os="macos", target_arch = "powerpc")))]
我们还可以自定义一些功能开关。比如在Cargo.toml
中加入这样的代码:
toml
[features]
# 默认开启的功能开关
default = []
# 定义一个新的功能开关,以及它所依赖的其他功能
# 我们定义的这个功能不依赖其他功能,默认没有开启
my_feature_name = []
之后就可以在代码中使用这个功能开关,某部分代码可以根据这个开关的状态决定编译还是不编译:
rust
#[cfg(feature = "my_feature_name")]
mod sub_module_name {
}
这个开关究竟是开还是关,可以通过编译选项传递进去:
sh
cargo build --features "my_feature_name"
当我们使用cargo test
命令的时候,被#[cfg(test)]
标记的代码就会被编译执行;否则直接被忽略。
我们还是用一个示例来说明。我们现在准备实现一个辗转相除法求最大公约数的功能。新建一个名叫 gcd 的项目:
sh
cargo new --lib gcd
辗转相除法的细节就不展开了。实现代码如下所示:
rust
pub fn gcd(a: u64, b: u64) -> u64
{
let (mut l, mut g) = if a < b {
(a, b)
} else {
(b, a)
};
while l != 0 {
let m = g % l;
g = l;
l = m;
}
return g;
}
接下来添加一个最基本的测试:
rust
#[cfg(test)]
mod tests {
#[test]
fn it_works() {
assert_eq!(gcd(2, 3), 1);
}
}
使用cargo test
命令执行这个测试。这一次发生了编译错误,编译器找不到 gcd
这个函数。这是因为我们把测试用例写在了一个单独的模块中,在子模块中并不能直接访问父模块中的内容。在 mod 内部加一句use gcd;
或者use super::*;
可以解决这个问题。
Compiling gcd v0.1.0 (file:///projects/gcd)
Finished dev [unoptimized + debuginfo] target(s) in 2.33 secs
Running target/debug/deps/gcd-1658b34b1de16a01
running 1 test
test tests::it_works ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
Doc-tests gcd
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
打印出来的结果非常清晰易读。前面一部分是执行测试模块中的测试用例的结果,后面一部分是执行文档中的测试用例的结果。1 passed
代表通过了1
个测试。0 failed
代表失败了0
个测试。0 ignored
代表忽略了0
个测试。
用户可以用#[ignore]
标记测试用例,暂时忽略这个测试。比如:
rust
#[test]
#[ignore]
fn it_works() {
assert_eq!(gcd(2, 3), 1);
}
如果只想运行被忽略的测试,可以使用 cargo test -- --ignored
。或者运行所有的测试,不管它们是否被忽略,可以运行 cargo test -- --include-ignored
。
0 measured
代表跑了0
个 benchmark 性能测试。我们可以用#[bench]
添加性能测试用例:
rust
#[cfg(test)]
mod tests {
use super::*;
use self::test::Bencher;
#[bench]
fn big_num(b: &mut Bencher) {
b.iter(|| gcd(12345, 67890) )
}
}
这个功能目前还没有稳定,需要用户在当前 crate 中开启 feature gate:
rust
#![feature(test)]
extern crate test;
然后使用cargo bench
就可以执行这个性能测试。这时就可以看到测试结果中有1 measured
的结果。
测试结果中还有0 filtered out
统计数据。这个数据代表的是用户跑测试的时候过滤出来了多少测试用例。比如我们有许多测试用例,但是只想执行某一个具体的测试用例it_works
,可以这样做:cargo test it_works
。或者可以用包含特定单词的方式过滤出多个测试用例,比如cargo test add
。还有更多的用法可以参见cargo test -h
。
在测试用例内部,我们一般用assert_eq!
宏来检查真实结果和预期结果是否一致。 除此之外,也还有其他的检查方法。assert!
宏可以用于检查一个 bool 类型结果是否为true
。 assert_ne!
宏可以用于检查两个数据是否不相等。另外,我们还可以在这些宏里面自定义错误信息。比如我们用0
来测试上面这个gcd
函数。因为0
作为除数没有意义,所以我们希望任何一个参数为0
的时候,输出结果都是0
,可以写这样的测试用例:
rust
#[test]
fn with_zero() {
assert_eq!(gcd(10, 0), 0, "division by zero has no meaning");
}
如果测试失败,失败消息中会显示我们指定的那条信息。
另外,有些时候测试结果无法用“等于不等于”这种条件表达,比如发生 panic。测试框架也允许我们用#[should_panic]
做这种测试。假设,我们修改一下上面的gcd
函数的逻辑,不允许参数为0
,如果参数是0
直接发生 panic:
rust
pub fn gcd(a: u64, b: u64) -> u64
{
if a == 0 || b == 0 {
panic!("Parameter should not be zero");
}
…
}
为了测试这种情况,我们可以写如下测试用例:
rust
#[test]
#[should_panic]
fn with_zero() {
gcd(10, 0);
}
一般我们都把测试用例放到单独的 mod 里面,打上#[cfg(test)]
条件编译的标签,这样编译正常代码的时候就可以把测试相关的整个模块忽略掉。这个测试模块一般直接放在被测试代码的同一个文件中:一方面是比较直观容易管理;另一方面,根据 Rust 的模块可见性规则,这个测试模块有权访问它父模块的私有元素,这样比较方便测试。
用户也可以自己组织测试用例的代码结构。比如单独使用一个新的文件夹来管理测试用例,这都是没问题的。毕竟测试代码也不过就是一个普通的模块而已,我们可以自由选择如何管理这个模块。
Rust 默认的测试框架毕竟还只是一个轻量级的框架。功能比许多其他语言中的大型测试框架要差一些。这也是目前 Rust 设计组比较关心的一个领域。他们正在设计一个方案,使用户可以比较方便地实现自定义测试框架。这样可以由社区开发一些功能更强大的测试框架作为替代品,供大家使用。
35.2.1 控制测试的行为
关于测试,还有很多其他控制选项,帮助我们更好的完成相应的测试要求。运行cargo help test
可以打印出关于测试命令的详细介绍。还可以参考官网 cargo 文档的 Tests 章。
这里介绍一些常用的的控制选项:
默认情况下,是并行运行多个测试的,这样测试完成运行的速度更快,我们可以更快地获得反馈。
sh
$ cargo test -- --test-threads=1
通过将测试线程的数量设置为 1
,禁止并行运行测试。这样运行测试会花费更长的时间,如果是共享状态,测试是不会相互干扰的。
默认情况下,对于通过的测试,Rust 的测试库会捕获任何打印到标准输出的内容,所以我们不会在输出中看到代码中有关 println!
的打印信息;
sh
$ cargo test -- --show-output
这样就能让通过的测试,也能显示代码中相关的打印输出了。
35.2.2 集成测试
在 Rust 中,集成测试与你的库是完全分开的,而且只对库项目进行集成测试。集成测试就好像其他的使用者一样,这意味着它们只能调用属于库的公共 API 的部分。
为什么只能测试库项目(library crate)呢?可以这样理解:对于一个二进制项目(binary crates)来说,除了入口 src/main.rs
文件中少量的代码外,几乎所有重要代码都可以合理拆分为库项目,而这些库项目是可以集成测试的,所以对于 src/main.rs
中的少部分代码能正常运行就可以了。
要创建集成测试,首先需要先在 src 同级新建 tests 目录,cargo 会在 tests 目录中查找集成测试文件,类似下面的目录结构:
sh
adder
├── Cargo.lock
├── Cargo.toml
├── src
│ └── lib.rs
└── tests
├── common
│ └── mod.rs
└── integration_test.rs
tests 目录下的每个文件都是一个单独的 crate。这里注意 tests 下的所有子目录中的文件都不会被识别为测试文件而运行,但是这里的 mod.rs
,属于旧命名约定,rust 可以理解。通过这种方式,避免了被识别为测试文件,也方便我们在其中定义一些测试帮助函数。
tests/integration_test.rs
内容:
rust
use adder;
mod common;
#[test]
fn it_adds_two() {
common::setup();
assert_eq!(4, adder::add_two(2));
}
然后在 it_adds_two
函数中,我们可以调用 tests/common/mod.rs
中的 common::setup()
帮助函数。
如果一个单元测试失败,紧接着的集成测试和文档测试都不会有任何输出,只有在所有单元测试都通过的情况下,其他的测试才会正常运行。
如果我们只想运行某个集成测试文件中的所有代码,可以通过命令类似下面的命令:
sh
$ cargo test --test integration_test
上面的命令,只运行了 tests/integration_test.rs
文件中的所有测试。