测试机构


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


编写这两种测试都很重要,以确保你的库的各个部分分别和一起执行你期望的事情。


单元测试


单元测试的目的是将每个代码单元与代码的其余部分隔离开来进行测试,以快速查明代码在哪里按预期工作,哪些位置没有按预期工作。您将把单元测试放在每个文件的 src 目录中,其中包含它们正在测试的代码。约定是创建一个名为 tests 的模块 以包含测试函数,并使用 cfg(test) 的 Git。


测试模块和 #[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 test 主动运行测试时,Cargo 才会编译我们的测试代码。这包括此模块中可能存在的任何辅助函数,以及用 #[test] 注释的函数。


测试私有函数


测试社区内部存在关于是否应该直接测试私有函数的争论,而其他语言则使测试私有函数变得困难或不可能。无论你遵循哪种测试意识形态,Rust 的隐私规则都允许你测试私有函数。考虑示例 11-12 中带有 private 函数 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 模块只是另一个模块。正如我们在 “引用模块树中项目的路径” 中讨论的那样 部分中,子模块中的项可以使用其祖先模块中的项。在 在这个测试中,我们将 Tests 模块的父项的所有 items 都带入了作用域中,使用 使用 super::*,然后测试可以调用 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 文件中。


文件名: 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,因此我们需要将我们的库引入每个 test crate 的作用域中。因此,我们在代码顶部添加了 use adder::add_two;,这在单元测试中是不需要的。


我们不需要在 tests/integration_test.rs 中使用 #[cfg(test)] 来获取。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),然后是单元测试的 summary 行。


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


每个集成测试文件都有自己的部分,因此如果我们在 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 章中关于如何将代码分成模块和文件所学的那样。


当您有一组要在多个集成测试文件中使用的辅助函数,并尝试按照“将模块分成不同的 Files“部分,将它们提取到一个公共模块中。例如,如果我们创建 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 并显示 0 个测试,这不是我们想要的。我们只是想与其他集成测试文件共享一些代码。为了避免 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 module 视为集成测试文件。当我们将 setup 函数代码移动到 tests/common/mod.rs 并删除 tests/common.rs 文件,则测试输出中的部分将不再显示。测试子目录中的文件 目录不会编译为单独的 crate 或在测试中具有部分 输出。


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


文件名: 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; 声明与 module 声明相同 我们在示例 7-21 中进行了演示。然后,在 test 函数中,我们可以调用 common::setup() 函数。


Binary Crate 的集成测试


如果我们的项目是一个二进制 crate,它只包含一个 src/main.rs 文件,没有 src/lib.rs 文件,我们就不能在 tests 目录,并使用 use 语句将 src/main.rs 文件中定义的函数引入范围。只有 library crate 公开其他 crate 可以使用的函数;binary crate 应该独立运行。


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


总结


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


让我们将您在本章和前几章中学到的知识结合起来,共同完成一个项目吧!