测试

在数十年的软件开发过程中,人们发现了一个真理:未经测试的软件很少能正常工作。(许多人会说:“大多数经过测试的软件也不起作用。”但我们都是乐观主义者,对吧?)因此,为了确保您的程序按预期工作,明智的做法是进行测试。

一种简单的方法是编写一个 README 文件,描述您的程序应该做什么。当您准备好发布新版本时,请查看 README 并确保行为仍然符合预期。您可以通过写下程序对错误输入的反应方式来使此练习更加严格。

这里还有一个奇思妙想:在编写代码之前先写 README

自动化测试

现在,这一切都很好,但是手动完成所有这些操作?这可能需要很多时间。同时,许多人开始享受让计算机为他们做事。让我们谈谈如何自动化这些测试。

Rust 有一个内置的测试框架,所以让我们从编写第一个测试开始

fn answer() -> i32 {
  42
}

#[test]
fn check_answer_validity() {
    assert_eq!(answer(), 42);
}

您可以将这段代码片段放在几乎任何文件中,cargo test 将找到并运行它。这里的关键是 #[test] 属性。它允许构建系统发现这些函数并将其作为测试运行,验证它们不会出现恐慌。

现在我们已经看到了如何编写测试,我们仍然需要弄清楚什么要测试。正如您所见,为函数编写断言相当容易。但 CLI 应用程序通常不仅仅是一个函数!更糟糕的是,它通常处理用户输入、读取文件和写入输出。

使您的代码可测试

有两种互补的方法来测试功能:测试构建完整应用程序的小单元,这些单元称为“单元测试”。还有一种方法是从“外部”测试最终应用程序,称为“黑盒测试”或“集成测试”。让我们从第一个开始。

为了弄清楚我们应该测试什么,让我们看看我们的程序功能。主要的是,grrs 应该打印出与给定模式匹配的行。因此,让我们为确切地编写单元测试:我们希望确保我们最重要的逻辑部分正常工作,并且我们希望以不依赖于我们周围的任何设置代码(例如,处理 CLI 参数)的方式进行测试。

回到我们对 grrs第一次实现,我们在 main 函数中添加了这段代码

// ...
for line in content.lines() {
    if line.contains(&args.pattern) {
        println!("{}", line);
    }
}

不幸的是,这不太容易测试。首先,它在 main 函数中,因此我们无法轻松调用它。这可以通过将这段代码移到一个函数中来轻松解决


#![allow(unused)]
fn main() {
fn find_matches(content: &str, pattern: &str) {
    for line in content.lines() {
        if line.contains(pattern) {
            println!("{}", line);
        }
    }
}
}

现在我们可以在测试中调用此函数,并查看它的输出

