测试组织
正如本章开头提到的,测试是一门复杂的学科,不同的人使用不同的术语和组织方式。Rust 社区从两个主要类别来考虑测试:单元测试和集成测试。单元测试小而更集中,一次隔离地测试一个模块,并且可以测试私有接口。集成测试完全在你的库之外,并且以任何其他外部代码相同的方式使用你的代码,只使用公共接口,并且可能在每个测试中执行多个模块。
编写这两种测试对于确保你的库的各个部分按预期工作至关重要,无论是单独还是协同工作。
单元测试
单元测试的目的是将每个代码单元与其余代码隔离进行测试,以快速查明代码在何处按预期工作以及何处不按预期工作。你将在每个文件的 src 目录中放置单元测试,其中包含它们正在测试的代码。约定是在每个文件中创建一个名为 tests
的模块来包含测试函数,并使用 cfg(test)
注释该模块。
测试模块和 #[cfg(test)]
tests
模块上的 #[cfg(test)]
注释告诉 Rust 仅在你运行 cargo test
时才编译和运行测试代码,而不是在你运行 cargo build
时。当您只想构建库时,这可以节省编译时间,并节省生成的编译工件中的空间,因为不包含测试。你会看到,由于集成测试进入不同的目录,它们不需要 #[cfg(test)]
注释。但是,由于单元测试与代码位于相同的文件中,你将使用 #[cfg(test)]
来指定它们不应包含在编译结果中。
回想一下,当我们在本章的第一部分生成新的 adder
项目时,Cargo 为我们生成了以下代码
文件名:src/lib.rs
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);
}
}
在自动生成的 tests
模块上,属性 cfg
代表 配置,并告诉 Rust 只有在给定特定配置选项时才应包含以下项。在这种情况下,配置选项是 test
,Rust 提供该选项用于编译和运行测试。通过使用 cfg
属性,Cargo 仅在我们使用 cargo test
主动运行测试时才编译我们的测试代码。这包括此模块中的任何辅助函数,以及使用 #[test]
注释的函数。
测试私有函数
关于是否应直接测试私有函数,测试社区存在争议,其他语言使得测试私有函数变得困难或不可能。无论你坚持哪种测试理念,Rust 的隐私规则都允许你测试私有函数。考虑列表 11-12 中的代码,其中包含私有函数 internal_adder
。
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);
}
}
请注意,internal_adder
函数未标记为 pub
。测试只是 Rust 代码,tests
模块只是另一个模块。正如我们在“模块树中引用项的路径”部分讨论的那样,子模块中的项可以使用其祖先模块中的项。在此测试中,我们使用 use super::*
将 tests
模块的父模块的所有项引入作用域,然后测试可以调用 internal_adder
。如果你认为不应测试私有函数,那么 Rust 中没有任何内容会迫使你这样做。
集成测试
在 Rust 中,集成测试完全在你的库之外。它们以任何其他代码相同的方式使用你的库,这意味着它们只能调用属于你的库的公共 API 的函数。它们的目的是测试你的库的许多部分是否协同工作。单独工作正常的代码单元在集成时可能会出现问题,因此集成代码的测试覆盖率也很重要。要创建集成测试,首先需要一个 tests 目录。
tests 目录
我们在项目目录的顶层创建一个 tests 目录,紧挨着 src。Cargo 知道在此目录中查找集成测试文件。然后,我们可以根据需要制作任意数量的测试文件,并且 Cargo 会将每个文件编译为单独的 crate。
让我们创建一个集成测试。将列表 11-12 中的代码仍保留在 src/lib.rs 文件中,创建一个 tests 目录,并创建一个名为 tests/integration_test.rs 的新文件。你的目录结构应如下所示
adder
├── Cargo.lock
├── Cargo.toml
├── src
│ └── lib.rs
└── tests
└── integration_test.rs
将列表 11-13 中的代码输入到 tests/integration_test.rs 文件中。
use adder::add_two;
#[test]
fn it_adds_two() {
let result = add_two(2);
assert_eq!(result, 4);
}
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
输出的三个部分包括单元测试、集成测试和文档测试。请注意,如果某个部分中的任何测试失败,则不会运行以下部分。例如,如果单元测试失败,则不会有集成和文档测试的任何输出,因为这些测试只有在所有单元测试都通过后才会运行。
单元测试的第一部分与我们一直看到的一样:每个单元测试一行(一个名为 internal
的单元测试,我们在列表 11-12 中添加了该单元测试),然后是单元测试的摘要行。
集成测试部分以行 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 的类型系统和所有权规则有助于防止某些类型的错误,但测试仍然对于减少与代码预期行为相关的逻辑错误非常重要。
让我们结合你在本章和之前章节中学到的知识来完成一个项目吧!