译:关于 Rust 测试的所有知识

原文:https://www.shuttle.rs/blog/2024/03/21/testing-in-rust
作者:Joshua Mo
译者:ChatGPT 4 Turbo

测试是一个重要工具。它可以减少生产错误,并允许我们检查回归。测试的价值很容易看出来——它节省了以后寻找回归的时间(和金钱!)。通过本文,你将全面了解在 Rust 中实现不同类型测试的知识。

Rust 单元测试

设置一个简单的测试

要开始,我们只需要为我们的测试定义一个模块:

#[cfg(test)]
mod tests {
     #[test]
     fn it_works() {
          assert_eq!("hello world!", "hello world!");
     }
}

如果我们将这段代码添加到 src 中的任何 Rust 文件里,然后运行 cargo test,它会编译我们所有的依赖并运行这个测试模块。属性宏确保这个模块只在使用 cargo test 时运行。#[test] 宏将函数声明为一个测试,使其能被 cargo test 运行。

我们可以使用几个宏来帮助我们进行测试:

  • assert!() 试图断言给定的变量等于 true,如果不是则失败(例如,检查 Result 是否是 Ok 变体与 result.is_ok())。可以在最后添加一个变量作为自定义消息。
  • assert_eq!() 如上所用,比较两个项,如果不等于 true 则恐慌。可以在最后添加第三个变量作为自定义消息。
  • debug_assert!() 做的事情与 assert!() 相同,但不在 --release 模式下。可以在最后添加一个变量作为自定义消息。

在异步上下文中测试

有时,你可能需要在异步上下文中测试(例如,使用 tokio 时)。你可以简单地使用以下宏:

#[tokio::test]
async fn my_test() {
    assert_eq!("hello world!", "hello world!", "Somehow this failed? :(");
}

tokio::test 提供了一个方便的抽象,用于在 Tokio 运行时进行测试。宏属性的完整解释可以在这里找到。

开发依赖

如果我们只想添加用于测试的 crates 怎么办?我们可以使用 --dev 标志来做到这一点。例如,如果我们想添加 hyper crate 用于 HTTP 请求测试,我们可以使用以下 shell 片段添加它:

cargo add hyper --dev -F client

现在,当我们运行我们的 crate 时,hyper 只会在我们需要进行测试时构建!

Cargo.toml 中,开发依赖部分将如下所示:

[dev-dependencies]
hyper = { version = "1.2.0", features = ["client"] }

共享常用函数

一些单元测试可能会想要共享常用函数。例如,在一堆测试中设置所需的功能。作为一个起点,这可以通过将函数包含在与你的测试相同的模块中或在不同的模块中来完成。

然而,这可能会变得相当混乱。通常,组织这种情况最好的方法是将共享功能放在一个父 mod 中。如果你的应用测试有相当多的设置,更惯用的方法是拥有一个本地未发布的 crate,其中包含了所有你需要的测试工具。然后将本地 crate 作为开发依赖导入并从那里开始。例如,假设你有一个叫做 test_utilities 的 crate,其中包含以下代码:

// src/lib.rs

async fn do_a_thing() {
    println!("This function does a thing!");
}

假设你有一个项目文件夹,看起来像这样:

├── Cargo.toml
├── src
│   └── lib.rs
├── tests
│   └── integration.rs
└── utilities
    ├── Cargo.toml
    └── src
        └── lib.rs

你会想要确保你在 Cargo.toml 中作为开发依赖导入 utilities

[dev-dependencies]
utilities = { path = "utilities" }

Rust 集成测试

要设置集成测试,在你的项目根目录下创建一个名为 tests 的新文件夹。然后你可以创建一个以任何你喜欢的名称命名的 .rs 文件。我们的示例文件将称为 integration_tests.rs

想象一下,你在一个叫做 returner 的主 Rust 应用中有一个函数叫做 return_one(),它简单地返回一个 i32 类型的数字 1。

// src/lib.rs
fn return_one() -> i32 {
     1
}

