如何编写测试

测试是 Rust 函数,用于验证非测试代码是否按预期方式运行。测试函数的主体通常执行以下三个操作

  1. 设置任何所需的数据或状态。
  2. 运行要测试的代码。
  3. 断言结果符合预期。

让我们看看 Rust 特别为编写执行这些操作的测试提供的功能,包括 test 属性、一些宏和 should_panic 属性。

测试函数的结构

最简单地说,Rust 中的测试是一个用 test 属性注释的函数。属性是关于 Rust 代码片段的元数据;我们在第 5 章中使用结构体时使用的 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

pub fn add(left: usize, right: usize) -> usize {
    left + right
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn it_works() {
        let result = add(2, 2);
        assert_eq!(result, 4);
    }
}

清单 11-1:cargo new 自动生成的测试模块和函数

现在,让我们只关注it_works()函数。注意#[test]注解:这个属性表明这是一个测试函数,所以测试运行器知道将这个函数视为测试。我们也可能在tests模块中包含非测试函数,以帮助设置常见场景或执行常见操作,因此我们始终需要指明哪些函数是测试。

示例函数体使用assert_eq!宏断言result(包含将 2 和 2 相加的结果)等于 4。这个断言是典型测试格式的示例。让我们运行它看看这个测试是否通过。

cargo test命令运行我们项目中的所有测试,如清单 11-2 所示。

$ cargo test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished test [unoptimized + debuginfo] target(s) in 0.57s
     Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4)

running 1 test
test 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

清单 11-2:运行自动生成的测试的输出

Cargo 编译并运行了测试。我们看到一行running 1 test。下一行显示生成的测试函数的名称,称为it_works,以及运行该测试的结果为ok。总体摘要test result: ok.表示所有测试都通过,而显示1 passed; 0 failed的部分则统计了通过或失败的测试数量。

可以将测试标记为忽略,使其在特定情况下不运行;我们将在本章后面的“除非特别要求,否则忽略某些测试”部分中介绍。因为我们在这里没有这样做,所以摘要显示0 ignored。我们还可以向cargo test命令传递一个参数,以仅运行名称与字符串匹配的测试;这称为过滤,我们将在“按名称运行测试子集”部分中介绍。我们也没有过滤正在运行的测试,因此摘要的末尾显示0 filtered out

0 measured统计数据用于衡量性能的基准测试。截至撰写本文时,基准测试仅在 nightly Rust 中可用。请参阅有关基准测试的文档以了解更多信息。

测试输出的下一部分从Doc-tests adder开始,用于显示任何文档测试的结果。我们还没有任何文档测试,但 Rust 可以编译出现在我们的 API 文档中的任何代码示例。此功能有助于保持您的文档和代码同步!我们将在“文档注释作为测试”部分中介绍第 14 章。现在,我们将忽略Doc-tests输出。

让我们开始根据自己的需求自定义测试。首先将it_works函数的名称更改为另一个名称,例如exploration,如下所示

文件名:src/lib.rs

pub fn add(left: usize, right: usize) -> usize {
    left + right
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn exploration() {
        let result = add(2, 2);
        assert_eq!(result, 4);
    }
}

然后再次运行cargo test。输出现在显示exploration而不是it_works

$ cargo test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished test [unoptimized + debuginfo] target(s) in 0.59s
     Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4)

running 1 test
test 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

现在我们将添加另一个测试,但这次我们将创建一个失败的测试!当测试函数中出现恐慌时,测试就会失败。每个测试都在一个新线程中运行,当主线程看到测试线程已死时,该测试将被标记为失败。在第 9 章中,我们讨论了调用panic!宏是最简单的恐慌方式。将新测试作为名为another的函数输入,这样您的src/lib.rs文件看起来像清单 11-3。

文件名:src/lib.rs

pub fn add(left: usize, right: usize) -> usize {
    left + right
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn exploration() {
        let result = add(2, 2);
        assert_eq!(result, 4);
    }
    
    #[test]
    fn another() {
        panic!("Make this test fail");
    }
}

