写点什么

Rust 从 0 到 1- 自动化测试 - 测试组织

用户头像
关注
发布于: 3 小时前
Rust从0到1-自动化测试-测试组织

就像我们最开始说的,测试是复杂的方法(我们难以完全证明 bug 不存在),并且不同的开发者采用的技术和组织方式也不相同。Rust 社区倾向于从两个主要分类来进行考虑:单元测试(unit tests)与 集成测试(integration tests)。单元测试一般比较小,聚焦于单个功,一次仅测试一个模块,可以测试私有接口;而集成测试对于被测试的代码来说相当于外部调用我们库的代码,因此,只测试公有接口,并且一个测试可能会调用到多个模块。

从这两个方面对我们的代码进行测试,对于保证我们的代码从单元和整体(底层单个的功能实现和组合起来对外提供的服务)两个方面都按照我们预期运行是非常重要的。

单元测试

单元测试的目的是在与其他部分隔离的情况下单独测试代码的每一个单元部分,以便于快速验证其功能是否符合我们的预期。我们一般将单元测试与他们要测试的代码放在一起,即存放在位于 src 目录下的相同文件中。通常的习惯做法是在每个源文件中创建包含测试函数的 tests 模块,并在模块上标注 cfg(test) 。

测试模块和 #[cfg(test)]

模块的 #[cfg(test)] 注解告诉 Rust 只在执行 cargo test 时才对其编译和运行,而在执行 cargo build 时则不会。由于没有包含测试代码,在构建库的时候可以节省编译时间,并且也可以减少最终编译产生的文件的大小。而集成测试的代码因为位于不同的文件夹,所以不需要使用 #[cfg(test)] 进行注解。我们首先来回顾下 Cargo 为我们自动生成的测试代码:

#[cfg(test)]mod tests {    #[test]    fn it_works() {        assert_eq!(2 + 2, 4);    }}
复制代码

上面的例子中,cfg 是 configuration 的缩写,它告诉 Rust 其后的内容只应在指定配置项中被包含。在这个例子中,配置项是 test,Rust 提供用于编译和运行测试。通过指定 #[cfg(test)] 属性,Cargo 只会在我们使用 cargo test 运行时才编译和运行测试代码。需要注意的是,被编译的不仅仅是标注为 #[test] 的函数,还包括测试模块中可能包含的帮助函数(用于帮助我们完成测试的一些通用或抽象功能)。

测试私有函数

社区中一直存在关于是否应该直接对私有函数进行测试的争议,并且在其它一些语言中想要直接测试私有函数是一件困难,甚至是不可能的事。不过无论我们认同哪种观点,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 internal() { assert_eq!(4, internal_adder(2, 2)); }}
复制代码

上面的例子中 internal_adder 函数并没有标记为 pub,但是因为测试模块是其所在模块的子模块,我们完全可以在测试中导入和调用 internal_adder 函数。当然,如果我们认为不应该直接测试私有函数,Rust 也没有强制要求。

集成测试

集成测试对于我们需要测试的代码来说完全相当于外部调用者,就像其它使用我们库的代码一样,也就是说它们只能调用我们库中对外暴露的公有 API 。集成测试的目的是测试代码的各个部分能否一起正常工作。一些通过单元测试可以正确运行的代码集成在一起运行时也可能会出现问题,所以集成测试同样也很重要。为了创建集成测试,首先我们需要创建一个 tests 目录。

tests 目录

我们首先需要在工程的根目录创建一个 tests 目录,与 src 同级。Cargo 知道去这个目录中找我们的集成测试代码。我们可以在这个目录中创建任意多的测试文件,Cargo 会将每一个文件当作单独的 crate 来编译。

下面让我们来创建一个集成测试。创建一个 tests 目录,新建文件 tests/integration_test.rs,其中的代码如下:

use adder;
#[test]fn it_adds_two() { assert_eq!(4, adder::add_two(2));}
复制代码

与单元测试不同,我们需要使用 use adder 引入 adder 库。因为 tests 目录中的每一个测试文件都是完全独立的 crate,所以需要在每一个文件中导入需要测试的库。

另外,我们并不需要在 tests/integration_test.rs 中的任何位置标注 #[cfg(test)]。这是因为 tests 文件夹是一个特殊的文件夹, Cargo 只会在运行 cargo test 时对其中的文件编译并运行。让我们执行 cargo test 看看:

$ cargo test   Compiling adder v0.1.0 (file:///projects/adder)    Finished test [unoptimized + debuginfo] target(s) in 1.31s     Running target/debug/deps/adder-1082c4b063a8fbe6
running 1 testtest tests::internal ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Running target/debug/deps/integration_test-1082c4b063a8fbe6
running 1 testtest it_adds_two ... 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
复制代码

上面的结果中包含了三个测试的输出:单元测试、集成测试和文档测试。第一部分的单元测试我们在前面的例子中已经解释过。集成测试部分以行 Running target/debug/deps/integration-test-ce99bcc2479f4607(最后的哈希值部分可能不同)开头,接下来是集成测试中的测试函数列表,最后是集成测试的摘要(在文档测试  Doc-tests adder 之前)。如果我们在 tests 目录下还有其它的测试文件,那么每个文件都对应一个集成测试部分。

我们仍然可以通过指定测试函数的名称来运行符合匹配规则的部分集成测试。另外,还可以使用 cargo test  --test [文件名] 来运行指定文件中的所有测试:

$ cargo test --test integration_test   Compiling adder v0.1.0 (file:///projects/adder)    Finished test [unoptimized + debuginfo] target(s) in 0.64s     Running target/debug/deps/integration_test-82e7799c1bc62298
running 1 testtest it_adds_two ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
复制代码

我们可以看到,上面的例子只运行了 tests 目录中我们指定的文件 integration_test.rs 中的测试。

集成测试中的子模块

随着测试用例的增加,我们可能会希望在 tests 目录增加更多文件以便进行更好的组织,譬如,按照被测的功能来进行分组,不同功能位于一个单独的文件中。

将每个文件当作其独立的 crate 来对待,有助于创建单独的作用域,更类似外部代码使用被测 crate 的场景。也是因为这样,tests 目录中的文件不能像 src 中的文件那样进行代码共享(前面我们介绍过代码组织的内容)。

那么,当我们有一些在多个集成测试文件都会用到的函数,怎么办呢?我们先按照前面代码组织章节介绍的内容试试看,譬如,我们创建 一个 tests/common.rs 文件,然后创建一个 setup 函数,我们希望能在多个测试文件中都可以对其调用:

pub fn setup() {    // setup code specific to your library's tests would go here}
复制代码

再次运行测试,我们将会在测试结果中看到对应 common.rs 的测试结果部分,即便这个文件并没有包含任何测试函数,也没有任何地方调用了 setup 函数,类似下面这样:

$ cargo test   Compiling adder v0.1.0 (file:///projects/adder)    Finished test [unoptimized + debuginfo] target(s) in 0.89s     Running target/debug/deps/adder-92948b65e88960b4
running 1 testtest tests::internal ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Running target/debug/deps/common-92948b65e88960b4
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Running target/debug/deps/integration_test-92948b65e88960b4
running 1 testtest it_adds_two ... 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
复制代码

然而,我们并不希望 common.rs 出现在测试结果中,我们只是希望其能够被其测试调用。为了做到这一点,我们需要创建 tests/common/mod.rs 。这是 Rust 的一种命名规范,告诉 Rust 不要将 common 看作一个集成测试文件(tests 目录中的子目录不会被作为单独的 crate 编译或作为一个测试结果部分出现在测试输出中)。我们将 setup 函数改到 tests/common/mod.rs 中并删除 tests/common.rs 文件,common 将不会再出现在测试结果中,同时其就可以作为模块在任何集成测试中使用。参考下面的例子:

use adder;
mod common;
#[test]fn it_adds_two() { common::setup(); assert_eq!(4, adder::add_two(2));}
复制代码
Binary Crates 的集成测试

如果项目是 binary crate 并且只包含 src/main.rs 没有 src/lib.rs,这样就无法在 tests 目录创建集成测试并导入 src/main.rs 中定义的函数。只有 lib crate 才会向外部暴露可供调用的函数。

这就是为什么 Rust 项目通常将主要的功能实现抽象至 src/lib.rs 中,而在 src/main.rs 中直接进行调用的原因之一。因为通过这种结构划分,我们就可以利用集成测试对 lib crate 中的主要功能进行测试了。如果这些主要的功能没有问题的话,src/main.rs 中的少量代码通常也不需要进行测试了。

总结

Rust 的测试功能旨提供一个帮助我们确保代码能按照我们预期的方式运行的方法,即使在对代码进行修改以后。单元测试对单个功能单元进行独立的验证,包括私有函数。集成测试用于按照公开的 API 检查多个功能单元是否能配合起来正确地工作。即使 Rust 的类型机制和所有权规则可以帮助我们避免一些 bug,但是测试对于减少不符合我们预期的逻辑 bug 仍然很重要。

发布于: 3 小时前阅读数: 2
用户头像

关注

公众号"山 顽石"欢迎大家关注;) 2021.03.23 加入

IT老兵,关注Rust、Java、前端、架构、大数据、AI、算法等;平时喜欢美食、旅游、宠物、健身、篮球、乒乓球等。希望能和大家交流分享。

评论

发布
暂无评论
Rust从0到1-自动化测试-测试组织