编写自动化测试
“Program testing can be a very effective way to show the presence of bugs, but it is hopelessly inadequate for showing their absence.”这不意味着需要尽可能的测试软件
- 例如,我们可以编写一个叫做
add_two
的将传递给它的值加二的函数。它的签名有一个整型参数并返回一个整型值。当实现和编译这个函数时,Rust
会进行所有目前我们已经见过的类型检查和借用检查,例如,这些检查会确保我们不会传递String
或无效的引用给这个函数。Rust
所 不能 检查的是这个函数是否会准确的完成我们期望的工作:返回参数加2
后的值,而不是比如说参数加 10 或减 50 的值!这也就是测试出场的地方。
如何编写测试
- Rust 中的测试函数是用来验证非测试代码是否按照期望的方式运行的。测试函数体通常执行如下三种操作:
- 设置任何所需的数据或状态
- 运行需要测试的代码
- 断言其结果是我们所期望的
- Rust中有专门的用于编写测试的功能:
test
属性、一些宏和should_panic
属性
测试函数剖析
- 作为简单的例子,Rust中测试是一个带有test属性标注的函数。属性(
attribute
)是关于Rust
代码片段的元数据。derive
就是一个属性(#[derive(Debug)]
),如果要进行一个测试函数,需要在fn行之前加上#[test]
,当使用cargo test时,构建一个测试执行程序用来标记test
属性的函数,并且报告每一个测试是否通过。 - 第7章当使用Cargo新建项目库,会自动生成一个测试模块和测试函数。
- 创建新的库项目
adder
:
cargo new adder --lib
cd adder
fn main() {}
#[cfg(test)]
mod tests {
#[test]
fn it_works() {
assert_eq!(2 + 2, 4);
}
}
#[test]
:在函数之前的这个参数表明的是这个函数是一个测试函数。- 函数体通过
assert_eq!
宏断言2+2 = 4。
Compiling adder v0.1.0 (E:\RUST\adder)
Finished test [unoptimized + debuginfo] target(s) in 1.42s
Running unittests src\lib.rs (target\debug\deps\adder-b7a94d0e2e80adf5.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
- 这段测试是可以正常运行的,
test result
也证实了这一点。因为没有将任何测试标记为忽略,所以显示0 ignored
,也没有过滤要运行的测试,所以显示0 filtered out
。 0 measured
统计是针对性能测试的。性能测试(benchmark tests)在编写本书时,仍只能用于 Rust 开发版(nightly Rust)。Doc-tests adder
开头的这一部分是所有文档测试的结果。我们现在并没有任何文档测试,不过 Rust 会编译任何在 API 文档中的代码示例。
fn main() {}
#[cfg(test)]
mod tests {
#[test]
fn exploration() {
assert_eq!(2 + 2, 4);
}
#[test]
fn another() {
panic!("Make this test fail");
}
}
test tests::another ... failed
表明了another测试失败。
running 2 tests
test tests::exploration ... ok
test tests::another ... FAILED
failures:
---- tests::another stdout ----
thread 'tests::another' panicked at 'Make this test fail', src/lib.rs:10:9
note: Run with `RUST_BACKTRACE=1` for a backtrace.
failures:
tests::another
test result: FAILED. 1 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out
error: test failed
使用assert!宏检查结果
assert!
宏由标准库提供,在希望确保测试中一些条件为true
时非常有用。需要向assert!
宏提供一个求值为布尔值的参数。如果值是true
,assert!
什么也不做,同时测试会通过。如果值为false
,assert!
调用panic!
宏,这会导致测试失败。assert!
宏帮助我们检查代码是否以期望的方式运行。- 对
Rectangle
结构体和can_hold
方法进行修改,使得它们进行测试。
fn main() {}
#[derive(Debug)]
struct Rectangle {
width: u32,
height: u32,
}
impl Rectangle {
fn can_hold(&self, other: &Rectangle) -> bool {
self.width > other.width && self.height > other.height
}
}
can_hold
返回一个布尔值,它完美符合assert!
的使用场景,编写一个测试进行练习,创建一个长是8宽是7的Rectangle
实例,并假设它能放得下另一个长度为5宽为1的Rectangle
实例use super::*
;的方法是将相对路径中的所有项都引入到内部模块的作用域之中,"*`"的意思是全局导入,这种方法虽然能够将所有函数导入,但是得注意阅读的方便性问题。- 接着,放入需要测试的代码:
#[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));
}
}
- 这样是可以正常测试的。
- 修改代码,断言小的矩形放不下大的矩形。
#[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 smaller_cannot_hold_larger() {
let larger = Rectangle { width: 8, height: 7 };
let smaller = Rectangle { width: 5, height: 1 };
assert!(!smaller.can_hold(&larger));
}
}
- 这段代码也是可以正常测试的。但是这里要注意前面有取反符号
!
,用来将smaller.can_hold(&larger)
结果取反,取反之后为true
,传递给assert!
宏。 - 对代码修改,会传递错误,官方文档
使用assert_eq!和assert_ne!宏测试相等
- 通过使用
==
运算符的表达式来做到。这两个宏分别比较两个值相等还是不相等。断言失败的时候会打印出来两个值的具体的含义,用来观察为什么失败,这相较于assert!
宏较为高级,而assert!
宏只会打印出错误,并不会打印出导致错误的原因。
fn main() {}
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));
}
}
- 测试可以正常通过。
- 如果添加一个bug,将
a+2
改成a+3
, - 测试会出Bug,原因会列出来:
running 1 test
test tests::it_adds_two ... FAILED
failures:
---- tests::it_adds_two stdout ----
thread 'tests::it_adds_two' panicked at 'assertion failed: `(left == right)`
left: `4`,
right: `5`', src/lib.rs:11:9
note: Run with `RUST_BACKTRACE=1` for a backtrace.
failures:
tests::it_adds_two
test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out
- 相对应的是,
assert_ne!
会在值不相等的情况下输出。 - 当断言失败时,这些宏会使用调试格式打印出其参数,这意味着被比较的值必需实现了
PartialEq
和Debug trait
。所有的基本类型和大部分标准库类型都实现了这些trait
。对于自定义的结构体和枚举,需要实现PartialEq
才能断言他们的值是否相等。需要实现Debug
才能在断言失败时打印他们的值。因为这两个trait
都是派生trait
,如第 5 章示例 5-12 所提到的,通常可以直接在结构体或枚举上添加#[derive(PartialEq, Debug)]
标注。附录 C “可派生 trait” 中有更多关于这些和其他派生 trait 的详细信息。
自定义失败信息
- 你也可以向
assert!
、assert_eq!
和assert_ne!
宏传递一个可选的失败信息参数,可以在测试失败时将自定义失败信息一同打印出来。任何在assert!
的一个必需参数和assert_eq!
和assert_ne!
的两个必需参数之后指定的参数都会传递给format!
宏,所以可以传递一个包含{}
占位符的格式字符串和需要放入占位符的值。自定义信息有助于记录断言的意义;当测试失败时就能更好的理解代码出了什么问题。
fn main() {}
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"));
}
}
contains
函数中是某个字符串是否包含另外一个字符串。但是此代码测试是可以通过的,将代码修改一下,使其出现bug:
fn main() {}
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"));
}
}
- 这样就会产生错误,
running 1 test
test tests::greeting_contains_name ... FAILED
failures:
---- tests::greeting_contains_name stdout ----
thread 'tests::greeting_contains_name' panicked at 'assertion failed:
result.contains("Carol")', src/lib.rs:12:9
note: Run with `RUST_BACKTRACE=1` for a backtrace.
failures:
tests::greeting_contains_name
- 但是它仅仅产生了失败的行号和信息,让我们自己添加一个错误失败的信息参数:带占位符格式字符串,和
greeting
函数的值,
#[test]
fn greeting_contains_name() {
let result = greeting("Carol");
assert!(
result.contains("Carol"),
"Greeting did not contain name, value was `{}`", result
);
}
- 这样就可以看出确切的值,会帮助我们了解发生了什么,而不是期望值。
使用should_panic检查panic
- 写一个函数确保
Guess
实例中的值在1到100之间,超过100的时候会出现panic
- 对函数增加一个
should_panic
实现这些,这个属性在函数中代码panic!
的时候会通过,没有panic的时候失败。也就是说,程序员在这个点预判接下来程序会panic
。
fn main() {}
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);
}
}
- 这种情况之下,如果程序原本可以正常执行,他就会出现panic,如果程序原来出现panic,他就会正常测试,在这段代码中由于
200>100
,所以会正常执行,
running 1 test
test tests::greater_than_100 ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
- 接着是一种变形:
fn main() {}
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
}
}
}
running 1 test
test tests::greater_than_100 ... FAILED
failures:
failures:
tests::greater_than_100
test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out
- 之前的should_panic只是会出现,如果想要should_panic测试结果精确表述,使用expected参数提供值是Guess::new函数panic的子串,在这个例子中是
Guess value must be less than or equal to 100, got 200. 。
expected
信息的选择取决于panic
信息有多独特或动态,和你希望测试有多准确。在这个例子中,错误信息的子字符串足以确保函数在else if value > 100
的情况下运行。
fn main() {}
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 = "Guess value must be less than or equal to 100")]
fn greater_than_100() {
Guess::new(200);
}
}
- 将value<1和value>100的代码进行调换,
fn main() {}
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 = "Guess value must be less than or equal to 100")]
fn greater_than_100() {
Guess::new(200);
}
}
- 现在运行,结果会出现失败,
running 1 test
test tests::greater_than_100 ... FAILED
failures:
---- tests::greater_than_100 stdout ----
thread 'tests::greater_than_100' panicked at 'Guess value must be
greater than or equal to 1, got 200.', src/lib.rs:11:13
note: Run with `RUST_BACKTRACE=1` for a backtrace.
note: Panic did not include expected string 'Guess value must be less than or
equal to 100'
failures:
tests::greater_than_100
test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out
- 失败信息表明测试确实如期望
panic
了,不过panic
信息中并没有包含expected
信息'Guess value must be less than or equal to 100'。
而我们得到的panic
信息是'Guess value must be greater than or equal to 1, got 200.'
。这样就可以开始寻找 bug 在哪了! - 另外一个要说明的点是:panic存在是因为should panic中子串和Guess::new中的字符串不相同。
将Result<T,E>用于测试
- 使用panic,就也可以使用Result:
fn main() {
#[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"))
}
}
}
}
- 这样编写测试,在正确执行不调用assert_eq!宏而是返回Ok(()).
- 这样编写同时可以使用?运算符简化,但是不能再对它进行
#[should_panic]
标注,测试失败直接返回Err
。
控制测试如何运行
- 可以将一部分命令行参数传递给
cargo test
,另外一部分传递给生成的测试二进制文件,为了分割这两种参数,需要先列出传递给cargo test
的参数,接着是分隔符–,再之后是传递给测试二进制文件的参数。运行cargo test --help
会提示cargo test
的有关参数,而运行cargo test -- --help
可以提示在分隔符--
之后使用的有关参数。
并行或者连续的运行测试
- 当运行多个测试时,
Rust
默认使用线程来并行运行。这意味着测试会更快地运行完毕,所以你可以更快的得到代码能否工作的反馈。因为测试是在同时运行的,你应该确保测试不能相互依赖,或依赖任何共享的状态,包括依赖共享的环境,比如当前工作目录或者环境变量。 - 举个例子,每一个测试都运行一些代码,假设这些代码都在硬盘上创建一个
test-output.txt
文件并写入一些数据。接着每一个测试都读取文件中的数据并断言这个文件包含特定的值,而这个值在每个测试中都是不同的。因为所有测试都是同时运行的,一个测试可能会在另一个测试读写文件过程中修改了文件。那么第二个测试就会失败,并不是因为代码不正确,而是因为测试并行运行时相互干扰。一个解决方案是使每一个测试读写不同的文件;另一个解决方案是一次运行一个测试。 - 如果不希望测试并行运行,或者需要更加精准的控制线程的数量,将
--test-threads
参数和希望使用线程的数量给测试二进制文件。如:
cargo test -- --test-thread=1
- 将测试线程设置为1.告诉程序不要使用任何并行机制,这也会比并行运行花费更多时间,不过有共享的状态,测试就不会存在潜在的相互干扰了。
显示函数输出
- 默认情况下,当测试通过时,
Rust
的测试库会截获打印到标准输出的所有内容。比如在测试中调用了println!
而测试通过了,我们将不会在终端看到println!
的输出:只会看到说明测试通过的提示行。如果测试失败了,则会看到所有标准输出和其他错误信息。 - 示例有一个无意义的函数,打印出其参数的值并且接着返回10,接着是一个通过的测试和失败的测试。
fn main() {
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);
}
}
}
- 运行
cargo test
会看到这些测试的输出
running 2 tests
test tests::this_test_will_pass ... ok
test tests::this_test_will_fail ... FAILED
failures:
---- tests::this_test_will_fail stdout ----
I got the value 8
thread 'tests::this_test_will_fail' panicked at 'assertion failed: `(left == right)`
left: `5`,
right: `10`', src/lib.rs:19:9
note: Run with `RUST_BACKTRACE=1` for a backtrace.
failures:
tests::this_test_will_fail
test result: FAILED. 1 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out
- 注意输出中不会出现测试通过时打印的内容,即
I got the value 4。
因为当测试通过时,这些输出会被截获。失败测试的输出I got the value 8
,则出现在输出的测试摘要部分,同时也显示了测试失败的原因。 - 如果想要看通过的测试中打印的值,截获输出的行为通过
--nocapture
参数来禁用:
cargo test -- --nocapture
- 再次运行,得到:
running 2 tests
I got the value 4
I got the value 8
test tests::this_test_will_pass ... ok
thread 'tests::this_test_will_fail' panicked at 'assertion failed: `(left == right)`
left: `5`,
right: `10`', src/lib.rs:19:9
note: Run with `RUST_BACKTRACE=1` for a backtrace.
test tests::this_test_will_fail ... FAILED
failures:
failures:
tests::this_test_will_fail
test result: FAILED. 1 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out
通过指定的名字来运行部分测试
- 有时运行整个测试集会耗费很长时间。如果你负责特定位置的代码,你可能会希望只运行与这些代码相关的测试。你可以向
cargo test
传递所希望运行的测试名称的参数来选择运行哪些测试。 - 为展示如何运行部分测试,示例添加了三个测试,选择具体运行哪一个:
fn main() {
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));
}
}
}
- 如果直接运行测试,所有的测试都会并行运行:
running 3 tests
test tests::add_two_and_two ... ok
test tests::add_three_and_two ... ok
test tests::one_hundred ... ok
test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
运行单个测试
cargo test one_hundred
Finished dev [unoptimized + debuginfo] target(s) in 0.0 secs
Running target/debug/deps/adder-06a75b4a1f2515e9
running 1 test
test tests::one_hundred ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 2 filtered out
-
只有名称为
one_hundred
的测试被运行了;因为其余两个测试并不匹配这个名称。测试输出在摘要行的结尾显示了2 filtered out
表明还存在比本次所运行的测试更多的测试被过滤掉了。 -
不能像这样指定多个测试名称;只有传递给
cargo test
的第一个值才会被使用。不过有运行多个测试的方法。
过滤运行多个测试
- 我们可以指定部分测试的名称,任何名称匹配这个名称的测试会被运行。例如,因为头两个测试的名称包含
add
,可以通过cargo test add
来运行这两个测试:
cargo test add
Finished dev [unoptimized + debuginfo] target(s) in 0.0 secs
Running target/debug/deps/adder-06a75b4a1f2515e9
running 2 tests
test tests::add_two_and_two ... ok
test tests::add_three_and_two ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 1 filtered out
忽略某些测试
- 有时一些特定的测试执行起来是非常耗费时间的,所以在大多数运行 cargo test 的时候希望能排除他们。虽然可以通过参数列举出所有希望运行的测试来做到,也可以使用 ignore 属性来标记耗时的测试并排除他们,如下所示:
fn main() {
#[test]
fn it_works() {
assert_eq!(2 + 2, 4);
}
#[test]
#[ignore]
fn expensive_test() {
// 需要运行一个小时的代码
}
}
- 对于想要排除的测试,我们在
#[test]
之后增加了#[ignore]
行。现在如果运行测试,就会发现it_works
运行了,而expensive_test
没有运行:
$ cargo test
Compiling adder v0.1.0 (file:///projects/adder)
Finished dev [unoptimized + debuginfo] target(s) in 0.24 secs
Running target/debug/deps/adder-ce99bcc2479f4607
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
- 如果只是希望运行被忽略的测试,可以使用
cargo test -- --ignored
:
$ cargo test -- --ignored
Finished dev [unoptimized + debuginfo] target(s) in 0.0 secs
Running target/debug/deps/adder-ce99bcc2479f4607
running 1 test
test expensive_test ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 1 filtered out
测试的组织结构
Rust
社区倾向于根据测试的两个主要分类来考虑问题:单元测试(unit tests
)与 集成测试(integration tests
)。单元测试倾向于更小而更集中,在隔离的环境中一次测试一个模块,或者是测试私有接口。而集成测试对于你的库来说则完全是外部的。它们与其他外部代码一样,通过相同的方式使用你的代码,只测试公有接口而且每个测试都有可能会测试多个模块。
单元测试
- 单元测试的目的是在与其他部分隔离的环境中测试每一个单元的代码,以便于快速而准确的某个单元的代码功能是否符合预期。单元测试与他们要测试的代码共同存放在位于
src
目录下相同的文件中。规范是在每个文件中创建包含测试函数的tests
模块,并使用cfg(test)
标注模块。
测试模块和#[cfg(test)]
- 测试模块的
#[cfg(test)]
标注告诉Rust
只在执行cargo test
时才编译和运行测试代码,而在运行cargo build
时不这么做。这在只希望构建库的时候可以节省编译时间,并且因为它们并没有包含测试,所以能减少编译产生的文件的大小。与之对应的集成测试因为位于另一个文件夹,所以它们并不需要#[cfg(test)]
标注。然而单元测试位于与源码相同的文件中,所以你需要使用#[cfg(test)]
来指定他们不应该被包含进编译结果中。
fn main() {
#[cfg(test)]
mod tests {
#[test]
fn it_works() {
assert_eq!(2 + 2, 4);
}
}
}
- 上述代码就是自动生成的测试模块。
cfg
属性代表configuration
,它告诉Rust
其之后的项只应该被包含进特定配置选项中。在这个例子中,配置选项是test
,即Rust
所提供的用于编译和运行测试的配置选项。通过使用cfg
属性,Cargo
只会在我们主动使用cargo test
运行测试时才编译测试代码。这包括测试模块中可能存在的帮助函数, 以及标注为#[test]
的函数。
测试私有函数
- Rust中允许你进行私有函数的测试,考虑示例中私有函数测试的代码。
fn main() {}
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));
}
}
- 注意
internal_adder
函数并没有标记为pub
,不过因为测试也不过是Rust
代码同时tests
也仅仅是另一个模块,我们完全可以在测试中导入和调用internal_adder
。如果你并不认为应该测试私有函数,Rust
也不会强迫你这么做。
集成测试
- 在
Rust
中,集成测试对于你需要测试的库来说完全是外部的。同其他使用库的代码一样使用库文件,也就是说它们只能调用一部分库中的公有API
。集成测试的目的是测试库的多个部分能否一起正常工作。一些单独能正确运行的代码单元集成在一起也可能会出现问题,所以集成测试的覆盖率也是很重要的。为了创建集成测试,你需要先创建一个tests
目录。
tests目录
- 为了编写集成测试,需要在项目根目录创建一个
tests
目录,与src
同级。Cargo
知道如何去寻找这个目录中的集成测试文件。接着可以随意在这个目录中创建任意多的测试文件,Cargo
会将每一个文件当作单独的crate
来编译。 - 让我们创建一个集成测试,创建一个
tests
目录,和src
同级,cargo会将每个文件当作单独的crate编译。
use adder;
#[test]
fn it_adds_two() {
assert_eq!(4, adder::add_two(2));
}
- 与单元测试不同,我们需要在文件顶部添加
use adder
。这是因为每一个tests
目录中的测试文件都是完全独立的crate
,所以需要在每一个文件中导入库。 - 并不需要将
tests/integration_test.rs
中的任何代码标注为#[cfg(test)]
。tests
文件夹在Cargo
中是一个特殊的文件夹,Cargo
只会在运行cargo test
时编译这个目录中的文件。现在就运行cargo test
试试:
$ 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
- 我们已经知道,单元测试函数越多,单元测试部分的结果行就会越多。同样的,在集成文件中增加的测试函数越多,也会在对应的测试结果部分增加越多的结果行。每一个集成测试文件有对应的测试结果部分,所以如果在
tests
目录中增加更多文件,测试结果中就会有更多集成测试结果部分。 - 我们仍然可以通过指定测试函数的名称作为
cargo test
的参数来运行特定集成测试。也可以使用cargo test
的--test
后跟文件的名称来运行某个特定集成测试文件中的所有测试:
$ 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
- 这个命令只运行了test中指定的文件integration_test.rs中的测试。
集成测试中的子模块
- 随着集成测试的增加,你可能希望在
tests
目录增加更多文件以便更好的组织他们,例如根据测试的功能来将测试分组。正如我们之前提到的,每一个tests
目录中的文件都被编译为单独的crate
。 - 将每个集成测试文件当作其自己的
crate
来对待,这更有助于创建单独的作用域,这种单独的作用域能提供更类似与最终使用者使用crate
的环境。但是,tests
目录中的文件不能像src
中的文件那样共享相同的行为。 - 当你有一些在多个集成测试文件都会用到的帮助函数,而你尝试按照第 7 章 “将模块移动到其他文件” 部分的步骤将他们提取到一个通用的模块中时, tests 目录中不同文件的行为就会显得很明显。例如,如果我们可以创建 一个
tests/common.rs
文件并创建一个名叫setup
的函数,我们希望这个函数能被多个测试文件的测试函数调用:
pub fn setup() {
// 编写特定库测试所需的代码
}
- 同时,运行测试,会看到关于
common.rs
的文件测试结果部分。
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/common-b8b07b6f1be2db70
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
Running target/debug/deps/integration_test-d993c68b431d39df
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
-
我们并不想要
common
出现在测试结果中显示running 0 tests
。我们只是希望其能被其他多个集成测试文件中调用罢了。 -
为了不让
common
出现在测试输出中,我们将创建tests/common/mod.rs
,而不是创建tests/common.rs
。这是一种 Rust 的命名规范,这样命名告诉 Rust 不要将common
看作一个集成测试文件。将 setup 函数代码移动到tests/common/mod.rs
并删除tests/common.rs
文件之后,测试输出中将不会出现这一部分。tests
目录中的子目录不会被作为单独的crate
编译或作为一个测试结果部分出现在测试输出中。 -
一旦拥有了
tests/common/mod.rs
,就可以将其作为模块以便在任何集成测试文件中使用。这里是一个tests/integration_test.rs
中调用setup
函数的it_adds_two
测试的例子:
use adder;
mod common;
#[test]
fn it_adds_two() {
common::setup();
assert_eq!(4, adder::add_two(2));
}
二进制crate的集成测试
-
如果项目是二进制
crate
并且只包含src/main.rs
而没有src/lib.rs
,这样就不可能在tests
目录创建集成测试并使用extern crate
导入src/main.rs
中定义的函数。只有库crate
才会向其他crate
暴露了可供调用和使用的函数;二进制crate
只意在单独运行。 -
为什么 Rust 二进制项目的结构明确采用
src/main.rs
调用src/lib.rs
中的逻辑的方式?因为通过这种结构,集成测试 就可以 通过extern crate
测试库crate
中的主要功能了,而如果这些重要的功能没有问题的话,src/main.rs 中的少量代码也就会正常工作且不需要测试。
总结
Rust
的测试功能提供了一个确保即使你改变了函数的实现方式,也能继续以期望的方式运行的途径。单元测试独立地验证库的不同部分,也能够测试私有函数实现细节。集成测试则检查多个部分是否能结合起来正确地工作,并像其他外部代码那样测试库的公有API
。即使Rust
的类型系统和所有权规则可以帮助避免一些bug
,不过测试对于减少代码中不符合期望行为的逻辑bug
仍然是很重要的。