Rust 学习笔记(22)-测试

参考章节《Rust 程序设计语言》第11章 编写自动化测试

为你的程序编写测试是一个良好的习惯(尽管大部分人不愿意这么做,包括我自己,囧)

我们来看一个简单的测试用例

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
#[cfg(test)]
mod tests {
    #[test]
    fn it_works() {
        let a = true;
        assert!(a); // assert! 判断测试的条件是否为 true。需要向 assert! 宏提供一个求值为布尔值的参数。
        assert_eq!(2 + 2, 4); // assert_eq! 宏用于测试表达式的值是否与期望值相等,可以通过向 assert! 宏传递一个使用 == 运算符的表达式来做到。
        assert_ne!(2 + 2, 3); // 相同的还有 assert_ne! 测试表达式的值是否与期望值不等
    }
}

#[cfg(test)] 注解告诉 Rust 只在执行 cargo test 时才编译和运行测试代码,而在运行 cargo build 时不这么做。
为了将一个函数变成测试函数,还需要在 fn 行之前加上 #[test] 注解。
assert! 判断测试的条件是否为 true。需要向 assert! 宏提供一个求值为布尔值的参数。
assert_eq! 宏用于测试表达式的值是否与期望值相等,可以通过向 assert! 宏传递一个使用 == 运算符的表达式来做到。
assert_ne! 宏用于测试表达式的值是否与期望值不等,可以通过向 assert! 宏传递一个使用 == 运算符的表达式来做到。

测试什么时候算失败?

  1. 当测试函数中出现 panic 时测试就算失败了。每一个测试都在一个新线程中运行,当主线程发现测试线程异常了,就将对应测试标记为失败。
1
2
3
4
5
6
7
#[cfg(test)]
mod tests {
    #[test]
    fn it_works() {
        panic!("Make this test fail");
    }
}
  1. 当测试函数返回 Result<T, E> 并且返回的成员是 Err 时测试也算失败
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
#[cfg(test)]
mod tests {
    #[test]
    fn result_test() -> Result<(), String> {
        if 2 + 2 == 5 {
            Ok(())
        } else {
            Err(String::from("2 + 2 != 5"))
        }
    }
}

注意: 不能对这些使用 Result<T, E> 的测试使用 #[should_panic] 注解。

自定义失败信息

你也可以向 assert!assert_eq!assert_ne! 宏传递一个可选的失败信息参数,可以在测试失败时将自定义失败信息一同打印出来。
assert!assert_eq!assert_ne! 的必需参数之后指定的参数都会传递给 format! 宏,所以可以传递一个包含 {} 占位符的格式字符串和需要放入占位符的值。
自定义信息有助于记录断言的意义;当测试失败时就能更好的理解代码出了什么问题。

1
2
3
4
5
6
7
8
#[cfg(test)]
mod tests {
    #[test]
    fn it_works() {
        let result = 2 + 2;
        assert_eq!(result, 5, "2 + 2 == {}", result);
    }
}

cargo test 运行上面的代码

1
2
3
4
5
---- tests::it_works stdout ----
thread 'tests::it_works' panicked at 'assertion failed: `(left == right)`
  left: `4`,
 right: `5`: 2 + 2 == 4', src/lib.rs:6:9 <-- 这一行就会有我们的自定义信息
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

使用 should_panic 检查 panic

除了检查代码是否返回期望的正确的值之外,检查代码是否按照期望处理错误也是很重要。

1
2
3
4
5
6
7
8
9
#[cfg(test)]
mod tests {
    #[test]
    #[should_panic] // #[should_panic] 注解用来指明该函数应该发生painc,如果没有painc则测试失败
    fn panic_test1() {
        let v = vec![1, 2, 3];
        println!("{:?}", v[10]);
    }
}

然而 should_panic 测试结果可能会非常含糊不清,因为它只是告诉我们代码并没有产生 panic
should_panic 甚至在一些不是我们期望的原因而导致 panic 时也会通过。
为了使 should_panic 测试结果更精确,我们可以给 should_panic 属性增加一个可选的 expected 参数。测试工具会确保错误信息中包含其提供的文本。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
#[cfg(test)]
mod tests {
    #[test]
    #[should_panic(expected = "v = 1")] // 确保panic信息中包含v = 1
    fn panic_test2() {
        let v = vec![1, 2, 3];
        // println!("{:?}", v[10]); // 虽然引发了panic,但由于错误中不包含 v = 1 因此不算
        if v[0] == 1 {
            panic!("v = 1")
        }
    }
}

控制测试运行

关于参数的一些说明

