测试组织

正如本章开头所述,测试是一门复杂的学科,不同的人使用不同的术语和组织方式。Rust 社区从两个主要类别来考虑测试:单元测试和集成测试。单元测试 规模较小且更集中,一次隔离地测试一个模块,并且可以测试私有接口。集成测试 完全位于库的外部,并以任何其他外部代码相同的方式使用你的代码,仅使用公共接口,并可能在每个测试中运行多个模块。

编写这两种类型的测试对于确保库的各个部分按你的预期独立和协同工作非常重要。

单元测试

单元测试的目的是将每个代码单元与代码的其余部分隔离地进行测试,以快速查明代码在何处工作以及在何处未按预期工作。 你会将单元测试放在 src 目录中,每个文件中都包含要测试的代码。 约定是在每个文件中创建一个名为 tests 的模块来包含测试函数,并使用 cfg(test) 注解该模块。

Tests 模块和 #[cfg(test)]

tests 模块上的 #[cfg(test)] 注解告诉 Rust 仅在运行 cargo test 时编译和运行测试代码,而不是在运行 cargo build 时。 这节省了仅想构建库时的编译时间,并节省了生成编译后的工件的空间,因为其中不包含测试。 你会看到,由于集成测试放在不同的目录中,因此它们不需要 #[cfg(test)] 注解。 但是,由于单元测试与代码放在同一文件中,因此你将使用 #[cfg(test)] 来指定不应将其包含在编译结果中。

回想一下,在本章的第一节中,当我们生成新的 adder 项目时,Cargo 为我们生成了以下代码

文件名: src/lib.rs

pub fn add(left: u64, right: u64) -> u64 { left + right } #[cfg(test)] mod tests { use super::*; #[test] fn it_works() { let result = add(2, 2); assert_eq!(result, 4); } }

在自动生成的 tests 模块上,属性 cfg 代表 configuration(配置),并告诉 Rust 只有在给定特定配置选项的情况下才应包含以下项。 在这种情况下,配置选项是 test,Rust 为编译和运行测试提供了该选项。 通过使用 cfg 属性,Cargo 仅在我们使用 cargo test 主动运行测试时才编译我们的测试代码。 这包括此模块中可能存在的任何辅助函数,以及使用 #[test] 注解的函数。

测试私有函数

关于是否应直接测试私有函数,测试社区存在争议,其他语言使测试私有函数变得困难或不可能。 无论你坚持哪种测试思想,Rust 的隐私规则都允许你测试私有函数。 考虑清单 11-12 中的代码以及私有函数 internal_adder

文件名: src/lib.rs
pub fn add_two(a: usize) -> usize { internal_adder(a, 2) } fn internal_adder(left: usize, right: usize) -> usize { left + right } #[cfg(test)] mod tests { use super::*; #[test] fn internal() { let result = internal_adder(2, 2); assert_eq!(result, 4); } }
清单 11-12: 测试私有函数

请注意,internal_adder 函数未标记为 pub。 测试只是 Rust 代码,而 tests 模块只是另一个模块。 正如我们在 “模块树中引用项的路径”部分中讨论的那样,子模块中的项可以使用其祖先模块中的项。 在此测试中,我们使用 use super::*tests 模块父模块的所有项都引入作用域,然后测试可以调用 internal_adder。 如果你不认为应该测试私有函数,那么 Rust 中没有任何东西会强迫你这样做。

集成测试

在 Rust 中,集成测试完全位于库的外部。 它们以任何其他代码相同的方式使用你的库,这意味着它们只能调用属于库公共 API 的函数。 它们的目的是测试库的许多部分是否协同工作正常。 独立工作的代码单元在集成后可能会出现问题,因此集成代码的测试覆盖率也很重要。 要创建集成测试,你首先需要一个 tests 目录。

tests 目录

我们在项目目录的顶层,src 旁边创建一个 tests 目录。 Cargo 知道在此目录中查找集成测试文件。 然后我们可以创建任意数量的测试文件,Cargo 会将每个文件编译为一个单独的 crate。

让我们创建一个集成测试。 对于 src/lib.rs 文件中仍然包含清单 11-12 中的代码,创建一个 tests 目录,并创建一个名为 tests/integration_test.rs 的新文件。 你的目录结构应如下所示

adder ├── Cargo.lock ├── Cargo.toml ├── src │   └── lib.rs └── tests └── integration_test.rs

将清单 11-13 中的代码输入到 tests/integration_test.rs 文件中。

文件名: tests/integration_test.rs
use adder::add_two; #[test] fn it_adds_two() { let result = add_two(2); assert_eq!(result, 4); }
清单 11-13: adder crate 中函数的集成测试

tests 目录中的每个文件都是一个单独的 crate,因此我们需要将我们的库引入每个测试 crate 的作用域中。 因此,我们在代码顶部添加了 use adder::add_two;,这在单元测试中是不需要的。

我们不需要使用 #[cfg(test)] 注解 tests/integration_test.rs 中的任何代码。 Cargo 特殊处理 tests 目录,并且仅在运行 cargo test 时才编译此目录中的文件。 现在运行 cargo test

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