#[test]
fn find_a_match() {
    find_matches("lorem ipsum\ndolor sit amet", "lorem");
    assert_eq!( // uhhhh

或者……我们可以吗?现在,find_matches 直接打印到 stdout,即终端。我们无法在测试中轻松捕获它!当在实现之后编写测试时,这通常是一个问题:我们编写了一个紧密集成到其使用上下文的函数。

好的,我们如何使它可测试?我们需要以某种方式捕获输出。Rust 的标准库有一些用于处理 I/O(输入/输出)的巧妙抽象,我们将使用其中一个称为 std::io::Write。这是一个 特征,它抽象化了我们可以写入的内容,包括字符串,也包括 stdout

如果这是您第一次在 Rust 的上下文中听到“特征”,那么您将获得一种享受。特征是 Rust 最强大的功能之一。您可以将它们视为 Java 中的接口,或 Haskell 中的类型类(无论您更熟悉哪种)。它们允许您抽象化不同类型可以共享的行为。使用特征的代码可以以非常通用和灵活的方式表达想法。这意味着它也可能难以阅读。不要让这吓倒你:即使是使用 Rust 多年的人也不总是能立即理解泛型代码的作用。在这种情况下,考虑具体用法会有所帮助。例如,在我们的例子中,我们抽象化的行为是“写入它”。实现它的类型的示例包括:终端的标准输出、文件、内存中的缓冲区或 TCP 网络连接。(向下滚动 std::io::Write 的文档,查看“实现者”列表。)

有了这些知识,让我们更改我们的函数以接受第三个参数。它应该是任何实现了 Write 的类型。这样,我们就可以在测试中提供一个简单的字符串并对其进行断言。以下是我们可以编写此版本的 find_matches 的方法

fn find_matches(content: &str, pattern: &str, mut writer: impl std::io::Write) {
    for line in content.lines() {
        if line.contains(pattern) {
            writeln!(writer, "{}", line);
        }
    }
}

新参数是 mut writer,即一个可变的我们称为“writer”的东西。它的类型是 impl std::io::Write,您可以将其读作“任何实现了 Write 特征的类型的占位符”。还要注意我们如何将之前使用的 println!(…) 替换为 writeln!(writer, …)println! 的工作方式与 writeln! 相同,但始终使用标准输出。

现在我们可以测试输出

#[test]
fn find_a_match() {
    let mut result = Vec::new();
    find_matches("lorem ipsum\ndolor sit amet", "lorem", &mut result);
    assert_eq!(result, b"lorem ipsum\n");
}

现在要在我们的应用程序代码中使用它,我们必须通过添加 &mut std::io::stdout() 作为第三个参数来更改对 find_matches 的调用。以下是一个基于我们在前几章中看到的示例,并使用我们提取的 find_matches 函数的 main 函数

fn main() -> Result<()> {
    let args = Cli::parse();
    let content = std::fs::read_to_string(&args.path)
        .with_context(|| format!("could not read file `{}`", args.path.display()))?;

    find_matches(&content, &args.pattern, &mut std::io::stdout());

    Ok(())
}

我们刚刚看到了如何使这段代码易于测试。我们有

  1. 确定了应用程序的核心部分之一,
  2. 将其放入自己的函数中,
  3. 并使其更加灵活。

即使目标是使其可测试,我们最终得到的结果实际上是一段非常惯用且可重用的 Rust 代码。太棒了!

将您的代码拆分为库和二进制目标

我们还可以在这里做一件事。到目前为止,我们将我们编写的所有内容都放在 src/main.rs 文件中。这意味着我们当前的项目会生成一个二进制文件。但我们也可以使我们的代码作为库可用,如下所示

  1. find_matches 函数放入一个新的 src/lib.rs 中。
  2. fn 前面添加一个 pub(因此它是 pub fn find_matches),使其成为库用户可以访问的内容。
  3. src/main.rs 中删除 find_matches
  4. fn main 中,在对 find_matches 的调用之前加上 grrs::,因此现在是 grrs::find_matches(…)。这意味着它使用的是我们刚刚编写的库中的函数!

Rust 处理项目的方式非常灵活,最好尽早考虑将什么放入板条箱的库部分。例如,您可以考虑先为您的应用程序特定逻辑编写一个库,然后像使用任何其他库一样在您的 CLI 中使用它。或者,如果您的项目有多个二进制文件,您可以将通用功能放入该板条箱的库部分。

通过运行它们来测试 CLI 应用程序

到目前为止,我们一直在努力测试应用程序的业务逻辑,事实证明是 find_matches 函数。这非常有价值,并且是构建良好测试代码库的第一步。(通常,这些类型的测试称为“单元测试”。)

但是,我们还有很多代码没有测试:我们编写的所有用于处理外部世界的代码!假设您编写了 main 函数,但意外地留下了硬编码字符串而不是使用用户提供的路径参数。我们也应该为此编写测试!(这种级别的测试通常称为“集成测试”或“系统测试”。)

从本质上讲,我们仍然在编写函数并用 #[test] 对其进行注释。只是我们在这些函数内部做什么的问题。例如,我们希望使用项目的 main 二进制文件,并像运行常规程序一样运行它。我们还将这些测试放入新目录中的新文件中:tests/cli.rs

回顾一下,grrs 是一个小型工具,用于在文件中搜索字符串。我们之前已经测试过我们可以找到匹配项。让我们考虑一下我们可以测试哪些其他功能。

以下是我想到的。

  • 文件不存在时会发生什么?
  • 没有匹配项时的输出是什么?
  • 如果我们忘记了一个(或两个)参数,我们的程序会以错误退出吗?

这些都是有效的测试用例。此外,我们还应该包含一个“正常路径”的测试用例,即我们找到至少一个匹配项并打印出来。

为了使这些类型的测试更容易,我们将使用 assert_cmd crate。它有一堆很棒的助手,允许我们运行我们的主二进制文件并查看它的行为。此外,我们还将添加 predicates crate,它可以帮助我们编写 assert_cmd 可以测试的断言(并且具有很好的错误消息)。我们将这些依赖项添加到 Cargo.toml 中的“开发依赖项”部分,而不是添加到主列表中。它们仅在开发 crate 时需要,而不是在使用它时需要。

[dev-dependencies]
assert_cmd = "2.0.14"
predicates = "3.1.0"

这听起来像是很多设置。尽管如此,让我们直接开始创建我们的 tests/cli.rs 文件

use assert_cmd::prelude::*; // Add methods on commands
use predicates::prelude::*; // Used for writing assertions
use std::process::Command; // Run programs

#[test]
fn file_doesnt_exist() -> Result<(), Box<dyn std::error::Error>> {
    let mut cmd = Command::cargo_bin("grrs")?;

    cmd.arg("foobar").arg("test/file/doesnt/exist");
    cmd.assert()
        .failure()
        .stderr(predicate::str::contains("could not read file"));

    Ok(())
}

您可以使用 cargo test 运行此测试,就像我们上面编写的测试一样。第一次运行可能需要稍微长一点时间,因为 Command::cargo_bin("grrs") 需要编译您的主二进制文件。

生成测试文件

我们刚刚看到的测试只检查了我们的程序在输入文件不存在时是否会写入错误消息。这是一个重要的测试,但可能不是最重要的测试:现在让我们测试一下我们是否真的会打印我们在文件中找到的匹配项!

我们需要有一个我们知道其内容的文件,这样我们才能知道我们的程序应该返回什么,并在我们的代码中检查此期望。一个想法可能是向项目添加一个包含自定义内容的文件,并在我们的测试中使用它。另一种方法是在我们的测试中创建临时文件。在本教程中,我们将看看后一种方法。主要是因为它更灵活,并且在其他情况下也能工作;例如,当您测试更改文件的程序时。

为了创建这些临时文件,我们将使用 assert_fs crate。让我们将其添加到 Cargo.toml 中的 dev-dependencies

assert_fs = "1.1.1"

这是一个新的测试用例(您可以将其写入另一个测试用例的下方),它首先创建一个临时文件(一个“命名”文件,以便我们可以获取其路径),用一些文本填充它,然后运行我们的程序以查看我们是否获得了正确的输出。当 file 超出范围(在函数结束时),实际的临时文件将自动被删除。

use assert_fs::prelude::*;

#[test]
fn find_content_in_file() -> Result<(), Box<dyn std::error::Error>> {
    let file = assert_fs::NamedTempFile::new("sample.txt")?;
    file.write_str("A test\nActual content\nMore content\nAnother test")?;

    let mut cmd = Command::cargo_bin("grrs")?;
    cmd.arg("test").arg(file.path());
    cmd.assert()
        .success()
        .stdout(predicate::str::contains("A test\nAnother test"));

    Ok(())
}

测试什么?

虽然编写集成测试肯定很有趣,但编写它们以及在应用程序的行为发生变化时更新它们也需要一些时间。为了确保您明智地利用时间,您应该问问自己应该测试什么。

通常,为用户可以观察到的所有类型的行为编写集成测试是一个好主意。这意味着您不需要涵盖所有边缘情况:通常,为不同类型提供示例并依靠单元测试来涵盖边缘情况就足够了。

不要将测试重点放在您无法主动控制的事情上也是一个好主意。测试 --help 的确切布局是一个不好的主意,因为它是由您生成的。相反,您可能只想检查某些元素是否存在。

根据您的程序的性质,您还可以尝试添加更多测试技术。例如,如果您已经提取了程序的一部分,并且在尝试提出所有边缘情况时发现自己编写了许多示例用例作为单元测试,那么您应该查看 proptest。如果您有一个程序可以消耗任意文件并解析它们,请尝试编写一个 fuzzer 来查找边缘情况下的错误。