写点什么

Rust 从 0 到 1- 自动化测试 - 如何编写测试

用户头像
关注
发布于: 2021 年 06 月 24 日
Rust从0到1-自动化测试-如何编写测试

Edsger W. Dijkstra 在其 1972 年“谦逊的程序员”(The Humble Programmer)一文中说到 “软件测试是证明 bug 存在的有效方法,而却难以证明其不存在。”(Program testing can be a very effective way to show the presence of bugs, but it is hopelessly inadequate for showing their absence.)。但是,我们仍然应该尽可能的对软件进行测试!

我们所编写的程序的正确性就是代码能在多大程度上如我们预期的运行。Rust 从设计之初就非常重视程序的正确性,但是正确性很复杂并且难以证明。Rust 的类型系统在保证正确性上起了很重要的作用,但是它不可能捕获所有的错误。因此,Rust 语言本身也提供了对编写自动化测试的支持。

举例来说,假设我们编写了一个叫做 add_two 的函数,它的功能就是将传递给它的值加 2,它有一个整型参数并返回一个整型值。当实现这个函数以后,在编译时,Rust 会进行类型检查和借用检查,就像前面我们所介绍过的,来保证其正确性。例如,确保我们传递的参数是有效的,不会是个字符串或无效的引用。但是 Rust 无法知道我们的意愿,因此无法检查出这个函数是否会如我们预期的工作,即返回参数加 2 后的值,而不是加 10 或减 50!这就是测试要做的。

我们可以使用断言编写测试,比如,当传递 3 给 add_two 函数时,返回值应该是 5。不管以后我们什么时间修改了代码,都可以运行这个测试来确保函数仍然能按照我们的预期行为正确的运行。在 Rust 我们使用测试函数来验证代码是否按照预期的方式运行。测试函数通常包含以下三个步骤:

  • 准备被测代码所需的数据和上下文状态

  • 运行需要测试的代码

  • 按照我们的预期对结果进行断言

下面让我们看看 Rust 为此提供的用于帮助我们编写测试的功能,包括 test 属性、一些宏和 should_panic 属性。

测试函数剖析

简单来说测试在 Rust 中就是一个带有 test 属性注解的函数。属性(attribute)是一段 Rust 代码片段的元数据,前面结构体章节中用到的 derive 也是一种属性。我们可以通过在函数定义 fn 所在行的上一行加上 #[test],将一个函数变为测试函数。我们可以使用 cargo test 命令运行测试,Rust 会构建一个测试程序用来执行具有 test 属性的函数,并将测试的结果告诉我们。

当使用 Cargo 命令新建一个库项目时,它会自动为我们生成一个包含测试函数的测试模块。这样每次都会提醒我们测试函数的具体结构和语法。在此基础上我们可以任意增加我们需要的测试函数和测试模块!

我们首先会通过自动生成的测试代码模板体验一下测试是如何工作的,然后,我们会写一些真实的测试,测试我们编写的代码并使用断言判断它们行为的正确性。下面,让我们从新建一个库项目 adder 开始:

$ cargo new adder --lib     Created library `adder` project$ cd adder
复制代码

其中 src/lib.rs 的内容应该类似下面这样:

#[cfg(test)]mod tests {    #[test]    fn it_works() {        assert_eq!(2 + 2, 4);    }}
复制代码