清单 11-3:添加第二个测试,该测试将因调用panic!宏而失败

再次使用cargo test运行测试。输出应该看起来像清单 11-4,它显示我们的exploration测试通过,而another测试失败。

$ cargo test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished test [unoptimized + debuginfo] target(s) in 0.72s
     Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4)

running 2 tests
test tests::another ... FAILED
test tests::exploration ... ok

failures:

---- tests::another stdout ----
thread 'tests::another' panicked at src/lib.rs:10:9:
Make this test fail
note: 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`

清单 11-4:一个测试通过,一个测试失败时的测试结果

test tests::another行显示FAILED,而不是ok。在单个结果和摘要之间出现了两个新部分:第一个部分显示每个测试失败的详细原因。在本例中,我们获得了another失败的详细信息,因为它在src/lib.rs文件的第 10 行panicked at 'Make this test fail'。下一部分仅列出所有失败测试的名称,当测试很多且详细的失败测试输出很多时,这很有用。我们可以使用失败测试的名称来仅运行该测试,以便更轻松地调试它;我们将在“控制测试的运行方式”部分中详细介绍。

摘要行显示在最后:总体而言,我们的测试结果为FAILED。我们有一个测试通过,一个测试失败。

既然您已经了解了不同场景下的测试结果,那么让我们看一下除panic!之外在测试中很有用的其他一些宏。

使用assert!宏检查结果

标准库提供的assert!宏在您要确保测试中某个条件计算结果为true时很有用。我们向assert!宏提供一个计算结果为布尔值的参数。如果该值为true,则不会发生任何事情,测试通过。如果该值为false,则assert!宏调用panic!以导致测试失败。使用assert!宏可以帮助我们检查代码是否按预期工作。

在第 5 章的清单 5-15 中,我们使用了Rectangle结构体和can_hold方法,它们在这里的清单 11-5 中重复出现。让我们将这段代码放在src/lib.rs文件中,然后使用assert!宏为它编写一些测试。

文件名:src/lib.rs

#[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
    }
}

清单 11-5:使用第 5 章中的Rectangle结构体及其can_hold方法

can_hold方法返回一个布尔值,这意味着它是assert!宏的完美用例。在清单 11-6 中,我们编写了一个测试,通过创建一个宽度为 8、高度为 7 的Rectangle实例来练习can_hold方法,并断言它可以容纳另一个宽度为 5、高度为 1 的Rectangle实例。

文件名:src/lib.rs

#[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
    }
}

#[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));
    }
}

清单 11-6:用于can_hold的测试,检查较大的矩形是否确实可以容纳较小的矩形

请注意,我们在tests模块中添加了一行:use super::*;tests模块是一个常规模块,遵循我们在第 7 章的“模块树中引用项的路径”部分中介绍的常规可见性规则。因为tests模块是一个内部模块,所以我们需要将被测试的外部模块中的代码引入内部模块的范围。我们在这里使用通配符,因此我们在外部模块中定义的任何内容都可用于此tests模块。

我们已将测试命名为larger_can_hold_smaller,并创建了所需的两个Rectangle实例。然后,我们调用了assert!宏并向其传递了调用larger.can_hold(&smaller)的结果。此表达式应该返回true,因此我们的测试应该通过。让我们看看吧!

$ cargo test
   Compiling rectangle v0.1.0 (file:///projects/rectangle)
    Finished test [unoptimized + debuginfo] target(s) in 0.66s
     Running unittests src/lib.rs (target/debug/deps/rectangle-6584c4561e48942e)

running 1 test
test 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

它确实通过了!让我们添加另一个测试,这次断言较小的矩形不能容纳较大的矩形

文件名:src/lib.rs

#[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
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn larger_can_hold_smaller() {
        // --snip--
        let larger = Rectangle {
            width: 8,
            height: 7,
        };
        let smaller = Rectangle {
            width: 5,
            height: 1,
        };

        assert!(larger.can_hold(&smaller));
    }

    #[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!宏之前否定该结果。因此,如果can_hold返回false,我们的测试将通过

$ cargo test
   Compiling rectangle v0.1.0 (file:///projects/rectangle)
    Finished test [unoptimized + debuginfo] target(s) in 0.66s
     Running unittests src/lib.rs (target/debug/deps/rectangle-6584c4561e48942e)

running 2 tests
test tests::larger_can_hold_smaller ... ok
test 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

两个测试都通过了!现在让我们看看当我们在代码中引入错误时,测试结果会发生什么。我们将通过在比较宽度时将大于号替换为小于号来更改can_hold方法的实现

#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

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

#[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));
    }

    #[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));
    }
}

现在运行测试会产生以下结果

$ cargo test
   Compiling rectangle v0.1.0 (file:///projects/rectangle)
    Finished test [unoptimized + debuginfo] target(s) in 0.66s
     Running unittests src/lib.rs (target/debug/deps/rectangle-6584c4561e48942e)

running 2 tests
test tests::larger_can_hold_smaller ... FAILED
test tests::smaller_cannot_hold_larger ... ok

failures:

---- tests::larger_can_hold_smaller stdout ----
thread 'tests::larger_can_hold_smaller' panicked at src/lib.rs:28:9:
assertion failed: larger.can_hold(&smaller)
note: 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`