可以将一部分命令行参数传递给 cargo test,而将另外一部分传递给生成的测试二进制文件。
为了分隔这两种参数,需要先列出传递给 cargo test 的参数,接着是分隔符 --,再之后是传递给测试二进制文件的参数。
运行 cargo test --help 会提示 cargo test 的有关参数,而运行 cargo test -- --help 可以提示在分隔符 -- 之后使用的有关参数。

  1. 如果你不希望测试并行运行,或者想要更加精确的控制线程的数量,可以传递 --test-threads 参数和希望使用线程的数量给测试二进制文件。
1
$ cargo test -- --test-threads=1
  1. 默认情况下,当测试通过时,Rust 会截获打印到标准输出的所有内容。可以在结尾加上 --show-output 告诉 Rust 显示成功测试的输出。
1
$ cargo test -- --show-output
  1. 只运行部分测试

有时运行整个测试集会耗费很长时间。如果你负责特定位置的代码,你可能会希望只运行与这些代码相关的测试。
你可以向 cargo test 传递所希望运行的测试名称的参数来选择运行哪些测试。

1
$ cargo test 测试函数名称

你还可以指定部分测试的名称,任何名称匹配这个名称的测试会被运行。

1
$ cargo test add // 这将运行了所有名字中带有 add 的测试

也可以使用 cargo test--test 后跟文件的名称来运行某个特定集成测试文件中的所有测试

1
$ cargo test --test  文件名
  1. 忽略某些测试

有时一些特定的测试执行起来是非常耗费时间的,所以在大多数运行 cargo test 的时候希望能排除他们。可以使用 ignore 属性来标记耗时的测试并排除他们

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
#[test]
fn it_works() {
    assert_eq!(2 + 2, 4);
}

#[test]
#[ignore] // 此时,运行 cargo test,这个测试将被排除
fn expensive_test() {
    // 需要运行一个小时的代码
}

如果我们只希望运行被忽略的测试

1
$ cargo test -- --ignored

如果你希望不管是否忽略都要运行全部测试

1
$ cargo test -- --include-ignored

测试的组织结构

单元测试与集成测试

单元测试倾向于更小而更集中,在隔离的环境中一次测试一个模块,或者是测试私有接口。
而集成测试对于你的库来说则完全是外部的。它们与其他外部代码一样,通过相同的方式使用你的代码,只测试公有接口而且每个测试都有可能会测试多个模块。

  • 单元测试

我们之前的所有测试用例都是单元测试
单元测试与他们要测试的代码共同存放在位于 src 目录下相同的文件中。
规范是在每个文件中创建包含测试函数的 tests 模块,并使用 cfg(test) 标注模块。

  • 集成测试

为了编写集成测试,需要在项目根目录创建一个 tests 目录,与 src 同级。Cargo 知道如何去寻找这个目录中的集成测试文件。
接着可以随意在这个目录中创建任意多的测试文件,Cargo 会将每一个文件当作单独的 crate 来编译。
集成测试不需要 cfg(test)

举个例子,我们新建一个库项目 adder 有如下代码 src/lib.rs

1
2
3
4
5
6
7
pub fn add_two(a: i32) -> i32 {
    internal_adder(a, 2)
}

fn internal_adder(a: i32, b: i32) -> i32 {
    a + b
}

然后在 src 同级目录创建一个 tests 目录,新建一个文件 tests/integration_test.rs

1
2
3
4
5
6
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 时编译这个目录中的文件。

  • 创建测试公共函数

当你有一些在多个集成测试文件都会用到的函数,你就可以将他们提取到一个通用的模块中,然后在测试文件中调用即可

我们将创建 tests/common/mod.rs ,而不是创建 tests/common.rs 。这是一种 Rust 的命名规范,这样命名告诉 Rust 不要将 common 看作一个集成测试文件。
tests 目录中的子目录不会被作为单独的 crate 编译或作为一个测试结果部分出现在测试输出中。

1
2
3
pub fn setup() {
    // setup code specific to your library's tests would go here
}

在测试文件中调用这个公共函数 tests/integration_test.rs

1
2
3
4
5
6
7
8
9
use adder;

mod common;

#[test]
fn it_adds_two() {
    common::setup();
    assert_eq!(4, adder::add_two(2));
}
  • Rust 允许你测试父模块中的私有函数
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
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));
    }
}

但不能测试别的模块中的私有函数

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
pub mod 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::adder::*;

    #[test]
    fn internal() {
        assert_eq!(4, internal_adder(2, 2)); // 你不能测试别的模块中的私有函数
    }
}

这章的内容很多,但是并不难,建议多敲几遍即可,千万不要只读,不写!读了!=会了

updatedupdated2024-10-012024-10-01