然后我们在测试中使用 crate 名称导入该 crate 并引用方法。

// tests/integration_tests.rs
#[test]
fn test_returns_one() {
    assert_eq!(returner::return_one(), 1);
}

然而,tests 文件夹并不仅仅是用于测试!如前所述,我们可以在 tests 中创建一个模块文件夹来添加额外的实用函数。我们将创建一个叫做 common 的文件夹,其中应该有两个文件:一个 mod.rs 文件和一个 postgres.rs 文件。这些文件的内容应该如下。

// tests/common/mod.rs
mod postgres;

// tests/common/postgres.rs
use sqlx::PgPool;

async fn setup() -> PgPool {
    let pool = PgPool::connect("postgres://postgres:postgres@localhost:5432/postgres")
.await.unwrap();

    pool
}

现在我们可以在测试函数中回过头来使用它:

// 导入 common 模块。
mod common;

#[tokio::test]
fn test_add() {
    // 使用 common 代码。
    let db = common::setup();
    let query = sqlx::query("SELECT 'hello world!'")
        .execute(&db)
        .await;

    assert!(query.is_ok())
}

通常建议将类似的测试聚集在一起。这使你能够轻松地找到测试。

Rust 测试库的 crates

pretty_assertions

通过一个简单的宏,pretty_assertions 会让你的断言失败时更易于阅读。

use pretty_assertions::assert_eq;

断言失败现在看起来会像这样:

总之,这是一个简单的 crate,它做了一件事情让你的生活更加轻松。特别是如果你需要解析大型对象或字符串!

tempfile

虽然 tempfile 严格来说不是一个测试依赖库,但它确实使测试变得更加容易!它允许通过 tempfile::TempDirtempfile::tempfile() 分别设置和拆除临时文件目录和文件。然后你可以提取 PathBuf 并将其用在你想要的地方(例如,在需要文件路径的函数中)。

请参阅下方的小示例,摘自文档:

use tempfile::tempdir;
use std::fs::File;
use std::io::{self, Write};

fn run() {
    // 在 `std::env::temp_dir()` 内创建一个目录。
    let dir = tempdir()?;

    let file_path = dir.path().join("my-temporary-note.txt");
    let mut file = File::create(file_path)?;
    writeln!(file, "Brian was here. Briefly.")?;

    // 通过显式关闭 `TempDir`,我们可以检查它是否已经成功删除。
    // 如果我们不显式地关闭它,目录在 `dir` 超出作用域时仍会被删除,
    // 但我们不会知道删除目录是否成功。
    drop(file);
    dir.close()?;
}

这对于需要文件系统处理的情况非常有用(例如,如果您正在下载或生成文件)。更多信息可以在这里找到。

rstest

rstest 是一个旨在通过允许将 fixtures 作为函数参数传入来简化测试的 Rust 库。这里是一个简短的代码片段,显示了您如何轻松地使用它来创建 fixtures,然后在您的 #[rstest] 测试中使用 fixture 函数的名称:

#[fixture]
fn my_fixture() -> i32 {
    1
}

#[rstest]
fn assert_that_one_equals_one(my_fixture: i32) {
    assert_eq!(my_fixture, 1);
}

此外,我们还可以为支持异步的情况添加属性宏。这避免了需要创建额外的函数。

use rstest::*;
use std::future::Future;

#[rstest]
#[case(2, async { 4 })]
#[case(21, async { 42 })]
#[tokio::test]
async fn my_async_test(#[case] a: u32, #[case] #[future] result: u32) {
    assert_eq!(2 * a, result.await);
}

如您所见,第二个变量是一个需要 future 的异步函数。我们可以简单地添加 #[future] 属性以使其支持异步。

proptest

属性测试是 Rust 中和其他方面一样重要的一面。简单地说:它是根据对象或函数的属性进行测试,直到它崩溃(或输入完成)。例如,考虑一个具有非标准 to_string() 实现(来自 std::fmt::Display 特征)的枚举,属性测试库可能会尝试使用非 UTF-8 字符串。这可能会根据您是否考虑到非 UTF-8 字符串而导致测试失败。