我们的测试发现了错误!因为larger.width为 8,而smaller.width为 5,所以can_hold中对宽度的比较现在返回false:8 不小于 5。

使用assert_eq!assert_ne!宏检查相等性

验证功能的一种常见方法是测试被测试代码的结果与您期望代码返回的值之间的相等性。您可以使用assert!宏并向其传递使用==运算符的表达式来执行此操作。但是,这是一种非常常见的测试,因此标准库提供了一对宏——assert_eq!assert_ne!——以更方便地执行此测试。这两个宏分别比较两个参数的相等性或不相等性。如果断言失败,它们还会打印这两个值,这使得更容易看到测试失败的原因;相反,assert!宏仅指示它为==表达式获得了false值,而不会打印导致false值的那些值。

在清单 11-7 中,我们编写了一个名为add_two的函数,它向其参数添加2,然后我们使用assert_eq!宏测试此函数。

文件名:src/lib.rs

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));
    }
}

清单 11-7:使用assert_eq!宏测试add_two函数

让我们检查一下它是否通过了!

$ cargo test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished test [unoptimized + debuginfo] target(s) in 0.58s
     Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4)

running 1 test
test 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

我们向assert_eq!传递4作为参数,它等于调用add_two(2)的结果。此测试的行是test tests::it_adds_two ... ok,而ok文本表示我们的测试通过了!

让我们在代码中引入一个错误,看看assert_eq!在失败时是什么样子。将add_two函数的实现更改为改为添加3

pub fn add_two(a: i32) -> i32 {
    a + 3
}

#[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.61s
     Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4)

running 1 test
test tests::it_adds_two ... FAILED

failures:

---- tests::it_adds_two stdout ----
thread 'tests::it_adds_two' panicked at src/lib.rs:11:9:
assertion `left == right` failed
  left: 4
 right: 5
note: 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 测试失败,错误信息告诉我们失败的断言是 assertion `left == right` failed 以及 leftright 的值。这条信息帮助我们开始调试: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 特性。所有原始类型和大多数标准库类型都实现了这些特性。对于您自己定义的结构体和枚举,您需要实现 PartialEq 来断言这些类型的相等性。您还需要实现 Debug 以在断言失败时打印值。因为这两个特性都是可推导特性,如第 5 章的清单 5-12 所述,这通常与在您的结构体或枚举定义中添加 #[derive(PartialEq, Debug)] 注解一样简单。请参阅附录 C,“可推导特性,”以了解更多关于这些和其他可推导特性的信息。

添加自定义失败消息

