trpl-zh-cn/src/ch11-01-writing-tests.md
2024-01-10 18:19:44 +08:00

22 KiB
Raw Blame History

如何编写测试

ch11-01-writing-tests.md
commit 6e2fe7c0f085989cc498cec139e717e2af172cb7

Rust 中的测试函数是用来验证非测试代码是否是按照期望的方式运行的。测试函数体通常执行如下三种操作:

  1. 设置任何所需的数据或状态
  2. 运行需要测试的代码
  3. 断言其结果是我们所期望的

让我们看看 Rust 提供的专门用来编写测试的功能:test 属性、一些宏和 should_panic 属性。

测试函数剖析

作为最简单例子Rust 中的测试就是一个带有 test 属性注解的函数。属性attribute是关于 Rust 代码片段的元数据;第五章中结构体中用到的 derive 属性就是一个例子。为了将一个函数变成测试函数,需要在 fn 行之前加上 #[test]。当使用 cargo test 命令运行测试时Rust 会构建一个测试执行程序用来调用被标注的函数,并报告每一个测试是通过还是失败。

每次使用 Cargo 新建一个库项目时,它会自动为我们生成一个测试模块和一个测试函数。这个模块提供了一个编写测试的模板,为此每次开始新项目时不必去查找测试函数的具体结构和语法了。因为这样当然你也可以额外增加任意多的测试函数以及测试模块!

在实际编写测试代码之前,让我们先通过尝试那些自动生成的测试模版来探索测试是如何工作的。接着,我们会写一些真正的测试,调用我们编写的代码并断言它们的行为的正确性。

让我们创建一个新的库项目 adder,它会将两个数字相加:

$ cargo new adder --lib
     Created library `adder` project
$ cd adder

adder 库中 src/lib.rs 的内容应该看起来如示例 11-1 所示:

文件名src/lib.rs

