Rust 书籍实验:单元测试和集成测试

Beginner

This tutorial is from open-source community. Access the source code

简介

欢迎来到测试组织。本实验是《Rust 程序设计语言》的一部分。你可以在 LabEx 中练习你的 Rust 技能。

在本实验中,我们将了解 Rust 社区中测试的两个主要类别:单元测试,它规模较小,专注于独立测试单个模块;以及集成测试,它使用库的公共接口,并且每个测试可能会涉及多个模块。

测试组织

正如本章开头所提到的,测试是一门复杂的学科,不同的人使用不同的术语和组织方式。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

#[cfg(test)]
mod tests {
    #[test]
    fn it_works() {
        let result = 2 + 2;
        assert_eq!(result, 4);
    }
}

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

测试私有函数

在测试社区中,对于是否应该直接测试私有函数存在争议,并且其他语言使得测试私有函数变得困难或不可能。无论你坚持哪种测试理念,Rust 的隐私规则确实允许你测试私有函数。考虑清单 11-12 中的代码,其中有一个私有函数 internal_adder

文件名:src/lib.rs

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));
    }
}

清单 11-12:测试一个私有函数

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

集成测试

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

“tests”目录

我们在项目目录的顶层,即 src 旁边创建一个 tests 目录。Cargo 知道要在这个目录中查找集成测试文件。然后我们可以根据需要创建任意数量的测试文件,Cargo 会将每个文件作为一个独立的包进行编译。

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

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

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

文件名:tests/integration_test.rs

use adder;

#[test]
fn it_adds_two() {
    assert_eq!(4, adder::add_two(2));
}

清单 11-13:对 adder 包中一个函数的集成测试

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

我们不需要用 #[cfg(test)] 注解 tests/integration_test.rs 中的任何代码。Cargo 对 tests 目录有特殊处理,只有当我们运行 cargo test 时,才会编译这个目录中的文件。现在运行 cargo test

[object Object]

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

单元测试的第一部分 [1] 与我们之前看到的相同:每个单元测试一行(我们在清单 11-12 中添加了一个名为 internal 的测试),然后是单元测试的总结行。

集成测试部分以 Running tests/integration_test.rs 这一行开始 [2]。接下来,在该集成测试中的每个测试函数都有一行 [3],并且在 Doc-tests adder 部分开始之前,有一行是集成测试结果的总结行 [4]。

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

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

[object Object]

此命令仅运行 integration_test.rs 文件中的测试。

集成测试中的子模块

随着你添加更多的集成测试,你可能想要在 tests 目录中创建更多文件来帮助组织它们;例如,你可以根据测试的功能将测试函数分组。如前所述,tests 目录中的每个文件都作为一个独立的包进行编译,这对于创建单独的作用域以更紧密地模仿最终用户使用你的包的方式很有用。然而,这意味着 tests 目录中的文件与 src 中的文件行为不同,正如你在第 7 章中学到的关于如何将代码分离到模块和文件中的内容。

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

文件名:tests/common.rs

pub fn setup() {
    // 特定于你的库的测试的设置代码将放在这里
}

当我们再次运行测试时,我们会在测试输出中看到 common.rs 文件的一个新部分,即使这个文件不包含任何测试函数,而且我们也没有从任何地方调用 setup 函数:

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 也理解的旧命名约定,我们在“备用文件路径”中提到过。以这种方式命名文件会告诉 Rust 不要将 common 模块视为集成测试文件。当我们将 setup 函数代码移动到 tests/common/mod.rs 并删除 tests/common.rs 文件时,测试输出中的该部分将不再出现。tests 目录子目录中的文件不会作为单独的包进行编译,也不会在测试输出中有相应部分。

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

文件名:tests/integration_test.rs

use adder;

mod common;

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

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

二进制包的集成测试

如果我们的项目是一个仅包含 src/main.rs 文件且没有 src/lib.rs 文件的二进制包,那么我们无法在 tests 目录中创建集成测试,也无法通过 use 语句将 src/main.rs 文件中定义的函数引入作用域。只有库包才会暴露其他包可以使用的函数;二进制包是用于独立运行的。

这就是为什么提供二进制文件的 Rust 项目通常有一个简单的 src/main.rs 文件,该文件调用 src/lib.rs 文件中的逻辑。采用这种结构,集成测试可以通过 use 来测试库包,从而使重要功能可用。如果重要功能正常工作,那么 src/main.rs 文件中的少量代码也会正常工作,并且这少量代码不需要进行测试。

总结

恭喜你!你已经完成了“测试组织”实验。你可以在 LabEx 中练习更多实验来提升你的技能。