您还可以添加一个自定义消息,作为 assert!assert_eq!assert_ne! 宏的可选参数,与失败消息一起打印。在必需参数之后指定的任何参数都将传递给 format! 宏(在第 8 章的 “使用 + 运算符或 format! 宏进行连接”部分中讨论),因此您可以传递一个包含 {} 占位符和要放入这些占位符的值的格式字符串。自定义消息对于记录断言的含义很有用;当测试失败时,您将更好地了解代码中的问题。

例如,假设我们有一个函数,它通过姓名向人们打招呼,我们想测试我们传递给函数的姓名是否出现在输出中

文件名:src/lib.rs

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"));
    }
}

这个程序的要求还没有达成一致,我们很确定问候语开头的 Hello 文本会发生变化。我们决定不想在需求发生变化时更新测试,因此,我们不会检查返回值是否与 greeting 函数返回的值完全相等,而是只断言输出包含输入参数的文本。

现在,让我们通过更改 greeting 来排除 name,将一个 bug 引入这段代码,看看默认的测试失败是什么样子

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

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn greeting_contains_name() {
        let result = greeting("Carol");
        assert!(result.contains("Carol"));
    }
}

运行此测试会产生以下结果

$ cargo test
   Compiling greeter v0.1.0 (file:///projects/greeter)
    Finished test [unoptimized + debuginfo] target(s) in 0.91s
     Running unittests src/lib.rs (target/debug/deps/greeter-170b942eb5bf5e3a)

running 1 test
test tests::greeting_contains_name ... FAILED

failures:

---- tests::greeting_contains_name stdout ----
thread 'tests::greeting_contains_name' panicked at src/lib.rs:12:9:
assertion failed: result.contains("Carol")
note: 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 函数的值。让我们添加一个自定义失败消息,该消息由一个格式字符串组成,该字符串包含一个占位符,该占位符用我们从 greeting 函数获得的实际值填充

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

#[cfg(test)]
mod tests {
    use super::*;

    #[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 unittests src/lib.rs (target/debug/deps/greeter-170b942eb5bf5e3a)

running 1 test
test tests::greeting_contains_name ... FAILED

failures:

---- tests::greeting_contains_name stdout ----
thread 'tests::greeting_contains_name' panicked at src/lib.rs:12:9:
Greeting did not contain name, value was `Hello!`
note: 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`

我们可以在测试输出中看到我们实际获得的值,这将帮助我们调试发生了什么,而不是我们期望发生什么。

使用 should_panic 检查恐慌

除了检查返回值之外,重要的是检查我们的代码是否按预期处理错误条件。例如,考虑我们在第 9 章清单 9-13 中创建的 Guess 类型。使用 Guess 的其他代码依赖于 Guess 实例只包含 1 到 100 之间的值的保证。我们可以编写一个测试来确保尝试使用该范围之外的值创建 Guess 实例会导致恐慌。

我们通过在测试函数中添加 should_panic 属性来实现这一点。如果函数内部的代码发生恐慌,则测试通过;如果函数内部的代码没有发生恐慌,则测试失败。

清单 11-8 显示了一个测试,该测试检查 Guess::new 的错误条件是否在我们期望的时候发生。

文件名:src/lib.rs

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);
    }
}

清单 11-8:测试某个条件是否会导致 panic!

我们将 #[should_panic] 属性放在 #[test] 属性之后,并在它适用的测试函数之前。让我们看看此测试通过时的结果

$ cargo test
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished test [unoptimized + debuginfo] target(s) in 0.58s
     Running unittests src/lib.rs (target/debug/deps/guessing_game-57d70c3acb738f4d)

running 1 test
test tests::greater_than_100 - should panic ... 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

看起来不错!现在,让我们通过删除 new 函数在值大于 100 时会发生恐慌的条件,在我们的代码中引入一个 bug

pub struct Guess {
    value: i32,
}

// --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 }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    #[should_panic]
    fn greater_than_100() {
        Guess::new(200);
    }
}

当我们运行清单 11-8 中的测试时,它将失败

$ cargo test
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished test [unoptimized + debuginfo] target(s) in 0.62s
     Running unittests src/lib.rs (target/debug/deps/guessing_game-57d70c3acb738f4d)

running 1 test
test tests::greater_than_100 - should panic ... 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`

在这种情况下,我们没有得到一条非常有用的消息,但是当我们查看测试函数时,我们看到它用 #[should_panic] 进行了注释。我们得到的失败意味着测试函数中的代码没有导致恐慌。

使用 should_panic 的测试可能不精确。即使测试由于与我们预期不同的原因而发生恐慌,should_panic 测试也会通过。为了使 should_panic 测试更精确,我们可以向 should_panic 属性添加一个可选的 expected 参数。测试工具将确保失败消息包含提供的文本。例如,考虑清单 11-9 中修改后的 Guess 代码,其中 new 函数根据值过小还是过大而使用不同的消息发生恐慌。

文件名:src/lib.rs

pub struct Guess {
    value: i32,
}

// --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 = "less than or equal to 100")]
    fn greater_than_100() {
        Guess::new(200);
    }
}

清单 11-9:测试 panic!,其中恐慌消息包含指定的子字符串

此测试将通过,因为我们放在 should_panic 属性的 expected 参数中的值是 Guess::new 函数发生恐慌的消息的子字符串。我们可以指定我们期望的整个恐慌消息,在这种情况下将是 Guess value must be less than or equal to 100, got 200. 您选择指定的内容取决于恐慌消息中多少内容是唯一的或动态的,以及您希望测试的精确程度。在这种情况下,恐慌消息的子字符串足以确保测试函数中的代码执行 else if value > 100 部分。

为了看看 should_panic 测试带有 expected 消息时会发生什么,让我们再次通过交换 if value < 1else if value > 100 块的主体,在我们的代码中引入一个 bug

pub struct Guess {
    value: i32,
}

impl Guess {
    pub fn new(value: i32) -> Guess {
        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}."
            );
        }

        Guess { value }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    #[should_panic(expected = "less than or equal to 100")]
    fn greater_than_100() {
        Guess::new(200);
    }
}