输出的三个部分包括单元测试、集成测试和文档测试。 请注意,如果某个部分中的任何测试失败,则不会运行以下部分。 例如,如果单元测试失败,则不会有集成测试和文档测试的输出,因为只有在所有单元测试都通过的情况下才会运行这些测试。

单元测试的第一个部分与我们一直看到的一样:每项单元测试一行(我们在清单 11-12 中添加了一个名为 internal 的测试),然后是单元测试的摘要行。

集成测试部分以行 Running tests/integration_test.rs 开头。 接下来,集成测试中的每个测试函数都有一行,并且在 Doc-tests adder 部分开始之前,集成测试的结果有一行摘要行。

每个集成测试文件都有自己的部分,因此,如果我们在 tests 目录中添加更多文件,则会有更多集成测试部分。

我们仍然可以通过将测试函数名称指定为 cargo test 的参数来运行特定的集成测试函数。 要运行特定集成测试文件中的所有测试,请使用 cargo test--test 参数,后跟文件名

$ cargo test --test integration_test Compiling adder v0.1.0 (file:///projects/adder) Finished `test` profile [unoptimized + debuginfo] target(s) in 0.64s Running tests/integration_test.rs (target/debug/deps/integration_test-82e7799c1bc62298) running 1 test test 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 目录中创建更多文件以帮助组织它们; 例如,你可以按它们测试的功能对测试函数进行分组。 如前所述,tests 目录中的每个文件都编译为自己的单独 crate,这对于创建单独的作用域以更紧密地模拟最终用户将如何使用你的 crate 的方式很有用。 但是,这意味着 tests 目录中的文件与 src 中的文件不共享相同的行为,正如你在第 7 章中了解到的关于如何将代码分隔到模块和文件中的那样。

当你有一组辅助函数要在多个集成测试文件中使用,并且你尝试按照 “将模块分隔到不同的文件中” 中的步骤操作时,tests 目录文件的不同行为最为明显第 7 章的部分将其提取到公共模块中。 例如,如果我们创建 tests/common.rs 并在其中放置一个名为 setup 的函数,我们可以向 setup 添加一些代码,我们想从多个测试文件中的多个测试函数调用它

文件名: tests/common.rs

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` profile [unoptimized + debuginfo] target(s) in 0.89s Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4) running 1 test test tests::internal ... ok test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s Running tests/common.rs (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 tests/integration_test.rs (target/debug/deps/integration_test-92948b65e88960b4) running 1 test test 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,并为其显示 running 0 tests 不是我们想要的。 我们只是想与其他集成测试文件共享一些代码。 为了避免 common 出现在测试输出中,我们将创建 tests/common/mod.rs 而不是创建 tests/common.rs。 现在项目目录如下所示

├── Cargo.lock ├── Cargo.toml ├── src │   └── lib.rs └── tests ├── common │   └── mod.rs └── integration_test.rs

这是 Rust 也理解的较旧的命名约定,我们在 “备用文件路径”第 7 章的部分中提到过。 以这种方式命名文件告诉 Rust 不要将 common 模块视为集成测试文件。 当我们将 setup 函数代码移动到 tests/common/mod.rs 并删除 tests/common.rs 文件时,测试输出中的该部分将不再出现。 tests 目录的子目录中的文件不会编译为单独的 crate,也不会在测试输出中显示部分。

在创建 tests/common/mod.rs 之后,我们可以从任何集成测试文件中将其用作模块。 以下是在 tests/integration_test.rs 中的 it_adds_two 测试中调用 setup 函数的示例

文件名: tests/integration_test.rs

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

请注意,mod common; 声明与我们在清单 7-21 中演示的模块声明相同。 然后,在测试函数中,我们可以调用 common::setup() 函数。

二进制 crate 的集成测试

如果我们的项目是一个仅包含 src/main.rs 文件且没有 src/lib.rs 文件的二进制 crate,则我们无法在 tests 目录中创建集成测试,也无法使用 use 语句将 src/main.rs 文件中定义的函数引入作用域。 只有库 crate 公开其他 crate 可以使用的函数; 二进制 crate 旨在自行运行。

这是 Rust 项目提供二进制文件的一个原因,该项目具有直接的 src/main.rs 文件,该文件调用位于 src/lib.rs 文件中的逻辑。 使用该结构,集成测试 可以 使用 use 测试库 crate,以使重要的功能可用。 如果重要的功能正常工作,则 src/main.rs 文件中的少量代码也将正常工作,并且不需要测试少量代码。

总结

Rust 的测试功能提供了一种指定代码应如何运行的方法,以确保即使在进行更改时,它也能继续按预期工作。 单元测试分别执行库的不同部分,并且可以测试私有实现细节。 集成测试检查库的许多部分是否协同工作正常,并且它们使用库的公共 API 以与外部代码使用它的相同方式来测试代码。 即使 Rust 的类型系统和所有权规则有助于防止某些类型的错误,但测试对于减少与代码预期行为有关的逻辑错误仍然很重要。

让我们结合你在本章和先前章节中学到的知识来完成一个项目!