proptest 允许我们通过将随机输入与测试函数相匹配来进行属性测试。请查看下面的代码片段:

use proptest::prelude::*;

proptest! {
    #[test]
    fn i64_abs_is_never_negative_above_min(a in 1..1000i32) {
        assert!(a.abs() >= 0);
    }
}

这个简短的代码片段运行了 1000 个测试,并断言绝对值大于或等于 0。类似地,我们也可以使用正则表达式来找出一个函数是否正确覆盖了所有用例:

use proptest::prelude::*;

proptest! {
    #[test]
    fn number_can_be_parsed_from_string(a in "[0-9]{0-8}") {
        assert!(a.parse::<u64>().is_ok());
    }
}

如你所见,输入可以非常强大。想知道更多吗?请查看 proptest 的 mdbook 这里

Rust 的测试工具

Rust 的基础测试工具对于大多数基本用例来说已经足够了。然而,在某些情况下,你绝对会想要使用最新的工具。这里我们将谈论一些你可以用来提高测试效率的工具。

cargo-nextest

cargo-nextest 是一个为 Rust 改进了许多核心测试功能的测试运行器。要安装,你可以使用以下代码片段:

cargo add cargo-nextest

对于常规使用,你可以通过使用 cargo nextest run 来启动给定工作空间中的所有测试。你也可以使用 cargo nextest list 来列出你需要运行的所有测试!

cargo-nextest 默认支持检测缓慢或泄露的测试。通过 --retries 标志还支持重试。

想了解所有你可以用 cargo-nextest 做的事情吗?你可以在这里查看。

testcontainers

testcontainers 是一个自动为你启动本地化基础架构进行测试的工具。该项目完全开源且免费使用,并且拥有一个 Rust SDK。你可以使用这个代码片段安装:

cargo add testcontainers
cargo add testcontainers-modules

启动基础设施被 testcontainers-modules 的特性所控制;例如,如果你想要一个 Postgres 数据库,你需要添加 postgres 特性。

要使用测试容器,你需要设置一个安装命令来为你设置容器:

use sqlx::PgPool;

async fn setup() -> PgPool {
    let docker = Cli::default();
    let node = docker.run(Postgres::default());

    // 准备连接字符串
    let connection_string = &format!(
        "postgres://postgres:postgres@127.0.0.1:{}/postgres",
        node.get_host_port_ipv4(5432)
    );

    let db: PgPool = PgPool::connect(&connection_string).await.unwrap();

    db
}

testcontainers 会自动为你处理设置和拆卸。无需其他操作。

请注意,testcontainers 主要用于隔离测试。如果你想运行一个需要长时间运行的实例的测试,你可能想使用端到端测试。对于跨多个测试的使用,你可以使用 once_cell crate 并将其存储在 once_cell::static::Lazy 中:

static TEST_CONTAINER: Lazy<PgPool> = Lazy::new(|| {

    let docker = Cli::default();
    let node = docker.run(Postgres::default());

    // 准备连接字符串
    let connection_string = &format!(
        "postgres://postgres:postgres@127.0.0.1:{}/postgres",
        node.get_host_port_ipv4(5432)
    );

    let db: PgPool = PgPool::connect(&connection_string).await.unwrap();

    db
});

cargo-fuzz

cargo-fuzz 是一个旨在帮助你对 Rust 项目进行模糊测试的 crate。模糊测试是一种自动化测试方法,它尝试找到导致失败的函数输入,然后找到可以失败的最简测试用例。模糊测试通常也与基于属性的测试结合使用,因为它们相辅相成。虽然 crate 本身不是一个模糊器,实际上它调用了一个模糊器,但它仍然是一个非常有用的工具。它支持 libFuzzer,并且可以扩展以支持其他的。

