Rust Book 编译版 11 · 编写自动化测试:单元测试、集成测试与 cargo test
编译自 The Rust Programming Language(2024 edition),在原文基础上做了删减与补充。© The Rust Project Developers,依据 MIT OR Apache-2.0 许可编译转载。
开始之前
Rust 的类型系统和借用检查器能替你拦下一大类 bug------悬垂引用、数据竞争、类型不匹配。但它们管的是内存与类型层面的正确,管不了逻辑层面的正确:一个 add_two 函数,编译器能保证你不会往里传 String,却保证不了它算的是「加 2」而不是「加 10」。1972 年 Dijkstra 在《谦逊的程序员》(The Humble Programmer)里已经把话说透了:「程序测试可以非常有效地证明 bug 的存在,但对于证明 bug 不存在,它就无能为力了。」证明不了「不存在」,不妨碍我们尽己所能多写测试------程序的正确性(correctness)最终要靠它兜底。
更省心的是,测试在很多语言里是个「选配」题:先挑框架(JUnit、pytest、Google Test......),再配构建钩子。Rust 把这两步都免了------测试是语言与 Cargo 的一等公民:#[test] 属性标记测试函数,cargo test 一条命令编译并运行全部测试,零框架选型、零构建配置。本章讲清这套测试设施的运作机制:编写测试的注解与宏、运行测试的默认行为与可用选项,以及如何把测试组织成单元测试(unit test)与集成测试(integration test)。
前置知识 :第 10 章「泛型、trait 与生命周期」(本章会用到 PartialEq、Debug、Result<T, E> 与 trait 派生)。
你会学到:
- 用
#[test]属性与assert!/assert_eq!/assert_ne!宏编写测试,并为断言附加自定义失败消息 - 用
should_panic(配合expected参数)测试错误路径,以及编写返回Result<T, E>、可用?的测试 - 控制
cargo test的行为:并行与串行、显示被捕获的输出、按名称过滤、用#[ignore]跳过耗时测试 - 把测试组织成单元测试(
src内#[cfg(test)]模块,可测私有函数)与集成测试(tests/目录,只测公共 API)
如何编写测试
测试是验证非测试代码是否按预期运行的 Rust 函数,函数体通常做三件事:
- 准备所需的数据或状态;
- 运行待测代码;
- 断言(assertion)结果符合预期。
Rust 为此专门提供了设施:test 属性、一组宏,以及 should_panic 属性。
测试函数的结构
最简单的情况下,Rust 中的测试就是一个标注了 test 属性的函数。属性是关于代码片段的元数据------第 5 章随结构体用过的 derive 属性就是一例。在 fn 前一行加上 #[test],函数就成了测试函数;cargo test 会构建一个测试执行器二进制,运行所有被标注的函数并报告通过与否。
用 Cargo 新建库项目时,它会自动生成一个内含测试函数的测试模块,省得每次翻结构和语法;测试函数与测试模块都可以随意添加。我们先在这个模板上做实验摸清细节,之后再写真正调用自己代码的测试。
创建库项目 adder,用于把两个数相加:
bash
cargo new adder --lib
Created library `adder` project
cd adder
src/lib.rs 的内容如示例 11-1。
rust
pub fn add(left: u64, right: u64) -> u64 {
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 自动生成的代码
文件开头是一个 add 示例函数,让我们有东西可测。先看 it_works:#[test] 注解告诉测试执行器把它当作测试------tests 模块里也可能有搭建通用场景的非测试辅助函数,所以哪些函数是测试需要显式指明。函数体用 assert_eq! 断言 add(2, 2) 的结果 result 等于 4,这是典型测试的基本形式。运行 cargo test 看看通过的样子(示例 11-2):
bash
cargo test
Compiling adder v0.1.0 (file:///projects/adder)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.57s
Running unittests src/lib.rs (target/debug/deps/adder-01ad14159ff659ab)
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:运行自动生成的测试的输出
解读一下摘要行:test result: ok. 表示全部通过,1 passed; 0 failed 汇总通过/失败数量。其余统计项对应本章后面的功能------0 ignored:可用 ignore 属性让特定测试默认不跑(见「除非特别指定,否则忽略测试」一节);0 filtered out:可给 cargo test 传名称参数做过滤 (filtering,见「按名称运行部分测试」一节);0 measured 属于基准测试(benchmark test),目前仅在 nightly Rust 中可用,详见相关文档。Doc-tests adder 起的部分是文档测试(documentation test)的结果------Rust 能编译 API 文档里出现的代码示例,以此保持文档与代码同步,第 14 章详谈,眼下忽略即可。
开始定制。先把 it_works 改名为 exploration:
文件名:src/lib.rs
rust
pub fn add(left: u64, right: u64) -> u64 {
left + right
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn exploration() {
let result = add(2, 2);
assert_eq!(result, 4);
}
}
再跑 cargo test,输出里显示的就是 exploration:
bash
cargo test
Compiling adder v0.1.0 (file:///projects/adder)
Finished `test` profile [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
再添加一个故意失败的测试。测试函数发生 panic 时测试即失败------每个测试跑在独立线程中,主线程发现测试线程挂掉就将其标记为失败;而最简单的 panic 方式就是调用 panic! 宏(第 9 章)。新测试写成名为 another 的函数,src/lib.rs 如示例 11-3:
rust
pub fn add(left: u64, right: u64) -> u64 {
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 失败。
bash
cargo test
Compiling adder v0.1.0 (file:///projects/adder)
Finished `test` profile [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' (6019162) panicked at src/lib.rs:17: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 显示的不是 ok 而是 FAILED。单个结果与摘要之间多了两个小节:第一个给出每个失败测试的详细原因------这里能看到 tests::another 在 src/lib.rs 第 17 行带着 Make this test fail 的消息 panic;第二个只列出失败测试的名字,测试多、失败输出多时很有用------拿着名字就能只跑那一个测试来调试(「控制测试的运行方式」一节详述)。最后的摘要行给出总体 FAILED:一过一败。
测试结果的样子见过了,接下来看 panic! 之外真正常用的测试宏。
使用 assert! 检查结果
assert! 宏适合确保某个条件求值为 true:传入一个布尔值,true 则无事发生、测试通过;false 则调用 panic! 使测试失败。
第 5 章示例 5-15 用过 Rectangle 结构体和它的 can_hold 方法,示例 11-5 再次给出。放进 src/lib.rs ,用 assert! 为它写测试:
rust
#[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 的矩形,断言它能容纳宽 5 高 1 的矩形:
rust
#[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 章介绍过的常规可见性规则;作为内层模块,需要把外层待测代码引入作用域------glob 导入让外层模块的所有内容在 tests 里都可用。运行测试:
bash
cargo test
Compiling rectangle v0.1.0 (file:///projects/rectangle)
Finished `test` profile [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
rust
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn 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,测试就通过:
bash
cargo test
Compiling rectangle v0.1.0 (file:///projects/rectangle)
Finished `test` profile [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
两个测试都通过。现在往代码里引入一个 bug------把 can_hold 比较宽度用的 > 换成 <:
rust
// --省略--
impl Rectangle {
fn can_hold(&self, other: &Rectangle) -> bool {
self.width < other.width && self.height > other.height
}
}
运行测试:
bash
cargo test
Compiling rectangle v0.1.0 (file:///projects/rectangle)
Finished `test` profile [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' (6020788) 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`
测试抓住了 bug:larger.width 是 8、smaller.width 是 5,宽度的比较现在返回 false------8 并不小于 5。
使用 assert_eq! 和 assert_ne! 测试相等性
验证功能最常见的姿势,是比对被测代码的结果与期望值。用 assert! 配 == 也能做,但这种测试太常见,标准库专门提供了 assert_eq! 和 assert_ne!:分别比较两值相等/不等,失败时还会把两个值打印出来------相比之下 assert! 只会告诉你 == 表达式得到了 false,不打印导致它的值。
示例 11-7 编写 add_two 函数给参数加 2,用 assert_eq! 测试:
rust
pub fn add_two(a: u64) -> u64 {
a + 2
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn it_adds_two() {
let result = add_two(2);
assert_eq!(result, 4);
}
}
示例 11-7:使用 assert_eq! 宏测试 add_two 函数
验证通过:
bash
cargo test
Compiling adder v0.1.0 (file:///projects/adder)
Finished `test` profile [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
引入一个 bug 看失败输出------把 add_two 改成加 3:
rust
pub fn add_two(a: u64) -> u64 {
a + 3
}
再运行:
bash
cargo test
Compiling adder v0.1.0 (file:///projects/adder)
Finished `test` profile [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' (6020955) panicked at src/lib.rs:12:9:
assertion `left == right` failed
left: 5
right: 4
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:失败消息给出断言 left == right 及两个值------left(add_two(2) 的结果)是 5,right 是 4。批量跑测试时这条消息尤其有用。注意与某些语言框架的 expected/actual 不同,Rust 的参数叫 left 和 right,顺序无所谓:写成 assert_eq!(4, result),失败时得到同样的消息。
assert_ne! 在两值不等时通过、相等时失败,适合「不确定值会是什么、但确定它不该是什么」的场景------比如函数一定会改变输入、但具体怎么变取决于星期几,那就断言输出不等于输入。
底层上,这两个宏分别使用 == 和 != 运算符,失败时以调试格式打印参数,因此被比较的值必须实现 PartialEq 和 Debug 这两个 trait。基本类型和标准库多数类型都已实现;自定义的结构体和枚举,通常加一行 #[derive(PartialEq, Debug)] 即可(第 5 章示例 5-12 提过,详见附录 C「可派生的 trait」)。
添加自定义失败消息
assert!、assert_eq!、assert_ne! 都支持可选的自定义失败消息:必选参数之后指定的任何参数都会传给 format! 宏(第 8 章讨论过),所以可以写带 {} 占位符的格式化字符串。消息用来说明断言的含义,测试失败时能更快定位问题。
示例:一个按名字问候的函数,测试名字是否出现在输出里:
文件名:src/lib.rs
rust
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 大概率会随需求变化,为了不跟着改测试,这里不比对完整相等,只断言输出包含输入的名字。引入 bug------让 greeting 不包含 name------看默认的失败输出:
rust
pub fn greeting(name: &str) -> String {
String::from("Hello!")
}
运行测试:
bash
cargo test
Compiling greeter v0.1.0 (file:///projects/greeter)
Finished `test` profile [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' (6021143) 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 实际返回的值打出来。加一条自定义失败消息,占位符用实际返回值填充:
rust
#[test]
fn greeting_contains_name() {
let result = greeting("Carol");
assert!(
result.contains("Carol"),
"Greeting did not contain name, value was `{result}`"
);
}
再运行,错误消息的信息量就上来了:
bash
cargo test
Compiling greeter v0.1.0 (file:///projects/greeter)
Finished `test` profile [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' (6021333) 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 检查 panic
除了返回值,错误路径同样要测。回想第 9 章示例 9-13 的 Guess 类型:使用它的代码依赖「实例只含 1 到 100 之间的值」这一保证,可以写个测试确保越界值会 panic。做法是给测试函数添加 should_panic 属性:函数体内的代码 panic 则测试通过,没 panic 反而失败。示例 11-8 检查 Guess::new 的错误条件是否如期发生:
rust
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] 之后、测试函数之前。通过时的输出:
bash
cargo test
Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
Finished `test` profile [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
不错。现在引入 bug------删掉「值大于 100 时 panic」的条件:
rust
// --省略--
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 的测试,它失败:
bash
cargo test
Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
Finished `test` profile [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 at src/lib.rs:21:8
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]------失败意味着函数体没有按预期 panic。
should_panic 的问题是不精确:哪怕是因为与我们预期不同的原因 panic,测试也会通过。更精确的做法是给 should_panic 添加可选的 expected 参数,测试工具会确保 panic 消息中包含给定文本。示例 11-9 修改了 Guess::new,根据值太小还是太大以不同的消息 panic:
rust
// --省略--
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 消息中包含指定子串的 panic!
测试通过:expected 参数的值是 Guess::new panic 消息的一个子串。也可以指定完整消息(本例即 Guess value must be less than or equal to 100, got 200),指定多少取决于消息里有多少独有或动态的内容、你想要多精确;本例一个子串就足以确保代码走的是 else if value > 100 分支。再看失败情形------把 if value < 1 和 else if value > 100 两个块的主体互换:
rust
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 测试,它失败:
bash
cargo test
Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
Finished `test` profile [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' (6021675) panicked at src/lib.rs:12: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`
失败消息表明:测试确实 panic 了,但消息里没有期望的子串 less than or equal to 100,实际是 Guess value must be greater than or equal to 1, got 200------顺藤摸瓜就能排查 bug。
编译注:
should_panic的脆弱性值得专门强调:它只回答「panic 了吗」,不回答「为什么 panic」------测试函数里任何一行代码引发的 panic(包括断言宏自己失败时的 panic)都会让测试「通过」。所以实际项目里要么始终配上expected子串,要么改用返回Result<T, E>的测试加assert!(value.is_err()),别裸用。
在测试中使用 Result<T, E>
到目前为止,测试失败都是 panic。测试也可以返回 Result<T, E>------示例 11-1 的那个测试改写后,失败时返回 Err 而不是 panic:
rust
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn it_works() -> Result<(), String> {
let result = add(2, 2);
if result == 4 {
Ok(())
} else {
Err(String::from("two plus two does not equal four"))
}
}
}
it_works 的返回类型变成 Result<(), String>:通过时返回 Ok(()),失败时返回装着 String 的 Err,不再调用 assert_eq!。返回 Result<T, E> 的最大好处是测试体里能用问号运算符(?)------「任何操作返回 Err 变体即算失败」的测试写起来很顺手。注意两点:#[should_panic] 不能用在返回 Result<T, E> 的测试上;要断言某个操作返回 Err 变体,请不要 对 Result<T, E> 值用 ?,而是用 assert!(value.is_err())。
写测试的手段齐了,接下来看运行测试时都发生了什么,以及 cargo test 的各种选项。
控制测试的运行方式
cargo test 与 cargo run 同理:在测试模式下编译代码,再运行生成的测试二进制。默认行为是并行运行所有测试、捕获(不显示)测试过程中的输出,让结果输出更易读;两者都能用命令行选项改变。
选项分两类:传给 cargo test 的,和传给测试二进制的------区分办法是先写 cargo test 的参数,再写分隔符 --,然后写测试二进制的参数。cargo test --help 显示前者,cargo test -- --help 显示后者(也记载于 The `rustc` Book 的「Tests」一节)。
并行或连续运行测试
多个测试默认借助线程并行运行,更快出结果、更早拿到反馈。同时跑意味着测试必须互不依赖、不依赖任何共享状态------包括工作目录、环境变量这类共享环境。反例:每个测试都写读同一个 test-output.txt、断言各自不同的值,并行时一个测试可能在另一个写入与读取的间隙把文件覆盖掉,后者随之失败------不是代码有错,是测试互相干扰。解法要么各写各的文件,要么一次只跑一个。
不想并行或想控制线程数,把 --test-threads 标志和线程数传给测试二进制:
bash
cargo test -- --test-threads=1
线程数设为 1 即完全串行。比并行慢,但共享状态的测试不会互相干扰。
显示函数的输出
默认情况下,测试通过时 Rust 的测试库会捕获所有打印到标准输出的内容:测试里调了 println! 而测试通过,终端看不到输出,只有表明通过的那一行;测试失败时,输出会随失败信息一起显示。示例 11-10 的函数打印参数的值并返回 10,配一个通过的和一个失败的测试:
rust
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!(value, 10);
}
#[test]
fn this_test_will_fail() {
let value = prints_and_returns_10(8);
assert_eq!(value, 5);
}
}
示例 11-10:测试一个会调用 println! 的函数
用 cargo test 运行:
bash
cargo test
Compiling silly-function v0.1.0 (file:///projects/silly-function)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.58s
Running unittests src/lib.rs (target/debug/deps/silly_function-160869f38cff9166)
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' (6019863) panicked at src/lib.rs:19:9:
assertion `left == right` failed
left: 10
right: 5
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 同时显示成功测试的输出:
bash
cargo test -- --show-output
带上 --show-output 再跑示例 11-10 的测试:
bash
cargo test -- --show-output
Compiling silly-function v0.1.0 (file:///projects/silly-function)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.60s
Running unittests src/lib.rs (target/debug/deps/silly_function-160869f38cff9166)
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' (6022313) panicked at src/lib.rs:19:9:
assertion `left == right` failed
left: 10
right: 5
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`
编译注:
--show-output是「跑完后把成功测试的输出集中补在汇总里」;调试测试时更常用的是老牌选项--nocapture(cargo test -- --nocapture),它直接关闭输出捕获,println!实时打印、与测试结果交错出现------「printf 调试」式的测试用它最直观。
按名称运行部分测试
整套测试有时跑很久;正在开发某块代码时,可能只想跑与之相关的测试。把想运行的测试名作为参数传给 cargo test 即可。先给 add_two 建三个测试(示例 11-11),再挑着跑:
rust
pub fn add_two(a: u64) -> u64 {
a + 2
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn add_two_and_two() {
let result = add_two(2);
assert_eq!(result, 4);
}
#[test]
fn add_three_and_two() {
let result = add_two(3);
assert_eq!(result, 5);
}
#[test]
fn one_hundred() {
let result = add_two(100);
assert_eq!(result, 102);
}
}
示例 11-11:三个名称各不相同的测试
不带参数运行,所有测试并行:
bash
cargo test
Compiling adder v0.1.0 (file:///projects/adder)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.62s
Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4)
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,只运行那个测试:
bash
cargo test one_hundred
Compiling adder v0.1.0 (file:///projects/adder)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.69s
Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4)
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 就都跑:
bash
cargo test add
Compiling adder v0.1.0 (file:///projects/adder)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.61s
Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4)
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 属性:
文件名:src/lib.rs
rust
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn it_works() {
let result = add(2, 2);
assert_eq!(result, 4);
}
#[test]
#[ignore]
fn expensive_test() {
// 需要运行一小时的代码
}
}
#[test] 之后加一行 #[ignore]。运行测试时,it_works 会跑而 expensive_test 不会:
bash
cargo test
Compiling adder v0.1.0 (file:///projects/adder)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.60s
Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4)
running 2 tests
test tests::expensive_test ... ignored
test tests::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:
bash
cargo test -- --ignored
Compiling adder v0.1.0 (file:///projects/adder)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.61s
Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4)
running 1 test
test tests::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 尽快返回;等适合检查这些被忽略测试、也有时间等待时,改跑 cargo test -- --ignored。想不论是否忽略都跑全部测试,用 cargo test -- --include-ignored。
测试的组织结构
测试的术语和组织方式因人而异,Rust 社区约定俗成地分两大类:单元测试 (unit tests)小而专注,一次隔离测一个模块,可以测私有接口;集成测试(integration tests)完全位于你的库之外,像其他任何外部代码那样使用你的库,只用公共接口,一次可能涉及多个模块。两类都写,才能确保库的各部分无论单独工作还是协同工作,都符合预期。两类测试在文件系统上的落位一目了然:
text
单元测试:与被测代码同文件、同 crate,靠 #[cfg(test)] 挡在发布构建之外
adder/src/lib.rs
├── pub fn add_two() # 被测代码,函数可以不加 pub
└── #[cfg(test)] mod tests # 内层模块,use super::* 连私有项都够得着
└── #[test] fn internal()
集成测试:tests/ 下每个 *.rs 文件 = 一个独立 crate,只能像外部用户一样走公共 API
adder/
├── src/lib.rs # 库本体
└── tests/
└── integration_test.rs # 顶部 use adder::add_two; 显式引入库
单元测试
单元测试的目的是把每个代码单元与其余代码隔离开来测试,快速定位哪里符合预期、哪里不符合。位置:src 目录中、与被测代码同一个文件;惯例是每个文件里建一个 tests 模块容纳测试函数,并标注 cfg(test)。
tests 模块与 #[cfg(test)]
tests 模块上的 #[cfg(test)] 告诉 Rust:只在 cargo test 时编译并运行测试代码,cargo build 时不会------既省编译时间,又因为测试不打包进产物而省空间。后面会看到集成测试放在另一个目录、不需要这个标注;单元测试与代码同文件,才需要用 #[cfg(test)] 把测试排除出编译结果。
还记得本章开头生成 adder 项目时 Cargo 给的这段代码吗:
文件名:src/lib.rs
rust
pub fn add(left: u64, right: u64) -> u64 {
left + right
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn it_works() {
let result = add(2, 2);
assert_eq!(result, 4);
}
}
属性 cfg 代表配置 (configuration),它告诉 Rust:只有在给定了某个配置选项时,才包含紧随其后的条目。这里的选项是 test,由 Rust 提供,用于编译和运行测试。借助 cfg 属性,只有主动用 cargo test 运行测试时,Cargo 才编译测试代码------既包括标了 #[test] 的函数,也包括模块内的任何辅助函数。
编译注:
#[cfg(test)]的本质是条件编译(conditional compilation):条件为假时整个tests模块根本不参与编译,所以测试代码既不进cargo build/cargo build --release的产物,也不拖慢日常构建。顺带留意测试输出里的Finished `test` profile------cargo test用的是独立的 test profile,和普通构建互不污染。
测试私有函数
是否应该直接测私有函数,测试社区一直有争论,其他语言往往让这件事困难甚至不可能。无论持哪种测试理念,Rust 的私有性规则都允许你测。示例 11-12 是带私有函数 internal_adder 的代码:
rust
pub fn add_two(a: u64) -> u64 {
internal_adder(a, 2)
}
fn internal_adder(left: u64, right: u64) -> u64 {
left + right
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn internal() {
let result = internal_adder(2, 2);
assert_eq!(result, 4);
}
}
示例 11-12:测试一个私有函数
internal_adder 没有标 pub。测试不过是普通的 Rust 代码,tests 也不过是普通的模块------第 7 章讲过,子模块中的条目可以使用其祖先模块中的条目,这里 use super::* 把 tests 模块的父模块全部引入作用域,测试就能调用 internal_adder。如果你认为不该测私有函数,Rust 也不会强迫你。
集成测试
集成测试完全位于你的库之外,像其他任何代码那样使用你的库------只能调用公共 API。目的是检验库的许多部分能否正确协同工作:各自独立运行正常的代码单元,集成起来也可能出问题,集成后的测试覆盖同样重要。要创建集成测试,首先需要一个 tests 目录。
tests 目录
在项目目录顶层、紧挨着 src 建 tests 目录,Cargo 知道到这里找集成测试文件;想建多少测试文件都可以,其中每个文件都会被编译成独立的 crate。
来实战一遍:保持示例 11-12 的代码仍在 src/lib.rs,新建 tests 目录,再创建 tests/integration_test.rs。目录结构如下:
toml
adder
├── Cargo.lock
├── Cargo.toml
├── src
│ └── lib.rs
└── tests
└── integration_test.rs
把示例 11-13 的代码输入 tests/integration_test.rs:
rust
use adder::add_two;
#[test]
fn it_adds_two() {
let result = add_two(2);
assert_eq!(result, 4);
}
示例 11-13:针对 adder crate 中某个函数的集成测试
tests 目录中的每个文件都是独立的 crate,所以要把库引入每个测试 crate 的作用域------顶部添加 use adder::add_two;,这在单元测试里是不需要的。也不需要给 tests/integration_test.rs 标 #[cfg(test)]:Cargo 对 tests 目录区别对待,只在 cargo test 时才编译其中的文件。运行 cargo test:
bash
cargo test
Compiling adder v0.1.0 (file:///projects/adder)
Finished `test` profile [unoptimized + debuginfo] target(s) in 1.31s
Running unittests src/lib.rs (target/debug/deps/adder-1082c4b063a8fbe6)
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-1082c4b063a8fbe6)
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 之前。每个集成测试文件有自己独立的部分,tests 里文件越多,这部分越多。
编译注: 「每个文件一个独立 crate」和单元测试对照着记最牢:
src里的tests模块是库 crate 的内层模块,use super::*连私有函数都够得着;tests/下的每个.rs则是外部 crate,只看得见你标了pub的公共 API------每跑一次集成测试,等于在模拟一个真实下游用户的视角。这也是它测不了私有函数、也不需要#[cfg(test)]的根本原因。
仍然可以把测试函数名作为参数传给 cargo test 来运行某个集成测试函数;要运行某个集成测试文件中的全部测试,用 --test 参数跟上文件名:
bash
cargo test --test integration_test
Compiling adder v0.1.0 (file:///projects/adder)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.64s
Running tests/integration_test.rs (target/debug/deps/integration_test-82e7799c1bc62298)
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 文件中的测试。
集成测试中的子模块
集成测试多起来后,你可能想在 tests 目录里建更多文件加以组织,比如按被测功能分组。如前所述,tests 中每个文件编译成各自独立的 crate,有利于隔离作用域、贴近终端用户使用 crate 的方式;但也意味着 tests 里的文件与 src 中的行为不同------模块与文件的拆分规则第 7 章学过。
差异最明显的场景:把多个集成测试文件共用的辅助函数抽到公共模块。比如建 tests/common.rs、放一个 setup 函数,往里写想从多个测试文件的多个测试函数中调用的代码:
文件名:tests/common.rs
rust
pub fn setup() {
// 针对你这个库的测试的设置代码会放在这里
}
再运行测试,输出中多出一个针对 common.rs 的部分------尽管这个文件不含任何测试函数,我们也没在任何地方调用过 setup:
bash
cargo test
Compiling adder v0.1.0 (file:///projects/adder)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.89s
Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4)
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-92948b65e88960b4)
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-92948b65e88960b4)
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,不是我们想要的------我们只想共享代码。办法:不建 tests/common.rs,改建 tests/common/mod.rs。此时项目目录如下:
toml
├── Cargo.lock
├── Cargo.toml
├── src
│ └── lib.rs
└── tests
├── common
│ └── mod.rs
└── integration_test.rs
这是 Rust 同样支持的旧式命名约定(第 7 章「备选文件路径」一节提过),这样命名是在告诉 Rust 不要把 common 模块当作集成测试文件。把 setup 移入 tests/common/mod.rs、删掉 tests/common.rs 后,输出中的那个部分就消失了------tests 目录的子目录中的文件不会被编译成独立 crate,也不在测试输出中占据部分。之后就能在任何集成测试文件里把它当模块用,比如在 it_adds_two 测试中调用 setup:
文件名:tests/integration_test.rs
rust
use adder::add_two;
mod common;
#[test]
fn it_adds_two() {
common::setup();
let result = add_two(2);
assert_eq!(result, 4);
}
mod common; 声明与示例 7-21 演示过的模块声明相同,随后在测试函数里就能调用 common::setup() 了。
二进制 crate 的集成测试
如果项目是二进制 crate------只有 src/main.rs 没有 src/lib.rs------就无法在 tests 目录中创建集成测试,也没法用 use 把 src/main.rs 里定义的函数引入作用域。只有库 crate 才向外暴露可供其他 crate 使用的函数;二进制 crate 本就是设计来独立运行的。
这也是提供二进制文件的 Rust 项目往往让 src/main.rs 精简、让它调用 src/lib.rs 中逻辑的原因之一:这种结构下,集成测试就能够 通过 use 引入库 crate 测到重要功能;重要功能正常工作,src/main.rs 里那一小段代码也将正常工作,而这一小段无需测试。
小结与下一步
- 测试在 Rust 里是一等公民:
#[test]标注加cargo test一条命令,零框架选型、零构建配置 - 断言工具箱:
assert!(布尔)、assert_eq!/assert_ne!(相等性,失败打印两值)、自定义失败消息走format!语法;类型系统的安全保证管内存不管逻辑,逻辑正确性要靠测试兜底 - 错误路径:
should_panic只回答「panic 了吗」,务必配expected子串;返回Result<T, E>的测试可以用?,断言Err用assert!(value.is_err()) - 运行控制:默认并行 + 捕获输出;
--test-threads=1串行、--show-output(或--nocapture)看输出、按名称/模块名过滤、#[ignore]加--ignored跳过耗时测试 - 组织结构:单元测试放
src内#[cfg(test)]的tests模块(条件编译,可测私有函数);集成测试放tests/(每文件一个独立 crate,只测公共 API,共享代码放tests/common/mod.rs);纯二进制项目测不了集成测试------main.rs保持薄、逻辑进lib.rs
下一篇:第 12 章「I/O 项目:构建一个命令行程序」------第一个有真实复杂度的项目 minigrep,本章的测试方法马上派上用场。