{{#rustdoc_include ../listings/ch11-writing-automated-tests/listing-11-01/src/lib.rs}}

示例 11-1cargo new 自动生成的测试模块和函数

现在让我们暂时忽略 tests 模块和 #[cfg(test)] 注解并只关注函数本身。注意 fn 行之前的 #[test]:这个属性表明这是一个测试函数,这样测试执行者就知道将其作为测试处理。tests 模块中也可以有非测试的函数来帮助我们建立通用场景或进行常见操作,必须每次都标明哪些函数是测试。

示例函数体通过使用 assert_eq! 宏来断言 2 加 2 等于 4。一个典型的测试的格式就是像这个例子中的断言一样。接下来运行就可以看到测试通过。

cargo test 命令会运行项目中所有的测试,如示例 11-2 所示:

{{#include ../listings/ch11-writing-automated-tests/listing-11-01/output.txt}}

示例 11-2运行自动生成测试的输出

Cargo 编译并运行了测试。可以看到 running 1 test 这一行。下一行显示了生成的测试函数的名称,它是 it_works,以及测试的运行结果,ok。接着可以看到全体测试运行结果的摘要:test result: ok. 意味着所有测试都通过了。1 passed; 0 failed 表示通过或失败的测试数量。

可以将一个测试标记为忽略这样在特定情况下它就不会运行;本章之后的“除非特别指定否则忽略某些测试”部分会介绍它。因为之前我们并没有将任何测试标记为忽略,所以摘要中会显示 0 ignored。我们也没有过滤需要运行的测试,所以摘要中会显示0 filtered out。在下一部分 “控制测试如何运行” 会讨论忽略和过滤测试。

0 measured 统计是针对性能测试的。性能测试benchmark tests在编写本书时仍只能用于 Rust 开发版nightly Rust。请查看 性能测试的文档 了解更多。

测试输出中的以 Doc-tests adder 开头的这一部分是所有文档测试的结果。我们现在并没有任何文档测试,不过 Rust 会编译任何在 API 文档中的代码示例。这个功能帮助我们使文档和代码保持同步!在第十四章的 “文档注释作为测试” 部分会讲到如何编写文档测试。现在我们将忽略 Doc-tests 部分的输出。

让我们开始自定义测试来满足我们的需求。首先给 it_works 函数起个不同的名字,比如 exploration,像这样:

文件名src/lib.rs

{{#rustdoc_include ../listings/ch11-writing-automated-tests/no-listing-01-changing-test-name/src/lib.rs}}

并再次运行 cargo test。现在输出中将出现 exploration 而不是 it_works

{{#include ../listings/ch11-writing-automated-tests/no-listing-01-changing-test-name/output.txt}}

现在让我们增加另一个测试,不过这一次是一个会失败的测试!当测试函数中出现 panic 时测试就失败了。每一个测试都在一个新线程中运行,当主线程发现测试线程异常了,就将对应测试标记为失败。第九章讲到了最简单的造成 panic 的方法:调用 panic! 宏。写入新测试 another 后, src/lib.rs 现在看起来如示例 11-3 所示:

文件名src/lib.rs

{{#rustdoc_include ../listings/ch11-writing-automated-tests/listing-11-03/src/lib.rs:here}}

示例 11-3增加第二个因调用了 panic! 而失败的测试

再次 cargo test 运行测试。输出应该看起来像示例 11-4它表明 exploration 测试通过了而 another 失败了:

{{#include ../listings/ch11-writing-automated-tests/listing-11-03/output.txt}}

示例 11-4一个测试通过和一个测试失败的测试结果

test tests::another 这一行是 FAILED 而不是 ok 了。在单独测试结果和摘要之间多了两个新的部分:第一个部分显示了测试失败的详细原因。在这个例子中,我们看到 another 因为在 src/lib.rs 的第 10 行 panicked at 'Make this test fail' 而失败的详细信息。下一部分列出了所有失败的测试,这在有很多测试和很多失败测试的详细输出时很有帮助。我们可以通过使用失败测试的名称来只运行这个测试,以便调试;下一部分 “控制测试如何运行” 会讲到更多运行测试的方法。

最后是摘要行:总体上讲,测试结果是 FAILED。有一个测试通过和一个测试失败。

现在我们见过不同场景中测试结果是什么样子的了,再来看看除 panic! 之外的一些在测试中有帮助的宏吧。

使用 assert! 宏来检查结果

assert! 宏由标准库提供,在希望确保测试中一些条件为 true 时非常有用。需要向 assert! 宏提供一个求值为布尔值的参数。如果值是 trueassert! 什么也不做,同时测试会通过。如果值为 falseassert! 调用 panic! 宏,这会导致测试失败。assert! 宏帮助我们检查代码是否以期望的方式运行。

回忆一下第五章中,示例 5-15 中有一个 Rectangle 结构体和一个 can_hold 方法,在示例 11-5 中再次使用它们。将它们放进 src/lib.rs 并使用 assert! 宏编写一些测试。

文件名src/lib.rs

{{#rustdoc_include ../listings/ch11-writing-automated-tests/listing-11-05/src/lib.rs:here}}

示例 11-5第五章中 Rectangle 结构体和其 can_hold 方法

can_hold 方法返回一个布尔值,这意味着它完美符合 assert! 宏的使用场景。在示例 11-6 中,让我们编写一个 can_hold 方法的测试来作为练习,这里创建一个长为 8 宽为 7 的 Rectangle 实例,并假设它可以放得下另一个长为 5 宽为 1 的 Rectangle 实例:

文件名src/lib.rs

{{#rustdoc_include ../listings/ch11-writing-automated-tests/listing-11-06/src/lib.rs:here}}

示例 11-6一个 can_hold 的测试,检查一个较大的矩形确实能放得下一个较小的矩形

注意在 tests 模块中新增加了一行:use super::*;tests 是一个普通的模块,它遵循第七章 “路径用于引用模块树中的项” 部分介绍的可见性规则。因为这是一个内部模块,要测试外部模块中的代码,需要将其引入到内部模块的作用域中。这里选择使用 glob 全局导入,以便在 tests 模块中使用所有在外部模块定义的内容。

我们将测试命名为 larger_can_hold_smaller,并创建所需的两个 Rectangle 实例。接着调用 assert! 宏并传递 larger.can_hold(&smaller) 调用的结果作为参数。这个表达式预期会返回 true,所以测试应该通过。让我们拭目以待!

{{#include ../listings/ch11-writing-automated-tests/listing-11-06/output.txt}}

它确实通过了!再来增加另一个测试,这一回断言一个更小的矩形不能放下一个更大的矩形:

文件名src/lib.rs

{{#rustdoc_include ../listings/ch11-writing-automated-tests/no-listing-02-adding-another-rectangle-test/src/lib.rs:here}}

因为这里 can_hold 函数的正确结果是 false ,我们需要将这个结果取反后传递给 assert! 宏。因此 can_hold 返回 false 时测试就会通过:

{{#include ../listings/ch11-writing-automated-tests/no-listing-02-adding-another-rectangle-test/output.txt}}

两个通过的测试!现在让我们看看如果引入一个 bug 的话测试结果会发生什么。将 can_hold 方法中比较长度时本应使用大于号的地方改成小于号:

{{#rustdoc_include ../listings/ch11-writing-automated-tests/no-listing-03-introducing-a-bug/src/lib.rs:here}}

现在运行测试会产生:

{{#include ../listings/ch11-writing-automated-tests/no-listing-03-introducing-a-bug/output.txt}}

我们的测试捕获了 bug因为 larger.length 是 8 而 smaller.length 是 5can_hold 中的长度比较现在因为 8 不小于 5 而返回 false

使用 assert_eq!assert_ne! 宏来测试相等

测试功能的一个常用方法是将需要测试代码的值与期望值做比较,并检查是否相等。可以通过向 assert! 宏传递一个使用 == 运算符的表达式来做到。不过这个操作实在是太常见了,以至于标准库提供了一对宏来更方便的处理这些操作 —— assert_eq!assert_ne!。这两个宏分别比较两个值是相等还是不相等。当断言失败时它们也会打印出这两个值具体是什么,以便于观察测试 为什么 失败,而 assert! 只会打印出它从 == 表达式中得到了 false 值,而不是打印导致 false 的两个值。

示例 11-7 中,让我们编写一个对其参数加二并返回结果的函数 add_two。接着使用 assert_eq! 宏测试这个函数。

文件名src/lib.rs

{{#rustdoc_include ../listings/ch11-writing-automated-tests/listing-11-07/src/lib.rs}}

示例 11-7使用 assert_eq! 宏测试 add_two 函数

测试通过了!

{{#include ../listings/ch11-writing-automated-tests/listing-11-07/output.txt}}

我们传递给 assert_eq! 宏的第一个参数 4 ,它等于调用 add_two(2) 的结果。测试中的这一行 test tests::it_adds_two ... okok 表明测试通过!

在代码中引入一个 bug 来看看使用 assert_eq! 的测试失败是什么样的。修改 add_two 函数的实现使其加 3

{{#rustdoc_include ../listings/ch11-writing-automated-tests/no-listing-04-bug-in-add-two/src/lib.rs:here}}

再次运行测试:

{{#include ../listings/ch11-writing-automated-tests/no-listing-04-bug-in-add-two/output.txt}}

测试捕获到了 bugit_adds_two 测试失败,错误信息告诉我们断言失败了,它告诉我们 assertion failed: `(left == right)` 以及 leftright 的值是什么。这个错误信息有助于我们开始调试:它说 assert_eq!left 参数是 4,而 right 参数,也就是 add_two(2) 的结果,是 5。可以想象当有很多测试在运行时这些信息是多么的有用。

需要注意的是,在一些语言和测试框架中,断言两个值相等的函数的参数被称为 expectedactual,而且指定参数的顺序非常重要。然而在 Rust 中,它们则叫做 leftright,同时指定期望的值和被测试代码产生的值的顺序并不重要。这个测试中的断言也可以写成 assert_eq!(add_two(2), 4),这时失败信息仍同样是 assertion failed: `(left == right)`

assert_ne! 宏在传递给它的两个值不相等时通过,而在相等时失败。在代码按预期运行,我们不确定值 是什么,不过能确定值绝对 不会 是什么的时候,这个宏最有用处。例如,如果一个函数保证会以某种方式改变其输出,不过这种改变方式是由运行测试时是星期几来决定的,这时最好的断言可能就是函数的输出不等于其输入。

assert_eq!assert_ne! 宏在底层分别使用了 ==!=。当断言失败时,这些宏会使用调试格式打印出其参数,这意味着被比较的值必须实现了 PartialEqDebug trait。所有的基本类型和大部分标准库类型都实现了这些 trait。对于自定义的结构体和枚举需要实现 PartialEq 才能断言它们的值是否相等。需要实现 Debug 才能在断言失败时打印它们的值。因为这两个 trait 都是派生 trait如第五章示例 5-12 所提到的,通常可以直接在结构体或枚举上添加 #[derive(PartialEq, Debug)] 注解。附录 C “可派生 trait” 中有更多关于这些和其他派生 trait 的详细信息。

自定义失败信息

你也可以向 assert!assert_eq!assert_ne! 宏传递一个可选的失败信息参数,可以在测试失败时将自定义失败信息一同打印出来。任何在 assert! 的一个必需参数和 assert_eq!assert_ne! 的两个必需参数之后指定的参数都会传递给 format! 宏(在第八章的 “使用 + 运算符或 format! 宏拼接字符串” 部分讨论过),所以可以传递一个包含 {} 占位符的格式字符串和需要放入占位符的值。自定义信息有助于记录断言的意义;当测试失败时就能更好的理解代码出了什么问题。

例如,比如说有一个根据人名进行问候的函数,而我们希望测试将传递给函数的人名显示在输出中:

文件名src/lib.rs

{{#rustdoc_include ../listings/ch11-writing-automated-tests/no-listing-05-greeter/src/lib.rs}}

这个程序的需求还没有被确定,因此问候文本开头的 Hello 文本很可能会改变。然而我们并不想在需求改变时不得不更新测试,所以相比检查 greeting 函数返回的确切值,我们将仅仅断言输出的文本中包含输入参数。

让我们通过将 greeting 改为不包含 name 在代码中引入一个 bug 来测试失败时是怎样的:

{{#rustdoc_include ../listings/ch11-writing-automated-tests/no-listing-06-greeter-with-bug/src/lib.rs:here}}

运行测试会产生:

{{#include ../listings/ch11-writing-automated-tests/no-listing-06-greeter-with-bug/output.txt}}

结果仅仅告诉了我们断言失败了和失败的行号。一个更有用的失败信息应该打印出 greeting 函数的值。让我们为测试函数增加一个自定义失败信息参数:带占位符的格式字符串,以及 greeting 函数的值:

{{#rustdoc_include ../listings/ch11-writing-automated-tests/no-listing-07-custom-failure-message/src/lib.rs:here}}

现在如果再次运行测试,将会看到更有价值的信息:

{{#include ../listings/ch11-writing-automated-tests/no-listing-07-custom-failure-message/output.txt}}

可以在测试输出中看到所取得的确切的值,这会帮助我们理解真正发生了什么,而不是期望发生什么。

使用 should_panic 检查 panic

除了检查返回值之外,检查代码是否按照期望处理错误也是很重要的。例如,考虑第九章示例 9-10 创建的 Guess 类型。其他使用 Guess 的代码都是基于 Guess 实例仅有的值范围在 1 到 100 的前提。可以编写一个测试来确保创建一个超出范围的值的 Guess 实例会 panic。

可以通过对函数增加另一个属性 should_panic 来实现这些。这个属性在函数中的代码 panic 时会通过,而在其中的代码没有 panic 时失败。

示例 11-8 展示了一个检查 Guess::new 是否按照我们的期望出错的测试:

文件名src/lib.rs

{{#rustdoc_include ../listings/ch11-writing-automated-tests/listing-11-08/src/lib.rs}}

示例 11-8测试会造成 panic! 的条件

#[should_panic] 属性位于 #[test] 之后,对应的测试函数之前。让我们看看测试通过时它是什么样子:

{{#include ../listings/ch11-writing-automated-tests/listing-11-08/output.txt}}

看起来不错!现在在代码中引入 bug移除 new 函数在值大于 100 时会 panic 的条件:

{{#rustdoc_include ../listings/ch11-writing-automated-tests/no-listing-08-guess-with-bug/src/lib.rs:here}}

如果运行示例 11-8 的测试,它会失败:

{{#include ../listings/ch11-writing-automated-tests/no-listing-08-guess-with-bug/output.txt}}

这回并没有得到非常有用的信息,不过一旦我们观察测试函数,会发现它标注了 #[should_panic]。这个错误意味着代码中测试函数 Guess::new(200) 并没有产生 panic。

然而 should_panic 测试结果可能会非常含糊不清。should_panic 甚至在一些不是我们期望的原因而导致 panic 时也会通过。为了使 should_panic 测试结果更精确,我们可以给 should_panic 属性增加一个可选的 expected 参数。测试工具会确保错误信息中包含其提供的文本。例如,考虑示例 11-9 中修改过的 Guess,这里 new 函数根据其值是过大还或者过小而提供不同的 panic 信息:

文件名src/lib.rs

{{#rustdoc_include ../listings/ch11-writing-automated-tests/listing-11-09/src/lib.rs:here}}

示例 11-9一个会带有特定错误信息的 panic! 条件的测试

这个测试会通过,因为 should_panic 属性中 expected 参数提供的值是 Guess::new 函数 panic 信息的子串。我们可以指定期望的整个 panic 信息,在这个例子中是 Guess value must be less than or equal to 100, got 200.expected 信息的选择取决于 panic 信息有多独特或动态,和你希望测试有多准确。在这个例子中,错误信息的子字符串足以确保函数在 else if value > 100 的情况下运行。

为了观察带有 expected 信息的 should_panic 测试失败时会发生什么,让我们再次引入一个 bugif value < 1else if value > 100 的代码块对换:

{{#rustdoc_include ../listings/ch11-writing-automated-tests/no-listing-09-guess-with-panic-msg-bug/src/lib.rs:here}}

这一次运行 should_panic 测试,它会失败:

{{#include ../listings/ch11-writing-automated-tests/no-listing-09-guess-with-panic-msg-bug/output.txt}}

失败信息表明测试确实如期望 panic 了,不过 panic 信息中并没有包含 expected 信息 'Guess value must be less than or equal to 100'。而我们得到的 panic 信息是 'Guess value must be greater than or equal to 1, got 200.'。这样就可以开始寻找 bug 在哪了!

Result<T, E> 用于测试

目前为止,我们编写的测试在失败时都会 panic。我们也可以使用 Result<T, E> 编写测试!这是一个延伸自示例 11-1 的测试,使用 Result<T, E> 重写,并在失败时返回 Err 而非 panic

{{#rustdoc_include ../listings/ch11-writing-automated-tests/no-listing-10-result-in-tests/src/lib.rs}}

现在 it_works 函数的返回值类型为 Result<(), String>。在函数体中,不同于调用 assert_eq! 宏,而是在测试通过时返回 Ok(()),在测试失败时返回带有 StringErr

这样编写测试来返回 Result<T, E> 就可以在函数体中使用问号运算符,如此可以方便的编写任何运算符会返回 Err 成员的测试。

不能对这些使用 Result<T, E> 的测试使用 #[should_panic] 注解。为了断言一个操作返回 Err 成员,不要使用对 Result<T, E> 值使用问号表达式(?)。而是使用 assert!(value.is_err())

现在你知道了几种编写测试的方法,让我们看看运行测试时会发生什么,以及可以用于 cargo test 的不同选项。