上一篇: 10-通用类型、特质和生命周期
Edsger W. Dijkstra 在 1972 年发表的文章《The Humble Programmer》中说:"程序测试可以非常有效地显示错误的存在,但对于显示错误的不存在却无能为力。这并不意味着我们不应该尽可能多地进行测试!
程序的正确性是指我们的代码在多大程度上实现了我们的意图。Rust 的设计高度关注程序的正确性,但正确性是复杂的,而且不容易证明。Rust 的类型系统承担了这一重任的很大一部分,但类型系统并不能囊括一切。因此,Rust 支持编写自动化软件测试。
假设我们编写了一个函数 add_two ,它可以在传给它的任何数字上加上 2。这个函数的签名接受一个整数作为参数,并返回一个整数作为结果。当我们实现并编译该函数时,Rust 会进行所有类型检查和借用检查,例如,确保我们没有向该函数传递 String 值或无效引用。但 Rust 无法检查这个函数是否会按照我们的意图运行,即返回参数加 2,而不是参数加 10 或参数减 50!这就是测试的作用所在。
例如,我们可以编写测试,断言当我们将 3 传递给 add_two 函数时,返回值是 5 。每当我们修改代码时,都可以运行这些测试,以确保现有的正确行为没有改变。
测试是一项复杂的技能:虽然我们无法在一章中涵盖如何写好测试的所有细节,但我们将讨论 Rust 测试设施的机制。我们将讨论编写测试时可用的注解和宏,运行测试时提供的默认行为和选项,以及如何将测试组织成单元测试和集成测试。
测试是 Rust 函数,用于验证非测试代码是否按预期方式运行。测试函数的主体通常执行以下三种操作:
①. 设置所需的数据或状态。
②. 运行要测试的代码。
③. 断言结果符合你的预期。
让我们看看 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 一致。
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn it_works() {
let result = add(2, 2);
assert_eq!(result, 4);
}
}
(清单 11-1:cargo new自动生成的测试模块和函数 )
现在,让我们忽略最上面的两行,把注意力集中在函数上。请注意 #[test] 注释:该属性表示这是一个测试函数,因此测试运行程序知道要将该函数视为测试。我们还可能在 tests 模块中使用非测试函数来帮助设置常用场景或执行常用操作,因此我们始终需要指明哪些函数是测试函数。
示例函数体使用 assert_eq! 宏断言 result (包含 2 和 2 相加的结果)等于 4。这个断言是一个典型测试格式的示例。让我们运行它,看看测试是否通过。
cargo test 命令将运行项目中的所有测试,如清单 11-2 所示。
cargo.exe test
Compiling adder v0.1.0 (E:\rustProj\adder)
Finished test [unoptimized + debuginfo] target(s) in 1.25s
Running unittests src\lib.rs (target\debug\deps\adder-033e40fcb4baf750.exe)
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:运行自动生成的测试的输出结果)
编译并运行测试。我们看到 running 1 test 这一行。下一行显示的是生成的测试函数名称,名为 it_works ,运行该测试的结果是 ok 。总体摘要 test result: ok. 表示所有测试都通过了,读取 1 passed; 0 failed 的部分则是通过或未通过测试的总数。
我们可以将测试标记为忽略,这样它就不会在特定实例中运行;我们将在本章后面的 "除非特别要求,否则忽略某些测试 "一节中介绍这一点。因为我们在这里没有这样做,所以摘要显示 0 ignored 。我们还可以给 cargo test 命令传递一个参数,只运行名称与字符串匹配的测试;这叫做过滤,我们将在 "按名称运行测试子集 "一节中介绍。我们还没有过滤正在运行的测试,因此摘要末尾显示 0 filtered out 。
0 measured 统计量用于衡量性能的基准测试。截至本文撰写时,基准测试仅在夜间 Rust 中提供。如需了解更多信息,请参阅有关基准测试的文档。
从 Doc-tests adder 开始的测试输出的下一部分是任何文档测试的结果。我们还没有任何文档测试,但 Rust 可以编译 API 文档中出现的任何代码示例。该功能有助于保持文档和代码的同步!我们将在第 14 章 "作为测试的文档注释 "部分讨论如何编写文档测试。现在,我们将忽略 Doc-tests 输出。
让我们开始根据自己的需要定制测试。首先将 it_works 函数的名称改为不同的名称,例如 exploration ,就像这样:
#[cfg(test)]
mod tests {
#[test]
fn exploration() {
assert_eq!(2 + 2, 4);
}
}
然后再次运行 cargo test 。现在输出显示的是 exploration 而不是 it_works :
PS E:\rustProj\adder> cargo.exe test
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 方法就是调用 panic! 宏。将新测试作为名为 another 的函数输入,这样你的 src/lib.rs 文件看起来就像清单 11-3。
#[cfg(test)]
mod tests {
#[test]
fn exploration() {
assert_eq!(2 + 2, 4);
}
#[test]
fn another() {
panic!("Make this test fail");
}
}
(清单 11-3:添加第二个会失败的测试,因为我们调用了 panic! 宏)
使用 cargo test 再次运行测试。输出结果应与清单 11-4 类似,其中显示 exploration 测试通过, another 测试失败。
cargo.exe test
Compiling adder v0.1.0 (E:\rustProj\adder)
Finished test [unoptimized + debuginfo] target(s) in 0.38s
Running unittests src\lib.rs (target\debug\deps\adder-033e40fcb4baf750.exe)
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! 以外的一些在测试中有用的宏。
当您要确保测试中的某些条件求值为 true 时,标准库提供的 assert! 宏非常有用。我们给 assert! 宏提供一个布尔值参数。如果值为 true ,则什么也不会发生,测试通过。如果值为 false ,则 assert! 宏调用 panic! 导致测试失败。使用 assert! 宏可以帮助我们检查代码是否按照我们的意图运行。
在第 5 章清单 5-15 中,我们使用了 Rectangle 结构和 can_hold 方法,在清单 11-5 中重复了这些代码。让我们把这些代码放到 src/lib.rs 文件中,然后使用 assert! 宏编写一些测试。
#[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 实例,并断言它可以容纳另一个宽度为 5、高度为 1 的 Rectangle 实例,从而编写了一个测试来练习 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));
}
}
(清单 11-6: can_hold 的测试,检查较大的矩形是否确实能容纳较小的矩形)
请注意,我们在 tests 模块中添加了一行新内容: use super::*; . tests 模块是一个常规模块,遵循第 7 章 "模块树中引用项的路径 "一节中介绍的常规可见性规则。由于 tests 模块是一个内层模块,我们需要将外层模块中的被测代码引入内层模块的作用域。我们在此使用 glob,这样外层模块中定义的任何内容都可以在 tests 模块中使用。
我们将测试命名为 larger_can_hold_smaller ,并创建了我们需要的两个 Rectangle 实例。然后,我们调用 assert! 宏,并将调用 larger.can_hold(&smaller) 的结果传给它。这个表达式应该返回 true ,所以我们的测试应该会通过。让我们来看看!
cargo.exe test
Compiling adder v0.1.0 (E:\rustProj\adder)
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 adder
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
确实通过了!我们再添加一个测试,这次断言一个较小的矩形不能容纳一个较大的矩形:
#[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));
}
#[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.exe test
Compiling adder v0.1.0 (E:\rustProj\adder)
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 adder
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
两个测试通过!现在,让我们看看当我们在代码中引入一个错误时,测试结果会发生什么变化。我们将改变 can_hold 方法的实现,在比较宽度时用小于号代替大于号:
impl Rectangle {
fn can_hold(&self, other: &Rectangle) -> bool {
self.width < other.width && self.height > other.height
}
}
现在运行测试结果如下:
cargo.exe test
Compiling adder v0.1.0 (E:\rustProj\adder)
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! 宏,并使用 == 运算符传递表达式。然而,这是一个非常常见的测试,因此标准库提供了一对宏 assert_eq! 和 assert_ne! ,以更方便地执行该测试。这些宏分别比较两个参数是否相等或不相等。如果断言失败,它们还会打印这两个值,这样就能更容易地看出测试失败的原因;相反, assert! 宏只指出它为 == 表达式得到了 false 值,而没有打印导致 false 值的值。
在清单 11-7 中,我们编写了一个名为 add_two 的函数,在其参数中添加了 2 ,然后使用 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));
}
}
(清单 11-7:使用 assert_eq! 宏测试函数 add_two)
检查一下是否通过!
cargo.exe test
Compiling adder v0.1.0 (E:\rustProj\adder)
Finished test [unoptimized + debuginfo] target(s) in 0.36s
Running unittests src\lib.rs (target\debug\deps\adder-033e40fcb4baf750.exe)
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
我们将 4 作为 assert_eq! 的参数,它等于调用 add_two(2) 的结果。该测试的行是 test tests::it_adds_two ... ok ,而 ok 文本表示测试通过!
让我们在代码中引入一个错误,看看 assert_eq! 失败时的样子。更改 add_two 函数的实现,改为添加 3 :
pub fn add_two(a: i32) -> i32 {
a + 3
}
再次运行测试:
cargo.exe test
Compiling adder v0.1.0 (E:\rustProj\adder)
Finished test [unoptimized + debuginfo] target(s) in 0.38s
Running unittests src\lib.rs (target\debug\deps\adder-033e40fcb4baf750.exe)
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`
我们的测试发现了这个错误! it_adds_two 测试失败,消息告诉我们失败的断言是 assertion failed: `(left == right)` ,以及 left 和 right 的值是多少。这条信息帮助我们开始调试: left 参数是 4 ,但 right 参数(我们原来的 add_two(2) )是 5 。可以想象,当我们进行大量测试时,这条信息会特别有用。
请注意,在某些语言和测试框架中,相等断言函数的参数被称为 expected 和 actual ,我们指定参数的顺序很重要。然而,在 Rust 中,它们被称为 left 和 right ,我们指定期望值和代码生成值的顺序并不重要。我们可以将此测试中的断言写成 assert_eq!(add_two(2), 4) ,这样就会出现与 assertion failed: `(left == right)` 相同的失败消息。
如果我们给出的两个值不相等, assert_ne! 宏将通过,如果相等则失败。当我们不确定数值会是什么,但知道数值肯定不应该是什么时,这个宏最有用。例如,如果我们正在测试一个保证以某种方式改变输入的函数,但改变输入的方式取决于我们运行测试的星期,那么最好的断言可能是函数的输出不等于输入。
在表面上, assert_eq! 和 assert_ne! 宏分别使用运算符 == 和 != 。当断言失败时,这些宏会使用调试格式打印参数,这意味着被比较的值必须实现 PartialEq 和 Debug 特性。所有基元类型和大多数标准库类型都实现了这些特性。对于您自己定义的结构体和枚举,您需要实现 PartialEq 来断言这些类型的相等性。您还需要实现 Debug ,以便在断言失败时打印值。正如第 5 章清单 5-12 所述,由于这两种特质都是可派生的特质,因此通常只需在结构或枚举定义中添加 #[derive(PartialEq, Debug)] 注解即可。
作为 assert! 、 assert_eq! 和 assert_ne! 宏的可选参数,您还可以添加要与失败信息一起打印的自定义信息。在必备参数之后指定的任何参数都将传递给 format! 宏(在第 8 章 "使用 + 操作符或 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"));
}
}
这个程序的要求尚未达成一致,而且我们很确定 Hello 开头的问候语文本会发生变化。我们决定在需求发生变化时不必更新测试,因此我们不检查 greeting 函数返回的值是否完全相等,而只断言输出包含输入参数的文本。
现在,让我们将 greeting 改为排除 name ,在代码中引入一个错误,看看默认的测试失败是什么样的:
pub fn greeting(name: &str) -> String {
String::from("Hello!")
}
运行该测试会产生以下结果:
cargo.exe test
Compiling adder v0.1.0 (E:\rustProj\adder)
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:13: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 函数得到的实际值:
#[test]
fn greeting_contains_name() {
let result = greeting("Carol");
assert!(
result.contains("Carol"),
"Greeting did not contain name, value was `{}`",
result
);
}
现在,当我们运行测试时,会得到一条内容更丰富的错误信息:
cargo.exe test
Compiling adder v0.1.0 (E:\rustProj\adder)
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:13:5:
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`
我们可以在测试输出中看到实际得到的值,这将有助于我们调试发生了什么,而不是我们期望发生什么。
除了检查返回值外,检查代码是否按照我们的预期处理错误条件也很重要。例如,请看我们在第 9 章 清单 9-13 中创建的 Guess 类型。使用 Guess 的其他代码依赖于 Guess 实例只能包含 1 到 100 之间值的保证。我们可以编写一个测试,以确保在尝试创建 Guess 实例时,如果值超出了这个范围,就会出错。
为此,我们在测试函数中添加了属性 should_panic 。如果函数内的代码出现恐慌,则测试通过;如果函数内的代码没有出现恐慌,则测试失败。
清单 11-8 显示了一个测试,用于检查 Guess::new 的错误条件是否在我们预期的情况下发生。
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.exe test
Compiling adder v0.1.0 (E:\rustProj\adder)
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 adder
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
看起来不错!现在,让我们在代码中引入一个错误,删除 new 函数在值大于 100 时会出错的条件:
impl Guess {
pub fn new(value: i32) -> Guess {
if value < 1 {
panic!("Guess value must be between 1 and 100, got {}.", value);
}
Guess { value }
}
}
当我们运行清单 11-8 中的测试时,测试将失败:
cargo.exe test
Compiling adder v0.1.0 (E:\rustProj\adder)
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 函数会根据值太小或太大而出现不同的提示信息。
pub struct Guess {
value: i32,
}
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 < 1 和 else if value > 100 块的主体,在代码中引入一个错误:
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
);
}
这一次,当我们运行 should_panic 测试时,它将失败:
cargo.exe test
Compiling adder v0.1.0 (E:\rustProj\adder)
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`
失败消息表明,该测试确实如我们所料发生了恐慌,但恐慌消息中并不包括预期的字符串 'Guess value must be less than or equal to 100' 。在这种情况下,我们得到的恐慌信息是 Guess value must be greater than or equal to 1, got 200. 现在我们可以开始找出错误所在了!
到目前为止,我们的测试在失败时都会惊慌失措。我们还可以编写使用 Result
#[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(()) ,在测试失败时返回内含 String 的 Err 。
在编写测试时,如果测试返回 Result
不能在使用 Result
现在,您已经知道了编写测试的几种方法,让我们来看看运行测试时会发生什么,并探索 cargo test 中的不同选项。
正如 cargo run 会编译代码并运行生成的二进制文件一样, cargo test 也会在测试模式下编译代码并运行生成的测试二进制文件。 cargo test 生成的二进制文件的默认行为是并行运行所有测试,并捕获测试运行过程中产生的输出,防止显示输出,从而更容易读取与测试结果相关的输出。不过,您可以指定命令行选项来更改默认行为。
有些命令行选项会进入 cargo test ,有些会进入生成的测试二进制文件。要区分这两类参数,可以列出进入 cargo test 的参数,然后是分隔符 -- ,最后是进入测试二进制文件的参数。运行 cargo test --help 会显示可以与 cargo test 一起使用的选项,运行 cargo test -- --help 会显示可以在分隔符之后使用的选项。
当你运行多个测试时,默认情况下它们会使用线程并行运行,这意味着它们会更快地完成运行,你也能更快地得到反馈。由于测试是同时运行的,因此必须确保测试不相互依赖,也不依赖任何共享状态,包括共享环境,如当前工作目录或环境变量。
例如,每个测试都运行一些代码,在磁盘上创建名为 test-output.txt 的文件,并向该文件写入一些数据。然后,每个测试都会读取该文件中的数据,并断言该文件包含一个特定的值,而每个测试中的值都是不同的。由于测试是同时运行的,一个测试可能会在另一个测试写入和读取文件之间的时间内覆盖文件。这样,第二个测试就会失败,不是因为代码不正确,而是因为测试在并行运行时相互干扰。一种解决方案是确保每个测试都写入不同的文件;另一种解决方案是一次运行一个测试。
如果不想并行运行测试,或者想对使用的线程数进行更精细的控制,可以向测试二进制文件发送 --test-threads 标志和希望使用的线程数。请看下面的示例:
$ cargo test -- --test-threads=1
我们将测试线程数设置为 1 ,告诉程序不要使用任何并行方式。使用一个线程运行测试会比并行运行花费更多时间,但如果测试共享状态,则不会相互干扰。
默认情况下,如果测试通过,Rust 的测试库会捕获打印到标准输出的任何内容。例如,如果我们在测试中调用 println! 且测试通过,我们不会在终端中看到 println! 的输出;我们只会看到表明测试通过的一行。如果测试失败,我们将看到打印到标准输出的内容以及失败信息的其余部分。
例如,清单 11-10 中有一个傻函数,它打印参数值并返回 10,还有一个测试通过和一个测试失败。
fn prints_and_returns_10(a: i32) -> i32 {
println!("I got the value {}", a);
10
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn this_test_will_pass() {
let value = prints_and_returns_10(4);
assert_eq!(10, value);
}
#[test]
fn this_test_will_fail() {
let value = prints_and_returns_10(8);
assert_eq!(5, value);
}
}
(清单 11-10:对调用 println!)
当我们使用 cargo test 运行这些测试时,会看到如下输出:
cargo.exe test
Compiling adder v0.1.0 (E:\rustProj\adder)
running 2 tests
test tests::this_test_will_fail ... FAILED
test tests::this_test_will_pass ... ok
failures:
---- tests::this_test_will_fail stdout ----
I got the value 8
thread 'tests::this_test_will_fail' panicked at src\lib.rs:19:9:
assertion `left == right` failed
left: 5
right: 10
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
failures:
tests::this_test_will_fail
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`
请注意,在该输出中,我们看不到 I got the value 4 ,而这正是通过的测试运行时的打印输出。该输出已被捕获。失败测试的输出 I got the value 8 出现在测试摘要输出部分,该部分还显示了测试失败的原因。
如果我们还想查看通过测试的打印值,可以通过 --show-output 告诉 Rust 也显示成功测试的输出。
cargo test -- --show-output
当我们使用 --show-output 标志再次运行清单 11-10 中的测试时,会看到以下输出结果:
cargo.exe test -- --show-output
running 2 tests
test tests::this_test_will_fail ... FAILED
test tests::this_test_will_pass ... ok
successes:
---- tests::this_test_will_pass stdout ----
I got the value 4
successes:
tests::this_test_will_pass
failures:
---- tests::this_test_will_fail stdout ----
I got the value 8
thread 'tests::this_test_will_fail' panicked at src\lib.rs:19:9:
assertion `left == right` failed
left: 5
right: 10
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
failures:
tests::this_test_will_fail
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`
有时,运行一个完整的测试套件需要很长时间。如果你正在处理某一特定领域的代码,你可能只想运行与该代码相关的测试。您可以将 cargo test 作为参数传递给要运行的测试名称,从而选择要运行的测试。
为了演示如何运行测试子集,我们首先要为 add_two 函数创建三个测试,如清单 11-11 所示,然后选择要运行的测试。
pub fn add_two(a: i32) -> i32 {
a + 2
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn add_two_and_two() {
assert_eq!(4, add_two(2));
}
#[test]
fn add_three_and_two() {
assert_eq!(5, add_two(3));
}
#[test]
fn one_hundred() {
assert_eq!(102, add_two(100));
}
}
如果我们运行测试时不传递任何参数,如前所述,所有测试都将并行运行:
cargo.exe test
Compiling adder v0.1.0 (E:\rustProj\adder)
Finished test [unoptimized + debuginfo] target(s) in 0.42s
Running unittests src\lib.rs (target\debug\deps\adder-033e40fcb4baf750.exe)
running 3 tests
test tests::add_three_and_two ... ok
test tests::add_two_and_two ... ok
test tests::one_hundred ... ok
test result: ok. 3 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 test ,以便只运行该测试:
cargo.exe test one_hundred
Finished test [unoptimized + debuginfo] target(s) in 0.00s
Running unittests src\lib.rs (target\debug\deps\adder-033e40fcb4baf750.exe)
running 1 test
test tests::one_hundred ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 2 filtered out; finished in 0.00s
只有名称为 one_hundred 的测试运行了,其他两个测试与名称不符。测试输出在最后显示 2 filtered out ,让我们知道还有更多测试没有运行。
我们不能以这种方式指定多个测试的名称;只有给 cargo test 的第一个值会被使用。但有一种方法可以运行多个测试。
我们可以指定测试名称的一部分,然后运行名称与该值相匹配的任何测试。例如,由于有两个测试的名称包含 add ,我们可以通过运行 cargo test add 来运行这两个测试:
cargo.exe test add
Finished test [unoptimized + debuginfo] target(s) in 0.00s
Running unittests src\lib.rs (target\debug\deps\adder-033e40fcb4baf750.exe)
running 2 tests
test tests::add_three_and_two ... ok
test tests::add_two_and_two ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 1 filtered out; finished in 0.00s
这条命令运行了名称中包含 add 的所有测试,并过滤掉了名为 one_hundred 的测试。还要注意的是,测试所在的模块会成为测试名称的一部分,因此我们可以通过过滤模块名称来运行模块中的所有测试。
有时,执行一些特定测试会非常耗时,因此您可能希望在运行 cargo test 时排除这些测试。您可以使用 ignore 属性注释耗时测试,以排除这些测试,而不是将所有要运行的测试作为参数列出,如图所示:
#[test]
fn it_works() {
assert_eq!(2 + 2, 4);
}
#[test]
#[ignore]
fn expensive_test() {
// code that takes an hour to run
}
在 #[test] 之后,我们在要排除的测试中添加 #[ignore] 行。现在,当我们运行测试时, it_works 会运行,但 expensive_test 不会:
cargo.exe test
Compiling adder v0.1.0 (E:\rustProj\adder)
Finished test [unoptimized + debuginfo] target(s) in 0.41s
Running unittests src\lib.rs (target\debug\deps\adder-033e40fcb4baf750.exe)
running 2 tests
test expensive_test ... ignored
test it_works ... ok
test result: ok. 1 passed; 0 failed; 1 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
expensive_test 函数被列为 ignored 。如果我们只想运行被忽略的测试,可以使用 cargo test -- --ignored :
cargo.exe test -- --ignored
Finished test [unoptimized + debuginfo] target(s) in 0.00s
Running unittests src\lib.rs (target\debug\deps\adder-033e40fcb4baf750.exe)
running 1 test
test expensive_test ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 1 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 test 的结果是快速的。当您需要检查 ignored 测试结果,并且有时间等待结果时,可以运行 cargo test -- --ignored 。如果想运行所有测试,无论它们是否被忽略,可以运行 cargo test -- --include-ignored 。
正如本章开头提到的,测试是一门复杂的学科,不同的人使用不同的术语和组织方式。Rust 社区将测试分为两大类:单元测试和集成测试。单元测试的规模较小,重点更突出,一次只测试一个独立的模块,并且可以测试私有接口。集成测试则完全不依赖于你的库,使用你的代码的方式与其他外部代码相同,只使用公共接口,每次测试可能会测试多个模块。
编写这两种测试对于确保程序库的各个部分都能按照您的期望单独或共同完成测试非常重要。
单元测试的目的是将每个单元的代码与其他代码隔离开进行测试,以便快速确定代码是否按预期运行。你需要将单元测试放在 src 目录下的每个文件中,并对其代码进行测试。惯例是在每个文件中创建一个名为 tests 的模块来包含测试函数,并在模块中注释 cfg(test) 。
测试模块上的 #[cfg(test)] 注解告诉 Rust,只有在运行 cargo test 时才编译和运行测试代码,而不是运行 cargo build 时。当你只想编译库时,这样做可以节省编译时间,而且由于不包含测试,编译后的工件也可以节省空间。你会发现,由于集成测试放在不同的目录下,因此它们不需要 #[cfg(test)] 注释。然而,由于单元测试与代码位于相同的文件中,因此您需要使用 #[cfg(test)] 来指定编译结果中不包含单元测试。
回想一下,在本章第一节生成新的 adder 项目时,Cargo 为我们生成了这段代码:
#[cfg(test)]
mod tests {
#[test]
fn it_works() {
let result = 2 + 2;
assert_eq!(result, 4);
}
}
这段代码就是自动生成的测试模块。属性 cfg 代表配置,它告诉 Rust,只有在特定的配置选项下才应包含以下项目。在本例中,配置选项是 test ,它由 Rust 提供,用于编译和运行测试。通过使用 cfg 属性,Cargo 只会在我们使用 cargo test 运行测试时编译我们的测试代码。除了注释为 #[test] 的函数外,这还包括该模块中可能存在的任何辅助函数。
测试界对是否应该直接测试私有函数存在争议,而其他语言则很难或根本不可能测试私有函数。无论你遵循哪种测试思想,Rust 的隐私规则确实允许你测试私有函数。请看清单 11-12 中的代码,其中包含私有函数 internal_adder 。
pub fn add_two(a: i32) -> i32 {
internal_adder(a, 2)
}
fn internal_adder(a: i32, b: i32) -> i32 {
a + b
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn internal() {
assert_eq!(4, internal_adder(2, 2));
}
}
(清单 11-12:测试私有函数)
注意 internal_adder 函数没有标记为 pub 。测试只是 Rust 代码,而 tests 模块只是另一个模块。正如我们在 "在模块树中引用项的路径 "一节中所讨论的,子模块中的项可以使用其祖先模块中的项。在本测试中,我们通过 use super::* 将 test 模块父模块中的所有项引入作用域,然后测试就可以调用 internal_adder 。如果您认为不应该测试私有函数,Rust 中也没有强制要求您这样做。
在 Rust 中,集成测试完全处于库的外部。它们使用库的方式与其他代码相同,这意味着它们只能调用属于库公共 API 的函数。集成测试的目的是测试库中的许多部分是否能正确地协同工作。单独运行正常的代码单元在集成时可能会出现问题,因此集成代码的测试覆盖范围也很重要。要创建集成测试,首先需要一个test目录。
我们在项目目录的顶层 src 旁边创建了一个测试目录。Cargo 知道要在这个目录中查找集成测试文件。然后,我们可以创建任意数量的测试文件,Cargo 会将每个文件编译为一个独立的 crate。
让我们创建一个集成测试。将清单 11-12 中的代码保留在 src/lib.rs 文件中,创建一个测试目录,并创建一个名为 tests/integration_test.rs 的新文件。目录结构应如下所示:
│ .gitignore
│ Cargo.lock
│ Cargo.toml
│
├─src
│ lib.rs
│
└─tests
integration_test.rs
在 tests/integration_test.rs 文件中输入清单 11-13 中的代码:
use adder;
#[test]
fn it_adds_two() {
assert_eq!(4, adder::add_two(2));
}
(清单 11-13: adder crate 中一个函数的集成测试)
tests 目录中的每个文件都是一个独立的板块,因此我们需要将我们的库引入每个测试板块的范围。因此,我们在代码顶部添加了 use adder ,而单元测试中并不需要。
我们不需要在 tests/integration_test.rs 中用 #[cfg(test)] 注释任何代码。Cargo 会对 tests 目录进行特殊处理,只有在运行 cargo test 时才会编译该目录下的文件。现在运行 cargo test :
cargo.exe test
Compiling adder v0.1.0 (E:\rustProj\adder)
Finished test [unoptimized + debuginfo] target(s) in 0.58s
Running unittests src\lib.rs (target\debug\deps\adder-033e40fcb4baf750.exe)
running 1 test
test tests::internal ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Running tests\integration_test.rs (target\debug\deps\integration_test-2bac9098cab66043.exe)
running 1 test
test 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
输出的三个部分包括单元测试、集成测试和文档测试。请注意,如果某个部分的任何测试失败,后面的部分将不会运行。例如,如果单元测试失败,集成测试和文档测试就不会有任何输出,因为这些测试只有在所有单元测试都通过时才会运行。
单元测试的第一部分与我们一直看到的相同:每项单元测试一行(清单 11-12 中添加的名为 internal 的一行),然后是单元测试的摘要行。
集成测试部分以 Running tests/integration_test.rs 开头。接下来,集成测试中的每个测试功能都有一行,在 Doc-tests adder 部分开始之前还有一行集成测试结果摘要。
每个集成测试文件都有自己的部分,因此如果我们在测试目录中添加更多文件,就会有更多的集成测试部分。
我们仍然可以通过指定测试功能的名称作为 cargo test 的参数来运行特定的集成测试功能。要运行特定集成测试文件中的所有测试,请使用 --test 参数 cargo test ,然后在后面加上文件名:
cargo.exe test --test integration_test
Finished test [unoptimized + debuginfo] target(s) in 0.00s
Running tests\integration_test.rs (target\debug\deps\integration_test-2bac9098cab66043.exe)
running 1 test
test it_adds_two ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
该命令只运行 tests/integration_test.rs 文件中的测试。
当你添加更多集成测试时,你可能想在测试目录中创建更多文件来帮助组织它们;例如,你可以按测试功能对测试函数进行分组。如前所述,测试目录中的每个文件都会编译为独立的 crate,这有助于创建独立的作用域,从而更接近最终用户使用 crate 的方式。不过,这意味着测试目录中的文件与 src 目录中的文件不具有相同的行为,正如第 7 章中关于如何将代码分隔为模块和文件所学到的那样。
当你在多个集成测试文件中使用一组辅助函数,并尝试按照第 7 章 "将模块分离到不同文件 "一节中的步骤将它们提取到一个共同的模块中时,测试目录文件的不同行为就最明显了。例如,如果我们创建了 tests/common.rs,并在其中放置了一个名为 setup 的函数,我们就可以在 setup 中添加一些代码,以便在多个测试文件中通过多个测试函数调用这些代码:
pub fn setup() {
// setup code specific to your library's tests would go here
}
当我们再次运行测试时,我们会在 common.rs 文件的测试输出中看到一个新的部分,尽管该文件不包含任何测试函数,我们也没有在任何地方调用 setup 函数:
cargo.exe test
Compiling adder v0.1.0 (E:\rustProj\adder)
Finished test [unoptimized + debuginfo] target(s) in 0.67s
Running unittests src\lib.rs (target\debug\deps\adder-033e40fcb4baf750.exe)
running 1 test
test tests::internal ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Running tests\common.rs (target\debug\deps\common-0d6058881b615884.exe)
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Running tests\integration_test.rs (target\debug\deps\integration_test-2bac9098cab66043.exe)
running 1 test
test 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
让 common 出现在测试结果中并显示 running 0 tests 并不是我们想要的。我们只是想与其他集成测试文件共享一些代码。
为避免 common 出现在测试输出中,我们将不创建 tests/common.rs,而是创建 tests/common/mod.rs。现在项目目录如下:
│ .gitignore
│ Cargo.lock
│ Cargo.toml
│
├─src
│ lib.rs
│
└─tests
│ integration_test.rs
│
└─common
mod.rs
这是我们在第 7 章 "替代文件路径 "一节中提到的 Rust 也能理解的旧命名约定。以这种方式命名文件可以避免 Rust 将 common 模块视为集成测试文件。当我们将 setup 函数代码移入 tests/common/mod.rs 并删除 tests/common.rs 文件时,测试输出中将不再出现该部分。测试目录下子目录中的文件不会被编译为单独的crate,也不会在测试输出中出现章节。
创建 test/common/mod.rs 后,我们就可以在任何集成测试文件中将其作为模块使用。下面是一个从 test/integration_test.rs 中的 it_adds_two 测试调用 setup 函数的示例:
use adder;
mod common;
#[test]
fn it_adds_two() {
common::setup();
assert_eq!(4, adder::add_two(2));
}
请注意, mod common; 声明与清单 7-21 中演示的模块声明相同。然后在测试函数中,我们可以调用 common::setup() 函数。
如果我们的项目是一个二进制板块,只包含一个 src/main.rs 文件,而没有 src/lib.rs 文件,我们就无法在测试目录下创建集成测试,也无法通过 use 语句将 src/main.rs 文件中定义的函数引入作用域。只有库crate才会暴露其他板块可以使用的函数;二进制crate应独立运行。
这也是提供二进制文件的 Rust 项目有一个直接的 src/main.rs 文件的原因之一,该文件调用 src/lib.rs 文件中的逻辑。利用这种结构,集成测试可以通过 use 测试库板块,使重要功能可用。如果重要功能正常工作,那么 src/main.rs 文件中的少量代码也将正常工作,而这少量代码无需测试。
Rust 的测试功能提供了一种指定代码应如何运行的方法,以确保即使在你进行修改时,代码也能按照你的预期继续运行。单元测试可分别测试库的不同部分,并可测试私有实现细节。集成测试则检查库中的许多部分是否能正确地协同工作,它们使用库的公共应用程序接口,以与外部代码相同的方式测试代码。尽管 Rust 的类型系统和所有权规则有助于防止某些类型的错误,但测试对于减少与代码预期行为有关的逻辑错误仍然很重要。
下一篇: 12-输入/输出项目构建命令行程序