运行 cargo fuzz init 将创建一个名为 fuzz_targets 的目录,其中包含了一个模糊测试目标的列表。

要在你的应用程序中使用 cargo-fuzz 库,你需要使用宏。下面的示例展示了如何模糊测试 url 包中 Url::parse() 方法。

use url::Url;

fuzz_target!(|data: &[u8]| {
    if let Ok(s) = std::str::from_utf8(data) {
        let _ = url::Url::parse(s);
    }
});

请注意,我们在这里的目标是实现一个最小的实现。一般来说,你希望在尽可能低的级别进行测试,以便能够

要对 fuzz_targets 目录中生成的目标进行模糊测试,可以这样做:

cargo fuzz run <target-name>

总的来说,这是一个非常有用的库。你也可以将其与 proptest 结合使用,以实现非常高效的测试策略。

感兴趣了解更多?你可以在这里找到使用 cargo-fuzzafl(另一个模糊测试库)的教程。

cargo-mutants

最后:变异测试!变异测试允许你通过扫描你的代码,然后更改一些变量,并根据所做的更改期待一些测试失败或成功来测试你的代码覆盖率。有了 cargo-mutants,这可以轻松完成。

要开始使用,你需要安装它:

cargo install cargo-mutants

要使 cargo-mutants 提供有用的结果,你的 Rust 项目必须已经

  1. 使用 cargo build 构建,以及
  2. cargo testcargo nextest 下运行的可靠的非易变测试。

易变的测试可能会使 cargo-mutants 的结果洞察无效。

假设正常情况下测试通过,cargo-mutants 将生成它能生成的每一个变异体(受到过滤器的限制),然后对每一个变异体运行 cargo buildcargo test

每个变异体将产生以下结果之一:

  • caught — 应用此变异体后,一个测试失败了。这是关于测试覆盖率的一个好迹象。
  • missed — 应用这种变异后,没有测试失败,这似乎表明测试覆盖率存在缺口。或者,可能是因为变异体与正确的代码无法区分。你可能希望添加一个更好的测试,或标记该功能应该被跳过。
  • 不可行 — 尝试的变异编译不通过。这对测试覆盖率来说是不确定的,无需采取任何行动,但这表明了 cargo-mutants 有机会生成更好的变异体,或者至少不生成不可行的变异体。
  • 超时 — 变异使测试套件运行了很长时间,直到最终被终止。你可能想调查原因,并可能将该函数标记为跳过。

感兴趣吗?你可以在这里找到他们的文档——它们非常全面!

这里需要注意的主要事项是,Rust 中的变异测试可能有些昂贵。如果你更改了一行代码,你需要重新扫描并重新编译你的程序,这在时间上可能很费用。如果你需要在 CI 中使用 cargo-mutants,这可能会导致 CI 费用非常昂贵。

insta-rs

快照测试(也称为批准测试)也是另一种测试形式,你可以用来帮助为遗留系统构建测试。它通常按以下方式工作:

  • 将你的代码引入测试
  • 向被测试函数投入各种输入
  • 捕获输出

这构建了一个可以用来检查任何回归的“快照网”。

使用 insta,你可以捕获输入,insta 会为你自动管理快照。快照捕获可以像这样简单(摘自文档):

#[test]
fn test_simple() {
    insta::assert_yaml_snapshot!(calculate_value());
}

当实际运行测试时,你可以使用 cargo test 正常运行测试。但是,如果一个测试中有多个快照断言,你可能想使用 cargo insta test 来代替,它会为你处理这个问题。

一旦完成了捕获,你可以使用 cargo insta review 来审查你的快照!快照可以存储在 .snap 文件中,或者存储在你的 Rust 文件中的内联字符串字面量中。

如果你有兴趣了解更多,可以在这里找到 crate 文档。

总结

感谢您的阅读!希望通过这篇文章,您已经对如何在 Rust 环境中进行测试有了更深的理解。有了如此多的测试类型可供选择,确保我们的 Rust 应用程序按预期工作从未如此简单。