这次,当我们运行 should_panic 测试时,它将失败

$ cargo test
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished test [unoptimized + debuginfo] target(s) in 0.66s
     Running unittests src/lib.rs (target/debug/deps/guessing_game-57d70c3acb738f4d)

running 1 test
test tests::greater_than_100 - should panic ... FAILED

failures:

---- tests::greater_than_100 stdout ----
thread 'tests::greater_than_100' panicked at src/lib.rs:13:13:
Guess value must be greater than or equal to 1, got 200.
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
note: panic did not contain expected string
      panic message: `"Guess value must be greater than or equal to 1, got 200."`,
 expected substring: `"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`

失败消息表明此测试确实如我们预期的那样发生了恐慌,但恐慌消息没有包含期望的字符串 less than or equal to 100。在这种情况下,我们得到的恐慌消息是 Guess value must be greater than or equal to 1, got 200. 现在我们可以开始找出我们的 bug 在哪里了!

在测试中使用 Result<T, E>

到目前为止,我们的测试在失败时都会发生恐慌。我们也可以编写使用 Result<T, E> 的测试!这是清单 11-1 中的测试,改写为使用 Result<T, E> 并返回 Err 而不是发生恐慌

#[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(()),当测试失败时返回包含 StringErr

编写测试使其返回 Result<T, E> 使您能够在测试主体中使用问号运算符,这可能是编写测试的一种便捷方式,如果测试中的任何操作返回 Err 变体,则这些测试应该失败。

您不能在使用 Result<T, E> 的测试上使用 #[should_panic] 注解。要断言某个操作返回 Err 变体,不要Result<T, E> 值上使用问号运算符。相反,使用 assert!(value.is_err())

既然您已经了解了几种编写测试的方法,让我们看看当我们运行测试时发生了什么,并探索我们可以与 cargo test 一起使用的不同选项。