【Rust 学习记录】11. 编写自动化测试

TwoSix Lv3

这一章讲的就是怎么在Rust编写单元测试代码,这一部分的思想不仅适用于Rust,在绝大多数语言都是有用武之地的

如何编写测试

测试代码的构成

构成

通用测试代码通常包括三个部分

  1. 准备所需的数据或者前置状态
  2. 调用需要测试的代码
  3. 使用断言,判断运行结果是否和我们期望的一致

在Rust中,有专门用于编写测试代码的相关功能,包含test属性,测试宏,should_panic属性等等

在最简单的情况下,Rust中的测试就是一个标注有test属性的函数。只需要将#[test]添加到函数的关键字fn上,就能使用cargo test命令来运行测试。

测试命令会构建一个可执行文件,调用所有标注了test的函数,生成相关报告。

PS:

属性是一种修饰代码的一种元数据,例如之前为了输出结构体时,加入的#[derive(Debug)]就是一个属性,声明属性后,会为下面的代码自动生成一些实现,如#[derive(Debug)]修饰结构体时,就会为结构体生成Debug trait的实现

初次尝试

接下来我们就试试怎么测试

首先新建一个名为adder的项目cargo new adder --lib(–lib指生成lib.rs文件)

可能是版本比较新,lib.rs里直接生成有了以下代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
pub fn add(left: usize, right: usize) -> usize {
left + right
}

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

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

