测试

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

一种简单的方法是编写一个 README 文件,描述你的程序应该做什么。当你准备发布新版本时,请仔细阅读 README,并确保行为仍然符合预期。你还可以通过写下你的程序应该如何响应错误输入,使这个过程更加严格。

这里有一个更棒的想法:在编写代码之前编写 README

自动化测试

现在,这一切都很好,但手动完成所有这些工作?这会花费很多时间。与此同时,很多人已经开始喜欢让计算机为他们做事。让我们来谈谈如何自动化这些测试。

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

fn answer() -> i32 {
  42
}

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

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

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

使你的代码可测试

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

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

回到我们的 grrs初步实现,我们在 main 函数中添加了这段代码

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

遗憾的是,这不容易测试。首先,它在主函数中,所以我们不能轻易地调用它。通过将这段代码移到一个函数中可以很容易地解决这个问题


#![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 的抽象。这是一个 trait,它抽象了我们可以写入的事物,其中包括字符串,也包括 stdout

如果这是你第一次在 Rust 中听到 “trait”,你将会惊喜的。Trait 是 Rust 最强大的功能之一。你可以将它们想象成 Java 中的接口,或 Haskell 中的类型类(无论你更熟悉哪一个)。它们允许你抽象出不同类型可以共享的行为。使用 trait 的代码可以用非常通用和灵活的方式表达想法。这意味着它也可能变得难以阅读。不要让这吓到你:即使是使用了 Rust 多年的人,也不总是能立即理解泛型代码的作用。在这种情况下,考虑具体的用途会有所帮助。例如,在我们的例子中,我们抽象的行为是“写入它”。实现(“impl”)它的类型的例子包括:终端的标准输出、文件、内存中的缓冲区或 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 trait 的类型的占位符”。还要注意我们如何将之前使用的 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() 作为第三个参数来更改 main 中对 find_matches 的调用。这是一个 main 函数的示例,该函数基于我们之前章节中看到的内容并使用我们提取的 find_matches 函数

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 处理项目的方式非常灵活,尽早考虑将什么放入你的 crate 的库部分是个好主意。例如,你可以考虑首先为你的应用程序特定逻辑编写一个库,然后在你的 CLI 中使用它,就像任何其他库一样。或者,如果你的项目有多个二进制文件,你可以将通用功能放入该 crate 的库部分。

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

到目前为止,我们一直专注于测试应用程序的业务逻辑,结果发现它就是 find_matches 函数。这非常有价值,是构建良好测试代码库的重要一步。(通常,这类测试被称为“单元测试”。)

不过,我们还有很多代码没有测试:所有与外部世界交互的代码!想象一下,你编写了 main 函数,但意外地使用了硬编码的字符串,而不是用户提供的路径参数。我们也应该为这些代码编写测试!(这种级别的测试通常被称为“集成测试”或“系统测试”。)

从本质上讲,我们仍然在编写函数并使用 #[test] 注解它们。关键在于我们在这些函数内部做什么。例如,我们将要使用项目的主二进制文件,并像运行普通程序一样运行它。我们还将把这些测试放到新目录中的新文件中:tests/cli.rs

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

以下是我想到的一些测试点。

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

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

为了使这些类型的测试更容易进行,我们将使用 assert_cmd crate。它有很多简洁的辅助函数,允许我们运行主二进制文件并查看其行为。此外,我们还将添加 predicates crate,它可以帮助我们编写 assert_cmd 可以针对其进行测试的断言(并且具有很好的错误消息)。我们将这些依赖项添加到 Cargo.toml 中的“dev dependencies”部分,而不是主列表。它们仅在开发 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.tomldev-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 以查找边缘情况下的错误。