我们暂时先略过前两行,先聚焦于测试函数,以了解其是如何工作的。fn it_works() { 上一行的 #[test] 表明其是测试函数,这样 Rust 就知道将其作为测试函数处理。因为我们很多时候也需要在测试模块中编写函数来帮助我们建立测试的上下文或执行一些常用的操作等,所以我们需要使用 #[test] 来标明哪些函数是测试函数。

在函数体中使用 assert_eq! 宏来断言 2 加 2 等于 4,这是一个典型的编写测试的格式。接下来使用 cargo test 命令运行就可以看到测试通过,类似下面这样:

$ cargo test   Compiling adder v0.1.0 (file:///projects/adder)    Finished test [unoptimized + debuginfo] target(s) in 0.57s     Running target/debug/deps/adder-92948b65e88960b4
running 1 testtest tests::it_works ... 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
复制代码

在我们执行命令以后,Cargo 编译并运行了自动生成的测试代码。在 Compiling、Finished 和 Running 之后,可以看到 running 1 test。其后列出了生成的测试函数(目前只有 1 个)的名称 it_works,以及运行结果,ok。接下来是全部测试运行的结果概要:test result: ok. 代表所有测试都通过了。1 passed; 0 failed 是通过和失败的测试数量。因为我们并没有将任何测试标记为忽略,所以是 0 ignored。我们也没有过滤需要运行的测试,所以是 0 filtered out(后面我们会讨论对测试的忽略和过滤)。0 measured 是针对性能测试的,性能测试(benchmark tests)目前仍只能用于 Rust 开发版(nightly Rust),有兴趣的同学可以查看官方文档(unstable-book)。后面 Doc-tests adder 之后的部分是所有文档中包含的测试的结果。目前我们还没有编写任何文档,所以也没有任何测试结果。这个功能可以帮助我们让文档和代码保持同步(这点非常重要,如果文档和代码不同步,那么其价值将不复存在)!我们会在在后面如何编写文档的章节中进行讨论,现在让我们先忽略它。

下面让我们改变测试的名称来看看测试的输出结果有什么变化,我们 it_works 函数的名字改为 exploration:

#[cfg(test)]mod tests {    #[test]    fn exploration() {        assert_eq!(2 + 2, 4);    }}
复制代码

然后再次运行 cargo test:

$ cargo test   Compiling adder v0.1.0 (file:///projects/adder)    Finished test [unoptimized + debuginfo] target(s) in 0.59s     Running target/debug/deps/adder-92948b65e88960b4
running 1 testtest tests::exploration ... 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
复制代码

下面我们来增加另一个测试函数,并且让它测试不通过!当测试函数 panic 时测试结果就是失败。每一个测试都在一个新的线程中运行,当主线程发现测试所在的线程异常终止了,就将其标记为失败,我们简单的使用 panic! 宏来实现:

#[cfg(test)]mod tests {    #[test]    fn exploration() {        assert_eq!(2 + 2, 4);    }
#[test] fn another() { panic!("Make this test fail"); }}
复制代码

再次运行 cargo test ,我们将得到类似下面的结果:

$ cargo test   Compiling adder v0.1.0 (file:///projects/adder)    Finished test [unoptimized + debuginfo] target(s) in 0.72s     Running target/debug/deps/adder-92948b65e88960b4
running 2 teststest tests::another ... FAILEDtest tests::exploration ... ok
failures:
---- tests::another stdout ----thread 'main' panicked at 'Make this test fail', src/lib.rs:10:9note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

failures: tests::another
test result: FAILED. 1 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
error: test failed, to rerun pass '--lib'
复制代码

test tests::another 的结果是 FAILED ,并且在测试结果列表和概要之间多了两部分内容:第一个部分显示了测试失败的原因;第二部分给出了所有失败的测试列表,这个在测试函数比较多的时候比较有帮助,后面我们可以通过指定失败测试的名称来逐个进行调试。最后的概要:总体的测试结果是 FAILED,其中有一个测试通过(1 passed)和一个测试失败(1 failed)。

使用宏 assert! 检查结果

标准库中提供的 assert! 宏在测试中针对条件判断结果是否为 true 的场景非常有用。assert! 宏的参数为布尔型的表达式。如果值是 true,assert! 什么也不做,测试通过;如果值为 false,assert! 将执行 panic! ,测试失败。下面我们对结构体章节中的例子编写测试,我们先来回顾一下:

#[derive(Debug)]struct Rectangle {    width: u32,    height: u32,}
impl Rectangle { fn can_hold(&self, other: &Rectangle) -> bool { self.width > other.width && self.height > other.height }}
复制代码

其中 can_hold 方法返回值为布尔型,这意味着它可以直接作为 assert! 宏的参数使用。下面让我们为 can_hold 方法编写一个测试:

#[cfg(test)]mod tests {    use super::*;
#[test] fn larger_can_hold_smaller() { let larger = Rectangle { width: 8, height: 7, }; let smaller = Rectangle { width: 5, height: 1, };
assert!(larger.can_hold(&smaller)); }}
复制代码

我们编写的测试函数为 larger_can_hold_smaller,并预先创建了两个 Rectangle 实例作为 can_hold 的参数用于测试,这个表达式预期应该返回 true,所以测试应该通过。下面让我们执行测试来看看结果:

$ cargo test   Compiling rectangle v0.1.0 (file:///projects/rectangle)    Finished test [unoptimized + debuginfo] target(s) in 0.66s     Running target/debug/deps/rectangle-6584c4561e48942e
running 1 testtest tests::larger_can_hold_smaller ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Doc-tests rectangle
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
复制代码

和我们预想的一样,测试通过了!现在我们再增加一个测试,反过来测试下,我们预期小的矩形应该不能放下一个比它大的矩形:

#[cfg(test)]mod tests {    use super::*;
#[test] fn larger_can_hold_smaller() { // --snip-- }
#[test] fn smaller_cannot_hold_larger() { let larger = Rectangle { width: 8, height: 7, }; let smaller = Rectangle { width: 5, height: 1, };
assert!(!smaller.can_hold(&larger)); }}
复制代码

在这个场景下 can_hold 函数的预期正确结果应该是返回 false ,因此我们对结果取反作为 assert! 的参数,这样测试就会通过:


$ cargo test Compiling rectangle v0.1.0 (file:///projects/rectangle) Finished test [unoptimized + debuginfo] target(s) in 0.66s Running target/debug/deps/rectangle-6584c4561e48942e
running 2 teststest tests::larger_can_hold_smaller ... oktest tests::smaller_cannot_hold_larger ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Doc-tests rectangle
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
复制代码

两个测试都通过了!下面让我们尝试引入一个 bug ,再来看看测试结果。我们将 can_hold 方法中比较长度的地方由大于号改为小于号:

// --snip--impl Rectangle {    fn can_hold(&self, other: &Rectangle) -> bool {        self.width < other.width && self.height > other.height    }}
复制代码

现在再来运行测试,会产生类似下面的结果:

$ cargo test   Compiling rectangle v0.1.0 (file:///projects/rectangle)    Finished test [unoptimized + debuginfo] target(s) in 0.66s     Running target/debug/deps/rectangle-6584c4561e48942e
running 2 teststest tests::larger_can_hold_smaller ... FAILEDtest tests::smaller_cannot_hold_larger ... ok
failures:
---- tests::larger_can_hold_smaller stdout ----thread 'main' panicked at 'assertion failed: larger.can_hold(&smaller)', src/lib.rs:28:9note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

failures: tests::larger_can_hold_smaller
test result: FAILED. 1 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
error: test failed, to rerun pass '--lib'
复制代码

我们编写的测试发现了 bug!

宏 assert_eq! 和 assert_ne! 

通常在编写测试时需要将测试代码的返回值与期望值做比较,并检查是否相等,我们可以在 assert! 宏中使用比较运算符来实现,譬如 == 。由于这种场景太常见了,因此标准库提供了相应的宏帮助我们处理 —— assert_eq! 和 assert_ne!。它们分别用于比较两个值是相等还是不相等,并且在失败时他们还会打印出用于比较的两个具体值,以便于我们找到失败的原因,这个时使用  assert! 所不具备的。下面让我们编写对输入参数加 2 后返回的函数 add_two,并使用 assert_eq! 宏进行测试:

pub fn add_two(a: i32) -> i32 {    a + 2}
#[cfg(test)]mod tests { use super::*;
#[test] fn it_adds_two() { assert_eq!(4, add_two(2)); }}
复制代码

让我执行测试来看看:

$ cargo test   Compiling adder v0.1.0 (file:///projects/adder)    Finished test [unoptimized + debuginfo] target(s) in 0.58s     Running target/debug/deps/adder-92948b65e88960b4
running 1 testtest tests::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
复制代码

测试通过!下面我们在代码中引入一个 bug 来看看测试失败是怎样的。我们先修改 add_two :

pub fn add_two(a: i32) -> i32 {    a + 3}
复制代码

再次运行测试:

$ cargo test   Compiling adder v0.1.0 (file:///projects/adder)    Finished test [unoptimized + debuginfo] target(s) in 0.61s     Running target/debug/deps/adder-92948b65e88960b4
running 1 testtest tests::it_adds_two ... FAILED
failures:
---- tests::it_adds_two stdout ----thread 'main' panicked at 'assertion failed: `(left == right)` left: `4`, right: `5`', src/lib.rs:11:9note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

failures: tests::it_adds_two
test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
error: test failed, to rerun pass '--lib'
复制代码

测试发现了代码的 bug!it_adds_two 测试失败,并打印出用于比较的两个值,即 left:'4' ,right: '5'(add_two(2) 的结果),这个信息有助于我们排查问题。需要注意的是,值的顺序并不重要,我们也可以写成 assert_eq!(add_two(2), 4),这时打印出的信息将会是 left:'5' ,right: '4'。

宏 assert_ne! 和 assert_eq! 相反,即在两个值不相等时通过,而在相等时失败。这个宏主要用于在我们不确定输出的值是什么,但是可以确定不是什么的场景。譬如,如果一个函数会改变其输入参数(保证不与输入值相同),不过其输出值和日期相关,这种场景使用  assert_ne!  断言输出不等于其输入可能更为合适。

宏 assert_eq! 和 assert_ne! 在底层分别使用了 == 和 !=,并且当比较结果为 false 时,会使用 debug formatting 打印出用于比较的值,这就需要用于比较的值必需实现 PartialEq 和 Debug trait。所有的基本类型和大部分标准库类型都实现了它们。而对于我们自定义的结构体或枚举,需要实现这两个 trait 才能进行比较和打印。同时,因为它们都是 derivable  traits,通常可以直接在结构体或枚举上添加 #[derive(PartialEq, Debug)] 注解。

自定义错误信息

我们还可以向宏 assert!、assert_eq! 和 assert_ne! 传递一个失败信息参数,用于在测试失败时将自定义的失败信息打印出来。在 assert! 的一个和 assert_eq! 和 assert_ne! 的两个必选参数之后的参数都会传递给 format! 宏,因此我们可以参照 format! 宏的使用方式,传递一个包含 {} 的格式字符串和其对应的值。自定义信息应该用于记录具有意义,能在测试失败时帮助我们更好的判断代码问题的信息。参考下面的例子,我们先来看看未加自定义信息的情况:

pub fn greeting(name: &str) -> String {    format!("Hello {}!", name)}
#[cfg(test)]mod tests { use super::*;
#[test] fn greeting_contains_name() { let result = greeting("Carol"); assert!(result.contains("Carol")); }}
复制代码

我们通过将 greeting 的返回值改为不包含参数 name 来引入一个 bug ,用于展示测试失败的情况:

pub fn greeting(name: &str) -> String {    String::from("Hello!")}
复制代码

运行测试,结果类似下面这样:

$ cargo test   Compiling greeter v0.1.0 (file:///projects/greeter)    Finished test [unoptimized + debuginfo] target(s) in 0.91s     Running target/debug/deps/greeter-170b942eb5bf5e3a
running 1 testtest tests::greeting_contains_name ... FAILED
failures:
---- tests::greeting_contains_name stdout ----thread 'main' panicked at 'assertion failed: result.contains(\"Carol\")', src/lib.rs:12:9note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

failures: tests::greeting_contains_name
test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
error: test failed, to rerun pass '--lib'
复制代码

结果告诉了我们测试失败了及失败的行号。单这时候我们可能很想知道 greeting 函数的返回值,用于排查问题。下面让我们为测试函数增加一个自定义失败信息参数:

#[test]fn greeting_contains_name() {    let result = greeting("Carol");    assert!(        result.contains("Carol"),        "Greeting did not contain name, value was `{}`",        result    );}
复制代码

让我们再次运行测试,来看看会得到什么信息:

$ cargo test   Compiling greeter v0.1.0 (file:///projects/greeter)    Finished test [unoptimized + debuginfo] target(s) in 0.93s     Running target/debug/deps/greeter-170b942eb5bf5e3a
running 1 testtest tests::greeting_contains_name ... FAILED
failures:
---- tests::greeting_contains_name stdout ----thread 'main' panicked at 'Greeting did not contain name, value was `Hello!`', src/lib.rs:12:9note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

failures: tests::greeting_contains_name
test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
error: test failed, to rerun pass '--lib'
复制代码

现在我们可以在结果信息中看到 greeting 函数确切的返回值,这将帮助我们对问题进行排查。

使用 should_panic

除了检查代码是否返回预期结果之外,检查代码是否按照预期处理错误也是很重要的一方面。以我们在“错误处理”章节中使用的例子为例, 其他使用 Guess 的代码都是基于 Guess 允许的值的范围在 1 到 100 为前提,基于此,我们可以编写一个测试来检验在值超出范围时 Guess 实例是否会 panic。我们可以通过为函数增加属性 should_panic 来实现。如果函数中的代码发生 panic 测试将通过,反之则失败。参考下面的例子:

pub struct Guess {    value: i32,}
impl Guess { pub fn new(value: i32) -> Guess { if value < 1 || value > 100 { panic!("Guess value must be between 1 and 100, got {}.", value); }
Guess { value } }}
#[cfg(test)]mod tests { use super::*;
#[test] #[should_panic] fn greater_than_100() { Guess::new(200); }}
复制代码

让我们看看执行测试的结果:

$ cargo test   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)    Finished test [unoptimized + debuginfo] target(s) in 0.58s     Running target/debug/deps/guessing_game-57d70c3acb738f4d
running 1 testtest tests::greater_than_100 ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Doc-tests guessing_game
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
复制代码

测试通过了!下面我们在代码中引入 bug,让 Guess::new() 在值大于 100 时不会 panic:

// --snip--impl Guess {    pub fn new(value: i32) -> Guess {        if value < 1 {            panic!("Guess value must be between 1 and 100, got {}.", value);        }
Guess { value } }}
复制代码

再次运行测试:

$ cargo test   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)    Finished test [unoptimized + debuginfo] target(s) in 0.62s     Running target/debug/deps/guessing_game-57d70c3acb738f4d
running 1 testtest tests::greater_than_100 ... FAILED
failures:
---- tests::greater_than_100 stdout ----note: test did not panic as expected
failures: tests::greater_than_100
test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
error: test failed, to rerun pass '--lib'
复制代码

如我们预期的一样,测试失败了,即 Guess::new(200) 并没有 panic。然而 should_panic 所提供的错误信息很有限,它只是告诉我们代码并没有按照预期的发生 panic,甚至在一些不是我们预期的原因而导致 panic 时也会通过。为了让 should_panic 的结果更符合我们的预期,我们可以利用 expected 参数,它会确保错误信息中需要包含相应的文本。参考下面的例子:

// --snip--impl Guess {    pub fn new(value: i32) -> Guess {        if value < 1 {            panic!(                "Guess value must be greater than or equal to 1, got {}.",                value            );        } else if value > 100 {            panic!(                "Guess value must be less than or equal to 100, got {}.",                value            );        }
Guess { value } }}
#[cfg(test)]mod tests { use super::*;
#[test] #[should_panic(expected = "Guess value must be less than or equal to 100")] fn greater_than_100() { Guess::new(200); }}
复制代码

因为 expected 参数的值于 Guess::new 函数 panic 信息相匹配,测试会通过。我们还可以将信息指定的更为精确,在上面的例子中就是 Guess value must be less than or equal to 100, got 200. ,这取决于被测代码中我们预期的 panic 是否能够被区分出来以及我们希望测试的粒度有多细,所以被测代码的可测性也很重要。让我们再次引入一个 bug,来看看 expected 的信息于 panic 不匹配时会怎样:

if value < 1 {    panic!(        "Guess value must be less than or equal to 100, got {}.",        value    );} else if value > 100 {    panic!(        "Guess value must be greater than or equal to 1, got {}.",        value    );}
复制代码

再次运行测试:

$ cargo test   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)    Finished test [unoptimized + debuginfo] target(s) in 0.66s     Running target/debug/deps/guessing_game-57d70c3acb738f4d
running 1 testtest tests::greater_than_100 ... FAILED
failures:
---- tests::greater_than_100 stdout ----thread 'main' panicked at 'Guess value must be greater than or equal to 1, got 200.', src/lib.rs:13:13note: run with `RUST_BACKTRACE=1` environment variable to display a backtracenote: panic did not contain expected string panic message: `"Guess value must be greater than or equal to 1, got 200."`, expected substring: `"Guess value must be less than or equal to 100"`
failures: tests::greater_than_100
test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
error: test failed, to rerun pass '--lib'
复制代码