我们先忽略一些没讲过的关键词,这段代码里我们定义了一个it_works函数,并标注为测试函数,然后使用断言判断add函数的结果是否正确的等于4。在了解了大概功能之后,我们直接运行测试看看

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
cargo test
Compiling adder v0.1.0 (E:\Code\rust\adder)
Finished test [unoptimized + debuginfo] target(s) in 0.29s
Running unittests src\lib.rs (target\debug\deps\adder-0a86cd050490705e.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

对应的测试结果如上

  • passed: 测试通过的函数数量,我们这里只有一个it_works函数,且测试通过,所以为1
  • failed: 测试失败的函数数量
  • ignored: 被标记为忽略的测试函数,后面会提
  • measured: Rust还提供了衡量函数性能的benchmark方法,不过编写书的时候似乎这部分还不完善,所以不会有讲解,想了解需要自行学习
  • filtered out:被过滤掉的测试函数
  • Doc-tests:文档测试,这是个很好用的特性,可以防止你在修改了函数之后,忘记修改自己的文档,保证文档能和实际代码同步。

测试时,每一个测试函数都是运行在独立的线程里的,所以发生panic时并不会影响其他的测试,我们可以写一个错误的函数看看

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
pub fn add(left: usize, right: usize) -> usize {
left + right
}

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

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

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

输出结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
cargo test
Compiling adder v0.1.0 (E:\Code\rust\adder)
Finished test [unoptimized + debuginfo] target(s) in 0.24s
Running unittests src\lib.rs (target\debug\deps\adder-0a86cd050490705e.exe)

running 2 tests
test tests::it_works ... ok
test tests::error ... FAILED

failures:

---- tests::error stdout ----
thread 'tests::error' panicked at 'assertion failed: `(left == right)`
left: `5`,
right: `4`', src\lib.rs:18:9
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
tests::error

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`

可以看见,error的panic并不影响it_works的测试通过。

assert!宏

assert!

assert!宏主要的功能是用来确保某个值为true,所以常被用于测试中。如a>b等场景,返回的是一个bool值,就完美的符合assert!的使用场景,可以使用assert!进行测试,例如

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
pub fn cmp(a: i32, b: i32) -> bool {
if a>b{
true
}
else{
false
}
}

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

#[test]
fn cmp_test(){
let result = cmp(3, 2);
assert!(result);
}
}

assert_eq!和assert_ne!

那如果返回值不是bool值呢?前面也出现过了,我们可以使用assert_eq!或者assert_ne!来断言两个值是否相等。

eq则对应的只有相等才能通过断言,ne则对应的只有不相等才能通过断言,用例见上面的add测试即可。

但是注意,assert_eq!和assert_ne!使用了==!=来实现是否相等的判断,也就意味着,传入这两个宏的参数是必须实现了PartialEq这个trait的。同时,我们可见错误的输出中会打印出详细的不相等原因,也就是说它还同时需要实现了Debug宏帮助打印输出。一般绝大部分参数都是满足要求的,自定义的结构体时需要注意。

之前提到过属性这个概念,会为你自动实现一些功能,实际上PartialEq和Debug作为可派生的宏,也内置了属性的实现,你只需要在自己定义的结构体上加上#[derive(PartialEq, Debug)],就能自动帮你实现这两个宏

自定义错误提示代码

上面我们说到assert_eq是会有详细输出的,告诉你怎么不相等了,帮助你排除bug,但普通的assert!只判断布尔值,所以没办法有详细的输出,这时候我们可以定制一个输出,使得错误提示更人性化一点。

如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
pub fn cmp(a: i32, b: i32) -> bool {
if a>b{
true
}
else{
false
}
}

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

#[test]
fn cmp_test(){
let a = 2;
let b = 3;
let result = cmp(a, b);
assert!(result, "{} is not bigger than {}", a, b);
}
}

和一般不一样,我们不需要用什么格式化字符串的方法先格式化一个字符串,再传入这个字符串,断言支持直接使用格式化的语法。这段代码的输出如下

1
2
3
running 1 test
thread 'tests::cmp_test' panicked at '2 is not bigger than 3', src\lib.rs:23:9
stack backtrace:

可见报错提示相对于单纯的panicked at更人性化了一些。

当然,自定义输出也支持在assert_eq和assert_ne里使用

should_panic

should_panic也是一个属性,用来测试代码是否能正确的在出错时发生panic。用例如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
pub fn positive_num(a: i32) -> i32 {
if a > 0 {
a
} else {
panic!("{} is not positive", a)
}
}

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

#[test]
#[should_panic]
fn pos_test(){
let a = -1;
positive_num(a);
}
}

这段代码用来检查一个数是否是正数,在不是时抛出panic,接下来我们使用#[should_panic]来检查程序是否正确的panic,这段代码运行测试通过没问题。

接下来我们修改一下a的值,让程序不抛出panic,看看会发生什么

1
2
3
4
5
6
7
8
9
10
11
12
13
14
running 1 test
test tests::pos_test - should panic ... FAILED

failures:

---- tests::pos_test stdout ----
note: test did not panic as expected

failures:
tests::pos_test

test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

error: test failed, to rerun pass `-p adder --lib`

测试失败,告诉你pos_test没有按照预期发生panic。这个特性可以用来检查你的代码是否能正确的处理报错,发生panic以阻止程序进一步运行,产生不可预估的后果。

但是,单纯这么使用感觉有点含糊不清,因为程序发生panic的原因可能不是我们所预期的,假如其他一些我们不知道的原因抛出了panic,也会导致测试通过。所以我们可以添加一个可选参数expected,用来检查panic发生报错的输出信息里是否包含指定的文字。

1
2
3
4
5
6
#[test]
#[should_panic(expected = "positive")]
fn pos_test(){
let a = -1;
positive_num(a);
}

这时候,should_panic就会检查发生的panic输出的报错信息是否包含”positive”这个字符串,如果是,才会测试通过,输出如下:

1
2
3
running 1 test
thread 'tests::pos_test' panicked at '-1 is not positive', src\lib.rs:9:9
stack backtrace:

可见,输出中也包含了报错的信息,更人性化了。

使用Result编写测试

之前学习Result枚举的时候我们就知道了这东西是用来处理报错的,自然也就可以用来处理测试。使用时也很简单,我们只需要声明测试函数的返回值是Result,test命令就会自动根据Result的枚举结果来判断是否测试成功了。如:

1
2
3
4
5
6
7
8
9
10
11
12
13
#[cfg(test)]
mod tests {
use super::*;

#[test]
fn it_works() -> Result<(), String>{
if 2+2 == 4 {
Ok(())
} else {
Err(String::from("two plus two does not equal four"))
}
}
}

使用Result编写测试函数的主要优势是可以使用问号表达式进行错误捕获,更方便我们去编写一些复杂的测试函数,可以让函数在任一时刻有错误被捕获到时,就返回报错。

问号表达式的使用可见【Rust 学习记录】9. 错误处理的?运算符部分,这里就不再写代码举例了(主要书上没例子,我也懒的写)

控制测试的运行方式

这一部分主要是对cargo test命令的讲解,具体的运行方式,参数的使用等。

cargo test的参数统一需要写在--后面,也就是说你想要使用--help显示参数文档时,需要使用以下命令

1
cargo test -- --help

并行或串行的执行代码

默认情况下,测试是多线程并发执行的,这可以使测试更快速的完成,且相互之间不会影响结果。但如果测试间有相互依赖关系,则需要串行执行。例如两个测试用例同时在操作一个文件,一个测试在写内容,一个测试在读内容时,则容易导致测试结果不合预期。

我们可以使用--test-threads=1来指定测试的线程数为1,即可实现串行执行,当然,你想执行的更快也可以指定更多的线程

1
cargo test -- --test-threads=1

显示函数的输出

默认情况下,test命令会捕获所有测试成功时的输出,也就是说,对于测试成功的函数,即使你使用了println!打印输出,你也无法在控制台看见你的输出,因为它被test命令捕获吞掉了。

如果你想要在控制台显示你的输出,只需要用--nocapture设置不捕获输出即可

1
cargo test -- --nocapture

只运行部分特定名称的测试

如果测试的函数越写越多,执行所有的测试可能很花时间,通常我们编写了一个新的功能并想进行测试的时候,我们只需要测试这一个功能就足够了,因此可以向test命令指定函数名称来进行测试。

如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
fn add_two(a: i32, b: i32) -> i32 {
a + b
}


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

#[test]
fn add_test_1() {
assert_eq!(add_two(1, 2), 3);
}

#[test]
fn add_test_2() {
assert_eq!(add_two(2, 2), 4);
}

#[test]
fn one_hundred() {
assert_eq!(100, 100);
}

}

对于这段代码,我们只想测试one_hundred这个函数,只需要对test命令指定运行one_hundred即可

1
cargo test one_hundred

输出如下:

1
2
3
4
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

这里显示2 filtered out,代表有两个测试用例被我们过滤掉了。

当然,这个方法也并不是只能运行一个测试函数,也可以通过部分匹配的方法执行多个名称里包含相同字符串的测试函数,例如:

1
2
3
4
5
6
7
cargo test add        

running 2 tests
test tests::add_test_1 ... ok
test tests::add_test_2 ... ok

test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 1 filtered out; finished in 0.00s

我们使用cargo test add命令,则可以测试所有名字里带add的测试函数,忽略掉了one_hundred函数。

不过需要注意的是,这种方法一次只能使用一个参数进行匹配并测试,如果你想同时用多个规则匹配多类的测试函数,就需要用其他方法了。

通过显示指定来忽略某些测试

忽略部分测试函数

当有部分测试函数执行特别耗时时,我们不想每次测试都执行这个函数,我们就可以通过#[ignore]属性来显示指定忽略这个测试函数。如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
fn add_two(a: i32, b: i32) -> i32 {
a + b
}


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

#[test]
fn add_test_1() {
assert_eq!(add_two(1, 2), 3);
}

#[test]
#[ignore]
fn add_test_2() {
assert_eq!(add_two(2, 2), 4);
}

#[test]
fn one_hundred() {
assert_eq!(100, 100);
}

}

此时我们直接执行cargo test,输出如下

1
2
3
4
5
6
7
8
running 3 tests
test tests::add_test_2 ... ignored
test tests::add_test_1 ... ok
test tests::one_hundred ... ok

test result: ok. 2 passed; 0 failed; 1 ignored; 0 measured; 0 filtered out; finished in 0.00s

Doc-tests adder

可见add_test_2函数被忽略不执行了,提示1 ignored。

单独执行被忽略的测试函数

如果我们想单独执行这些被忽略的函数,则可以使用--ignored命令

1
2
3
4
5
6
cargo test -- --ignored

running 1 test
test tests::add_test_2 ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 2 filtered out; finished in 0.00s

可见,只有add_test_2被执行了。

测试的组织结构

测试通常分为两类,单元测试和集成测试。单元测试小而专注,集中于测试一个私有接口或模块;集成测试则独立于代码库之外,正常的从外部调用公共接口,一次测试可能使用多个模块

单元测试

单元测试的目的在于将一小段代码单独隔离开来,快速确定代码结果是否符合预期。一般来说,单元测试的代码和需要测试的代码存放在同一文件中。同时也约定俗成的在每个源代码文件里都会新建一个tests模块来存放测试函数,并使用cfg(test)来标注。

测试模块和#[cfg(test)]

#[cfg(test)]旨在让Rust只在执行Cargo test命令的时候编译和运行这段代码,而在cargo build的时候剔除掉它们,只用于测试,节省编译时间与空间,使得我们可以更方便的把测试代码和源代码放在同一个文件里。(集成测试时一般不需要标注,因为集成测试一般是独立的一个文件)

我们之前编写的测试模块就使用了这个属性

1
2
3
4
5
6
7
8
9
10
#[cfg(test)]
mod tests {
use super::*;

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

测试私有函数

是不是应该测试私有函数一直有争议,不管你觉得要不要,但Rust提供了方法供你方便的测试。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
fn add(left: usize, right: usize) -> usize {
left + right
}

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

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

以上代码中的add没有标注pub关键字,也就是私有的,但因为Rust的测试代码本身也属于Rust代码,所以可以通过use的方法把私有的函数引入当前作用域来测试,也就是对应的代码里的use super::*;

集成测试

集成测试通常是新建一个tests目录,只调用对外公开的那部分接口。

tests目录

tests目录需要和src文件夹并列,Cargo会自动在这个目录下面寻找测试文件。

现在,我们新建一个tests/integration_test.rs文件,保留之前的lib.rs代码(add函数如果改了私有记得改回公有),并编写测试代码。

1
2
3
4
5
6
use adder;

#[test]
fn add_two() {
assert_eq!(adder::add(2, 2), 4);
}

集成测试就不需要#[cfg(test)]了,Rust有单独为tests目录做处理,不会build这个目录下的文件。

接下来,我们再执行以下Cargo test,看看输出

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
cargo test
Compiling adder v0.1.0 (E:\Code\rust\adder)
Finished test [unoptimized + debuginfo] target(s) in 0.32s
Running unittests src\lib.rs (target\debug\deps\adder-0a86cd050490705e.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

Running tests\integration_test.rs (target\debug\deps\integration_test-57f19c149db40d76.exe)

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

我们可以看到输出里

  1. 先输出了单元测试的结果,test tests::it_works … ok,每行输出一个单元测试结果
  2. 再输出集成测试的结果,Running tests\integration_test.rs,表示正在测试哪个文件的测试模块,后续跟着这个文件的测试结果
  3. 最后是文档测试

当编写的测试代码越多,输出也就会越多越杂,所以我们也可以使用--test参数指定集成测试的文件名,单独进行测试。如:cargo test --test integration_test

在集成测试中使用子模块

测试模块也和普通模块差不多,可以把函数分解到不同文件不同子目录里,当我们需要测试内容越来越多的时候,就会需要这么做。

但因为测试的特殊性,rust会把每个集成测试的文件编译成独立的包来隔离作用域,模拟用户实际的使用环境,这就意味着我们以前在src目录下管理文件的方法并不完全适用于tests目录了。

例如,我们需要编写一个common.rs文件,并且编写一个setup函数,这个函数将会用在多个不同的测试文件里使用,如

1
2
3
4
5
pub fn setup(){
// 一些测试所需要初始化的数据
a = 1;
a
}

我们执行cargo test时会有以下输出:

1
2
3
4
5
     Running tests\common.rs (target\debug\deps\common-15055e88a26e37ec.exe)

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

可以发现,即便我们没有在common.rs里写任何测试函数,它依旧会将它作为测试文件执行,并输出无意义的running 0 tests。这明显不是我们所希望的,那如何解决呢?

我们可以使用mod.rs文件,把common.rs的文件内容移到tests/common/mod.rs里面,这样的意思是让Rust把common视作一个模块,而不是集成测试文件。

于是,我们就可以通过mod关键字引入common模块并使用其中的函数,例如

1
2
3
4
5
6
7
8
9
10
// tests/integration_test.rs
use adder;

mod common;

#[test]
fn add_two() {
common::setup();
assert_eq!(adder::add(2, 2), 4);
}

此时再运行cargo test,就不会出现common相关的测试输出了。

二进制包的集成测试

如果我们的项目只有src/main.rs而没有src/lib.rs的话,是没有办法在tests中进行集成测试的,因为只有把代码用lib.rs文件指定为一个代码包crate,才能把函数暴露给其他包来使用,而main.rs对应的是二进制包,只能单独执行自己。

所以Rust的二进制项目通常会把逻辑编写在src/lib.rs里,main.rs只对lib.rs的内容进行简单的调用。

总结

没什么好总结的,下一章写项目去了。

  • 标题: 【Rust 学习记录】11. 编写自动化测试
  • 作者: TwoSix
  • 创建于 : 2023-05-09 16:00:30
  • 更新于 : 2024-07-04 23:52:28
  • 链接: https://twosix.page/2023/05/09/【Rust-学习记录】11-编写自动化测试/
  • 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。
评论