参考:https://kaisery.github.io/trpl-zh-cn/ch11-00-testing.html
讨论 Rust 测试功能的机制。我们会讲到编写测试时会用到的注解和宏,运行测试的默认行为和选项,以及如何将测试组织成单元测试 (unit testing) 和集成测试 (integration testing)。
Rust 会进行类型检查和借用检查,例如,这些检查会确保我们不会传递不符合的类型或无效的引用给这个函数。但 Rust 所 不能 检查的是这个函数是否会准确的完成我们期望的工作,例如 1+1 是否真的等于 2,而不是等于其他的数值。
编写测试
Rust 中的测试函数是用来验证非测试代码是否按照期望的方式运行的。测试函数体通常执行如下三种操作:
- 设置任何所需的数据或状态
- 运行需要测试的代码
-
#[test]
属性Rust 中的测试就是一个带有
#[test]
属性注解的函数。
属性(attribute)是关于 Rust 代码片段的元数据;结构体中用到的derive
属性就是一个例子。
当使用 Cargo 新建一个库项目时,它会自动为我们生成一个测试模块和一个测试函数。这有助于我们开始编写测试,因为这样每次开始新项目时不必去查找测试函数的具体结构和语法了。当然你也可以额外增加任意多的测试函数以及测试模块!
使用cargo new adder --lib
创建一个新的库项目adder
,adder 库中src/lib.rs
的内容:#[cfg(test)]
mod tests {
#[test]
// 为了将一个函数变成测试函数,需要在 `fn` 行之前加上 `#[test]`。
fn it_works() {
assert_eq!(2 + 2, 4);
}
}
当使用
cargo test
命令运行测试时,Rust 会构建一个测试执行程序用来调用标记了test
属性的函数,并报告每一个测试是通过还是失败。$ cargo test
Compiling adder v0.1.0 (file:///projects/adder)
Finished dev [unoptimized + debuginfo] target(s) in 0.22 secs
Running target/debug/deps/adder-ce99bcc2479f4607
running 1 test
test tests::it_works ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
Doc-tests adder
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
报告信息中的几种状态:
passed:
#[test]
代码测试通过,比如 通过所有assert!
之类的断言、测试代码运行无错误、should_panic
测试符合预期的 panic、测试函数的返回值是Ok
等- failed:
#[test]
代码测试不通过,比如 至少一个没通过assert!
断言 、测试代码运行错误、should_panic
测试不符合预期的 panic、测试函数的返回值是Err
等 - ignored:测试被标记为忽略,也就是带
#[ignore]
属性的#[test]
代码 - filtered out:测试被过滤,即被
cargo test xxx
指定之后、不属于报告中其他几种状态,剩余的未被测试的#[test]
代码 - measured:性能测试 (benchmark tests),带有
#[bench]
属性的测试,目前仅用于 nightly 版,见 https://doc.rust-lang.org/unstable-book/library-features/test.html - Doc-tests:文档测试的结果,Rust 会编译任何在 API 文档中的代码示例,这个功能帮助我们使文档和代码保持同步。#todo: 文档注释#
断言宏
assert!
测试值为 trueassert!(bool_expr)
:向assert!
宏提供一个求值为布尔值的参数。如果值是true
,assert!
什么也不做,同时测试会通过。如果值为false
,assert!
调用panic!
宏,这会导致测试失败。在希望确保测试中一些条件为true
时非常有用。#[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::*; // 由于 mod 的私有性,需要调用父级同级(模块)代码
#[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));
assert!(!smaller.can_hold(&larger));
// 多个断言在一个 #[test] 里时
// 所有断言通过报告中才会显示 larger_can_hold_smaller 测试通过
}
}
assert_eq!
测试两个值相等assert_eq!
和assert_ne!
。这两个宏分别比较两个值是相等 (equal) 还是不相等 (not euqal)。
使用assert_eq!(expr1, expr2)
相当于assert!(expr1 == expr2)
。
当断言失败时它们也会打印出这两个值具体是什么,以便于观察测试 为什么 失败,而assert!
只会打印出它从==
表达式中得到了false
值,而不是导致false
的两个值。#[cfg(test)]
mod tests {
#[test]
fn it_works() {
assert_eq!(2 + 2, 5);
}
}
cargo test
测试结果,可以看到断言中两个参数的值,即报告中的left
和right
的值,以及它们断言失败的位置:src/lib.rs 文件的 第 5 行第 9 个字符开始。
在一些语言和测试框架中,断言两个值相等的函数的参数叫做running 1 test
test tests::it_works ... FAILED
failures:
---- tests::it_works stdout ----
thread 'main' panicked at 'assertion failed: `(left == right)`
left: `4`,
right: `5`', src/lib.rs:5:9
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
expected
和actual
,而且指定参数的顺序是很关键的。
然而在 Rust 中,他们则叫做left
和right
,同时指定期望的值和被测试代码产生的值的顺序并不重要。
使用 assert_ne!(expr1, expr2)
相当于 assert!(expr1 != expr2)
。assert_ne!
宏在传递给它的两个值不相等时通过,而在相等时失败。
在代码按预期运行,我们不确定值 会 是什么,不过能确定值绝对 不会 是什么的时候,这个宏最有用处。例如,如果一个函数保证会以某种方式改变其输出,不过这种改变方式是由运行测试时是星期几来决定的,这时最好的断言可能就是函数的输出不等于其输入。
assert_eq!
和 assert_ne!
宏在底层分别使用了 ==
和 !=
。当断言失败时,这些宏会使用调试格式打印出其参数,这意味着被比较的值必需实现了 PartialEq
和 Debug
trait。所有的基本类型和大部分标准库类型都实现了这些 trait。
对于自定义的结构体和枚举,需要实现 PartialEq
才能断言他们的值是否相等。需要实现 Debug
才能在断言失败时打印他们的值。由于这两个 trait 都是派生 trait,通常可以直接在结构体或枚举上添加 #[derive(PartialEq, Debug)]
注解来实现这两个 trait。
你也许会好奇,为什么这三个断言是 宏 而不是函数。因为 宏 能使用任意多的参数,所以这三个宏也不例外。
你可以向 assert!
、assert_eq!
和 assert_ne!
宏传递 可选的失败信息参数,可以 在测试失败时将自定义失败信息一同打印出来:任何在 assert!
的一个必需参数和 assert_eq!
和 assert_ne!
的两个必需参数之后指定的参数都会传递给 format!
宏。所以可以传递一个包含 {}
或者 {:#?}
占位符的格式字符串 和 需要放入占位符的值。
添加自定义失败信息参数:
#[cfg(test)]
mod tests {
#[test]
fn it_works() {
let actual = 5;
let expect = 2 + 2;
assert_eq!(
actual, expect,
"the actual result is {}, but expect {}",
actual, expect
);
}
}
自定义信息有助于记录断言的意义,我们可以在测试输出中看到更明确的信息,这会帮助我们理解当测试失败时就能更好的理解代码出了什么问题:
---- tests::it_works stdout ----
thread 'main' panicked at 'assertion failed: `(left == right)`
left: `5`,
right: `4`: the actual result is 5, but expect 4', src/lib.rs:7:9
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
对处理错误进行测试
除了检查代码是否返回期望的正确的值之外,检查代码是否按照期望处理错误也是很重要的。
#[should_panic]
属性:测试 panic
#[should_panic]
属性在函数中的代码 panic 时会通过测试,而在其中的代码没有 panic 时测试失败。#[should_panic]
测试结果可能会非常含糊不清,因为遇到测试 failed,它仅仅告诉我们代码并没有产生 panic:test did not panic as expected
#[should_panic(expected = "expected panic info")]
:对带有特定错误信息的panic!
进行测试。- 应用背景:在代码中有多处地方
panic!
,然而我们想更准确地对具体一处panic!
进行测试。 - 比如为了测试
panic!("info A");
是不是真的生效,那么就在属性#[should_panic(expected = "info A")]
expected 参数里面填入info A
的子串信息。- 程序产生此 panic 时,测试 pass;
- 产生别的 panic 时,测试 failed,并提示实际产生的和预期的 panic 信息;
- 没有产生任何 panic,测试 failed,并提示
test did not panic as expected
。
- 应用背景:在代码中有多处地方
例子:实例化一个叫做 Guess
的结构体,它的字段必须是 [1, 100] 范围内的 i32 类型。
因为 i32 的类型限定已经由编译器处理,传入别的类型肯定不被允许。
而 [1, 100] 范围限定需要我们自己实现,我们用到了比较和 if 结构。如果传入的 i32 值不在这个范围内,我们直接让程序 panic。
现在测试 if 判断逻辑是不是真的处理对了输入值的范围,以及测试传入值进入 if 分支时引发预期的错误。
- 测试超出 [1, 100] 范围的情况下(这里是传入 200)会不会如预期那样引发 panic:使用
#[should_panic]
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[should_panic]
fn greater_than_100() {
Guess::new(200);
}
}
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 {}.",
"Guess value must be less than or equal to 100, got {}.", /* 错误的 if 逻辑引发非预期中的 panic */
value
);
} else if value > 100 {
panic!(
// "Guess value must be less than or equal to 100, got {}.",
"Guess value must be greater than or equal to 1, got {}.", /* 错误的 if 逻辑引发非预期中的 panic */
value
);
}
Guess { value }
}
}
cargo test
测试结果:通过测试,说明传入 200 的确会引发 panic。running 1 test
test tests::greater_than_100 ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
如果传入 50 ,不会让程序 panic,但是测试会 failed:
#[should_panic]
fn greater_than_100() {
Guess::new(50);
}
测试报告提示地很清楚,没有按预期 panic:
running 1 test
test tests::greater_than_100 ... FAILED
failures:
---- tests::greater_than_100 stdout ----
note: test did not panic as expected
- 测试超出 [1, 100] 范围的情况下(这里是传入 200)会不会如预期那样引发大于 100 情况下的 panic:
使用 #[should_panic(expected = "Guess value must be less than or equal to 100")]
来声明这个测试的目的。expected
参数的内容是大于 100 情况下的 panic 的信息中的字符串(全部内容或者子串)。
#[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);
}
}
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 {}.",
"Guess value must be less than or equal to 100, got {}.", /* 错误的 if 逻辑引发非预期中的 panic */
value
);
} else if value > 100 {
panic!(
// "Guess value must be less than or equal to 100, got {}.",
"Guess value must be greater than or equal to 1, got {}.", /* 错误的 if 逻辑引发非预期中的 panic */
value
);
}
Guess { value }
}
}
cargo test
测试结果:
running 1 test
test 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:25:13
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: `"Guess value must be less than or equal to 100"`
没有通过测试,而且显示的信息很多:
thread 'main' panicked at 'Guess value must be greater than or equal to 1, got 200.', src/lib.rs:25:13
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
这是程序 panic 时的 打印输出,说明程序的确 panic,由于 panic!
被传入了信息,所以被打印出来了。
note: 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"`
这是测试应该 panic 时的信息:panic did not contain expected string (panic 的信息没有包含预期的字符串)。
因为我们预期对大于 100 情况的应该出现 Guess value must be less than or equal to 100
信息的 panic;
而实际的 panic 信息是 Guess value must be greater than or equal to 1, got 200.
,程序没有按期望那样执行,所以代码有 bug,导致测试不通过。
我们遇到测试不通过的时候应该怎么做呢?检查代码,纠正 bug。错误报告显示 src/lib.rs:25:13 处引发的 panic,当我们检查那段代码,发现是 if 的逻辑有误,从而修正代码:
#[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);
}
}
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 {}.",
// "Guess value must be less than or equal to 100, got {}.", /* 错误的 if 逻辑引发非预期中的 panic */
value
);
} else if value > 100 {
panic!(
"Guess value must be less than or equal to 100, got {}.",
// "Guess value must be greater than or equal to 1, got {}.", /* 错误的 if 逻辑引发非预期中的 panic */
value
);
}
Guess { value }
}
}
最终程序通过测试:
running 1 test
test tests::greater_than_100 ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
使用 Result<T, E>
返回值:测试错误类型
利用 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"))
}
}
}
当程序返回 Ok()
类型,测试通过;当返回 Err()
类型,测试失败。在这个例子中便无需调用 assert_eq!
宏就可以对两个值比较。
此外,还可以利用 Result<T, E>
返回类型 在函数体中使用问号运算符,如此可以方便的编写任何运算符会返回 Err
成员的测试。
注意不能对这些使用 Result<T, E>
的测试使用 #[should_panic]
注解。相反应该在测试失败时直接返回 Err
值。
上面这个例子返回 Ok(())
,所以测试通过。下面这个例子,程序跳转到 else 分支,返回 Err
#[cfg(test)]
mod tests {
#[test]
fn it_works() -> Result<(), String> {
if 2 + 2 == 5 {
Ok(())
} else {
Err(String::from("two plus two does not equal four"))
}
}
}
测试报告显示 failed,并且提供了详细的信息:
running 1 test
test tests::it_works ... FAILED
failures:
---- tests::it_works stdout ----
Error: "two plus two does not equal four"
thread 'main' panicked at 'assertion failed: `(left == right)`
left: `1`,
right: `0`: the test returned a termination value with a non-zero status code (1) which indicates a failure', /hom
e/ubuntu/.rustup/toolchains/nightly-x86_64-unknown-linux-gnu/lib/rustlib/src/rust/library/test/src/lib.rs:192:5
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
运行测试
Rust 默认使用线程来并行运行测试,必须确保测试不能相互依赖,或依赖任何共享的状态,包括依赖共享的环境,比如当前工作目录或者环境变量。
cargo test
:在测试模式下编译代码并运行生成的测试二进制文件(位于/target/debug/deps/
下)- 可以将一部分命令行参数传递给 cargo test,而将另外一部分传递给生成的测试二进制文件。
- 为了分隔这两种参数,需要先列出传递给 cargo test 的参数,接着是分隔符 —,再之后是传递给测试二进制文件的参数。
cargo test --help
会提示cargo test
的有关参数;运行cargo test -- --help
可以提示在分隔符--
之后使用的有关参数。
cargo test -- --test-threads=1
:使用一个线程运行测试- 比并行运行花费更多时间
- 放弃并行的场景:需要共享环境,比如多个测试需要读写同一份文件。
cargo test -- --nocapture
:显示函数输出- Rust 的测试库默认会截获打印到标准输出的所有内容,所以无法看到代码中
println!
的输出 - 又由于默认并行测试,打印的顺序可能杂乱,可以配合单线程让打印有序:
cargo test -- --nocapture --test-threads=1
- Rust 的测试库默认会截获打印到标准输出的所有内容,所以无法看到代码中
cargo test 指定一个测试名称
:- 向
cargo test
传递任意测试的名称来只运行这个测试,只有传递给cargo test
的第一个值才会被使用。通过传入特定的测试名称来运行多个测试 - 这里的测试名称可以是:完整的测试函数名称、多个测试函数部分中相同的名称部分、测试所在的模块名(测试该模块下所有的测试)
- 向
cargo test -- --ignored
:- 带
#[ignore]
属性的#[test]
函数在通常情况下不会被运行,所以需要这个命令单独让它们一起运行 - 常用在耗时较多的测试上面:
#[test]
#[ignore]
fn expensive_test() {
// 需要运行一个小时的代码
}
测试的组织结构
单元测试独立地验证库的不同部分,也能够测试私有函数实现细节。
集成测试则检查多个部分是否能结合起来正确地工作,并像其他外部代码那样测试库的公有 API。单元测试 (unit tests)
单元测试的目的是在与其他部分隔离的环境中测试每一个单元的代码,以便于快速而准确的某个单元的代码功能是否符合预期。
单元测试与他们要测试的代码共同存放在位于 src 目录下相同的文件中。
规范是在每个文件中创建包含测试函数的tests
模块,并使用cfg(test)
标注模块:
- 带
告诉 Rust 只在执行
cargo test
时才编译和运行测试代码,而在运行cargo build
时不这么做。- cfg = configuration 提示编译器进行条件检查的配置内容,cfg 里的内容只应该被包含进特定配置选项中。
- 需要编译的不仅仅有标注为
#[test]
的函数,之外,还包括测试模块中可能存在的帮助函数。
单元测试位于与源码相同的文件中,所以你需要使用 #[cfg(test)]
来指定他们不应该被包含进编译结果中。
这在只希望构建库的时候可以节省编译时间,并且因为它们并没有包含测试,所以能减少编译产生的文件的大小。
测试社区中一直存在关于是否应该对私有函数直接进行测试的论战,而在其他语言中想要测试私有函数是一件困难的,甚至是不可能的事。不过无论你坚持哪种测试意识形态,Rust 的私有性规则确实允许你测试私有函数。
// 这个函数是私有的:只能在当前 crate 中使用,并没有 pub 公布给别人使用
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));
}
}
集成测试 (integration tests)
一般步骤
在 Rust 中,集成测试对于你需要测试的库来说完全是外部的。同其他使用库的代码一样使用库文件,也就是说它们只能调用一部分库中的公有 API。
集成测试的目的是测试库的多个部分能否一起正常工作。一些单独能正确运行的代码单元集成在一起也可能会出现问题,所以集成测试的覆盖率也是很重要的。
- 先创建一个 tests 目录(与 src 同级):集成测试因为位于另一个文件夹,所以并不需要
#[cfg(test)]
注解,但是需要#[test]
属性。 - 在 tests/ 目录中创建任意多的测试文件:Cargo 会将每一个文件当作单独的 crate 来编译。
- 在文件顶部添加
use 被测试的 lib crate 名称
:因为 tests 目录中每一个测试文件都是完全独立的 crate - 使用
cargo test
运行所有集成测试代码;使用cargo test --test 测试文件名(不需要 .rs 后缀)
来运行这个集成测试文件中的所有测试$ cargo test
Compiling adder v0.1.0 (file:///projects/adder)
Finished dev [unoptimized + debuginfo] target(s) in 0.31 secs
Running target/debug/deps/adder-abcabcabc
running 1 test
test tests::internal ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
Running target/debug/deps/integration_test-ce99bcc2479f4607
running 1 test
test it_adds_two ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
Doc-tests adder
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
$ cargo test --test integration_test
Finished dev [unoptimized + debuginfo] target(s) in 0.0 secs
Running target/debug/integration_test-952a27e0126bb565
running 1 test
test it_adds_two ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
多个集成测试文件共享代码
每个集成测试文件会被当作其各自的 crate 来对待,这更有助于创建单独的作用域,这种单独的作用域能提供更类似与最终使用者使用 crate 的环境。所以在 tests 文件夹中,无论遇到的 rs 文件有没有代码被#[test]
属性标记都会显示在测试报告中。即便毫无#[test]
标记的 rs 文件,也会显示出test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
。
集成测试可以通过创建模块名/mod.rs
来让这个模块(文件夹)里的内容不被cargo tese
测试,这可以让多个测试文件 “共享代码” :一个 demo.
├── Cargo.lock
├── Cargo.toml
├── src
│ └── lib.rs
└── tests
├── first_test.rs
├── front_of_house
│ ├── hosting.rs
│ └── mod.rs
└── second_test.rs
// tests/first_test.rs 和 tests/second_test.rs 内容
pub use front_of_house::hosting;
mod front_of_house;
#[test]
fn tests_it_works() {
assert_eq!(hosting::add_to_waitlist(), 0);
}
// tests/front_of_house/mod.rs
pub mod hosting;
// tests/front_of_house/hosting.rs
pub fn add_to_waitlist() -> u32 {
0
}
cargo test
测试结果:单元测试 unittests、集成测试 tests/first_test 和 tests/second_test.rs、文档测试 Doc-tests 三部分Running unittests (target/debug/deps/multi_lib_tests-1d903baba8c78794)
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
Running tests/first_test.rs (target/debug/deps/first_test-816b41aa3e91e2cd)
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
Running tests/second_test.rs (target/debug/deps/second_test-f4e355934f1194a1)
running 1 test
test tests::tests_it_works ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Doc-tests multi_lib_tests
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
binary crate 的集成测试
如果项目是二进制 crate 并且只包含 src/main.rs 而没有 src/lib.rs,这样就不可能在 tests 目录创建集成测试并使用 use 导入 src/main.rs 中定义的函数。因为use 当前crate名
仅导入 src/lib.rs。
只有 lib crate 才会向其他 crate 暴露了可供调用和使用的函数;binary crate 只意在单独运行。
为什么 Rust 二进制项目的结构明确采用 src/main.rs 调用 src/lib.rs 中的逻辑的方式?因为通过这种结构,集成测试 就可以 通过use 当前crate名
来测试 lib crate 中的主要功能了。
总而言之,binary crate 可以使用#[cfg(test)]
做单元测试,但是只能通过 src/lib.rs —— lib crate 来做集成测试。