测试失败了,失败信息告诉我们 panic 确实发生了,但是错误信息中并没有包含 expected 指定的信息 Guess value must be less than or equal to 100。实际产生的错误信息是 Guess value must be greater than or equal to 1, got 200.。

使用 Result<T, E> 

目前我们编写的测试在失败时就会 panic。还有另外一种方式,即使用 Result<T, E> 编写测试!参考下面的例子:

#[cfg(test)]mod tests {    #[test]    fn it_works() -> Result<(), String> {        if 2 + 2 == 4 {            Ok(())        } else {            Err(String::from("two plus two does not equal four"))        }    }}
复制代码

上面的例子中 it_works 函数的返回值的类型为 Result<(), String>。不同于宏 assert_eq!,我们测试通过时返回 Ok(()),在测试失败时返回 Err(String::from("two plus two does not equal four"))。这样我们就可以方便的对返回 Result<T, E>类型的函数进行测试了,譬如利用 ? 操作符(前面错误错误处理章节讨论过)。

另外需要注意的时,使用 Result<T, E> 的测试无法使用 #[should_panic] 注解。

发布于: 2021 年 06 月 24 日阅读数: 9
用户头像

关注

公众号"山 顽石"欢迎大家关注;) 2021.03.23 加入

IT老兵,关注Rust、Java、前端、架构、大数据、AI、算法等;平时喜欢美食、旅游、宠物、健身、篮球、乒乓球等。希望能和大家交流分享。

评论

发布
暂无评论
Rust从0到1-自动化测试-如何编写测试