22_rust_自动化测试

编写自动化测试

编写和运行测试

一个测试就是一个函数,用于验证非测试代码的功能是否和预期一致。
测试函数体通常执行3个操作(3A):

  • Arrange:准备数据/状态
  • Act:运行被测代码
  • Assert:断言结果

测试函数:

  • 测试函数需要使用test属性(attribute)进行标注,Attribute就是一段rust代码的元数据,不会改变被修饰代码逻辑,只是对修饰代码进行修饰或标注。(如原来debug的标注)
  • 在函数上加#[test],可把函数变成测试函数。

运行测试

使用cargo test命令运行所有测试函数,rust会构建一个TestRunner可执行文件,会运行标注了test的函数,并报告运行是否成功。
当使用cargo创建library项目时,会生成一个test module,里面有一个test函数,可添加任意多个test module或函数。
执行命令:

cargo new test_lib --lib

会在当前目录下创建出test_lib,并会创建出test_lib/src/lib.rs文件,默认内容如下:

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);
    }
}

含有测试函数,因为有#[test]修饰。新增一个用例:

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);
    }
    #[test]
    fn new_add_case() { // 新增用例
        let result = add(2, 3);
        assert_eq!(result, 4);
    }
}

执行cargo test命令,可看到如下运行结果:

   Compiling test_lib v0.1.0 (D:\courses\rust\01_hello_cargo\test_lib)
    Finished test [unoptimized + debuginfo] target(s) in 1.22s
     Running unittests src\lib.rs (target\debug\deps\test_lib-d48d3291e5f263a6.exe)

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

failures:

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


failures:
    tests::new_add_case

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

测试失败

  • 测试函数panic则表示失败
  • 每个测试运行在一个新线程里。
  • 当主线程监视某个测试线程挂了,则测试标记失败。

继续新增一个用例:

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);
    }
    #[test]
    fn new_add_case() {
        let result = add(2, 2);
        assert_eq!(result, 4);
    }
    #[test]
    fn test_panic() {
        panic!("this case failed");
    }
}

运行结果

cargo test
   Compiling test_lib v0.1.0 (D:\courses\rust\01_hello_cargo\test_lib)
    Finished test [unoptimized + debuginfo] target(s) in 0.29s
     Running unittests src\lib.rs (target\debug\deps\test_lib-d48d3291e5f263a6.exe)

running 3 tests
test tests::it_works ... ok
test tests::test_panic ... FAILED
test tests::new_add_case ... ok

failures:

---- tests::test_panic stdout ----
thread 'tests::test_panic' panicked at 'this case failed', src\lib.rs:21:9
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
    tests::test_panic

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

断言(Assert)

assert!宏,来自标准库,用来确定某个状态是否为true,如果传入为true,则测试通过,如果为false,则会调用panic!宏,测试失败。

#[derive(Debug)]
pub struct Rect {
    l: u32,
    w: u32,
}
impl Rect {
    pub fn can_hold(&self, o: &Rect) -> bool {
        self.l > o.l && self.w > o.w
    }
}

#[cfg(test)]
mod tests { // test模块,和其他模块一样
    use super::*; // 因为要使用模块外的结构体和函数,导入外部所有内容

    #[test]
    fn test1() {
        let larger = Rect {
            l: 8,
            w: 9,
        };
        let small = Rect {
            l: 5,
            w: 6,
        };
        assert!(larger.can_hold(&small));
        assert!(!small.can_hold(&larger));
    }
}

执行cargo test测试通过。

assert_eq!和assert_ne!

都来自标注库,用于判断两个参数是否相等或不等,实际上使用的就是==和!=运算符,如果断言失败,能够自动打印出两个参数的值,需要使用debug格式打印参数,这就要求参数实现了PartialEq和Debug Traits,所有的基本类型和标准库里大部分类型都实现了。

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);
        assert_ne!(5, add(3, 1));
    }
}

添加自定义错误消息

可向assert!、assert_eq!、assert_ne!添加可选的自定义消息。

  • 自定义消息和失败消息都会打印出来。
  • assert!:第1个参数必填,自定义消息作为第2个参数。
  • assert_eq!和assert_ne!:前2个参数必填,自定义消息作为第3个参数。
  • 自定义消息参数会被传递给format!宏,可使用{}占位符。
pub fn str_format(nm: &str) -> String {
    format!("input: {}", nm)
}

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

    #[test]
    fn it_works() {
        let result = str_format("test_str");
        assert!(result.contains("test_str"), "check:{}", result); // 断言通过不会打印
        assert!(result.contains("test_str2"), "not contain\"{}\"", result); // 断言失败,打印出后面的错误信息
    }
}

用should_panic检查恐慌

测试除了验证代码的返回值是否正确,还需验证代码是否如预期的处理了发生错误的情况。比如可验证代码在特定情况下是否发生了panic。这种情况需使用should_panic属性(attribute):

  • 提供特定输入使得被测函数发生了panic:测试通过
  • 提供特定输入被测函数没有发生panic:测试失败
pub struct St1 {
    v: u32,
}
impl St1 {
    pub fn new(v: u32) -> St1 {
        if v < 1 || v > 100 {
            panic!("need value 1~100, input v={}", v);
        }
        St1 { v }
    }
}
impl St1 {
    pub fn new2(v: u32) -> St1 {
        if v < 1 { // 比如忘写了一个边界条件,期望测试用例能测试出来,应发生panic但没发生
            panic!("need value 1~100, input v={}", v);
        }
        St1 { v }
    }
}
#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    #[should_panic]
    fn test1() { // 测试边界值,给定异常值后,发生panic,用例通过
        St1::new(200);
    }
    #[test]
    #[should_panic]
    fn test2() { // 给定异常值后,应发生panic但没发生,用例不通过
        St1::new2(200);
    }
}
/**
 * running 2 tests
test tests::test2 - should panic ... FAILED
test tests::test1 - should panic ... ok
 */

但should_panic不够精确,在复杂代码中,即便不是预期的panic!发生了,比如不是测试点的panic而是其他地方的panic,测试用例也能通过。所以需要一种让should_panic更精确的方法。
可为should_panic属性添加一个可选的expected参数,将检查失败消息中是否包含所指定的文字

pub struct St1 {
    v: u32,
}
impl St1 {
    pub fn new(v: u32) -> St1 {
        if v < 1 {
            panic!("need > 1, input v={}", v);
        }
        if v > 100 {
            panic!("need < 100, input v={}", v);
        }
        St1 { v }
    }
}

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

    #[test]
    #[should_panic(expected = "need < 100")]
    fn test1() { // panic的消息包含expected指定的信息,测试通过
        St1::new(200);
    }
    #[test]
    #[should_panic(expected = "need < 100")]
    fn test2() { // panic的消息不包含expected指定的信息,测试失败
        St1::new(0);
    }
}
/**
running 2 tests
test tests::test1 - should panic ... ok
test tests::test2 - should panic ... FAILED

failures:

---- tests::test2 stdout ----
thread 'tests::test2' panicked at 'need > 1, input v=0', src\lib.rs:40:13
note: panic did not contain expected string
      panic message: `"need > 1, input v=0"`,
 expected substring: `"need < 100"`

failures:
    tests::test2
*/

在测试中使用Result<T, E>

在测试中,运行失败的不止是panic,还可使用Result达到预期失败的目的。
在测试中,无需panic,可使用Result<T, E>作为返回类型编写测试:

  • 返回Ok:测试通过
  • 返回Err:测试失败
    注:不要在使用Result<T, E>编写的测试上标注#[should_panic]
pub fn add_func(v1: u32, v2: u32) -> u32 {
    v1 + v2
}

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

    #[test]
    fn test1() -> Result<(), String> { // 成功,返回Ok
        if add_func(2, 3) == 5 {
            Ok(())
        } else {
            Err(String::from("not expect value 5"))
        }
    }
    #[test]
    fn test2() -> Result<(), String> { // 成功,返回Err
        if add_func(2, 3) == 4 {
            Ok(())
        } else {
            Err(String::from("not expect value 5"))
        }
    }
}
// running 2 tests
// test tests::test1 ... ok
// test tests::test2 ... FAILED
// failures:

// ---- tests::test2 stdout ----
// Error: "not expect value 5"

控制测试如何运行

可使用添加命令行参数的方式,改变cargo test的行为。
如果直接执行cargo test命令,会执行默认行为:

  • 并行执行测试
  • 执行所有测试
  • 捕获(不显示)所有输出,成功时不打印plrinln打印,使读取与测试结果相关的输出更简洁。

增加命令行参数的方式:

  • 针对cargo test的参数:紧跟cargo test后
  • 针对测试可执行程序的参数:放在-- 之后
cargo test --help
cargo test -- --help # 打印测试可执行程序的Help信息,有个编译过程,会显示所有可放在--之后的参数

并行/串行运行测试

rust会并行运行多个测试,默认使用多个线程并行运行,好处是运行快。不过需确保测试之间不会相互依赖,不依赖于某个共享状态,比如环境、工作目录、环境变量等信息。

如果不想并行测试,或者设置测试时的参数,可设置--test-threads选项参数

  • 传递给二进制文件
  • 不想以并行方式运行测试,或想对线程数进行细粒度控制
  • 选项后跟线程数量
cargo test -- --test-threads=1 // 设置为单线程测试

显式函数输出

默认,若测试通过,rust的test库会捕获所有打印到标准输出内容,比如被测代码中用到了println!:

  • 若测试通过,则不会在终端看到println!打印的内容
  • 若测试失败,则可看到println!打印的内容和失败信息

如果想在成功的测试中看到打印的内容,则使用选项:--show-output

cargo test -- --show-output

按名称运行测试的子集

选择运行的测试,将测试的名称(一个或多个)作为cargo test的参数。

  • 运行单个测试:指定测试名
  • 运行多个测试:指定测试名的一部分(模块名也可)
cargo test test_case1 # 只跑一个用例
cargo test test_ # 跑以test_开头的用例
cargo test test_modu1 # 跑test_modu1模块的用例

忽略某些测试

通过命令控制忽略某些测试,运行剩余测试。比如一些测试比较耗时,不会在每次都跑,仅在特定的时候跑一次。

  • 对用例添加ignore属性(attribute),让用例正常被忽略。
  • 运行被忽略(ignore)的测试,使用命令cargo test -- --ignored
pub fn add_func(v1: u32, v2: u32) -> u32 {
    v1 + v2
}

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

    #[test]
    fn test1() -> Result<(), String> {
        if add_func(2, 3) == 5 {
            Ok(())
        } else {
            Err(String::from("not expect value 5"))
        }
    }
    #[test]
    #[ignore] // 被忽略的测试用例
    fn test2() -> Result<(), String> {
        if add_func(2, 3) == 5 {
            Ok(())
        } else {
            Err(String::from("not expect value 5"))
        }
    }
}

对上面测试代码进行测试:

# 默认测试命令,ignore属性用例被忽略
> cargo test
running 2 tests
test tests::test2 ... ignored
test tests::test1 ... ok
# 跑忽略的用例
> cargo test -- --ignored
running 1 test
test tests::test2 ... ok

测试的分类

rust对测试分两类:单元测试、集成测试。

  • 单元测试:
    • 小、专注
    • 一次对一个模块进行隔离测试
    • 可测private接口
  • 集成测试:
    • 在库外部,和其它外部代码一样使用代码接口
    • 只能使用public接口
    • 可在每个测试中使用多个模块

单元测试

通常用于函数级等小段代码隔离出来,测试功能是否符合预期,常把单元测试代码和被测代码都放在src目录下的同一个文件中,约定俗成的对每个文件都创立一个test模块来放单元测试代码。

1)单元测试使用#[cfg(test)]标注,tests模块上的#[cfg(test)]标注只有运行catgo test才编译和运行代码,运行cargo build不会编译运行。

2)如果集成测试在不同的目录,则不需要#[cfg(test)]标注。

cfg:configuration(配置)的缩写:

  • 告诉rust下的条目只有在指定的配置选项下才被包含;
  • 配置选项test:由rust提供,用来编译和运行测试。只有cargo test才会编译代码,包括模块中的helper函数和#[test]标注的函数。
#[cfg(test)]
mod tests {
    #[test]
    fn test1 () {
        assert_eq!(1, 1);
    }
}
// 只有运行cargo test的时候才会将上面代码纳入编译范围。

测试私有函数 :rust运行测试私有函数

pub fn add_const(v: u32) -> u32 {
    add_func(v, 5)
}
fn add_func(v1: u32, v2: u32) -> u32 {
    v1 + v2
}

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

    #[test]
    fn test1() {
        assert_eq!(6, add_func(1, 5)); // 测试私有函数
    }
}

集成测试

在rust里,集成测试完全位于被测试库的外部,目的是测试被测库的多个部分是否能正常的联合运行,所以集成测试的覆盖率很重要。

创建集成测试,首先需要创建tests目录,与src目录并列,cargo会自动在tests目录下寻找测试用例。cargo会将tests目录下的每个测试文件处理成一个单独的包,对应的tests目录下每个测试文件都是单独的一个crate。

创建一个test_lib项目

# 1. 创建项目
cargo create test_lib
# 2. 在test_lib目录下创建tests目录和测试文件
mkdir tests
touch tests/integration_test.rs

在test_lib/src/lib.rs文件中的内容:

pub fn add_const(v: u32) -> u32 {
    add_func(v, 5)
}
fn add_func(v1: u32, v2: u32) -> u32 {
    v1 + v2
}

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

    #[test]
    fn test1() {
        assert_eq!(6, add_func(1, 5)); // 测试私有函数
    }
}

在test_lib/tests/integration_test.rs的内容

use test_lib;

#[test]
fn test_add_const() {
    assert_eq!(8, test_lib::add_const(3));
}

执行测试用例:

> cargo test
   Compiling test_lib v0.1.0 (D:\courses\rust\01_hello_cargo\test_lib)
    Finished test [unoptimized + debuginfo] target(s) in 0.44s
     Running unittests src\lib.rs (target\debug\deps\test_lib-d48d3291e5f263a6.exe)

running 1 test
test tests::test1 ... 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-b6f79ec0788388c8.exe)

running 1 test
test test_add_const ... ok  # 这就是集成测试的打印,用例运行通过

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

   Doc-tests test_lib
  • 集成测试无需标注#[cfg(test)],tests目录会被特殊对待,只有运行cargo test命令,才会编译tests目录下的文件。

运行指定的集成测试

  • 运行一个特定的集成测试:cargo test 函数名
  • 运行某个测试文件内的所有测试:cargo test --test 文件名
> cargo test test_add_const
running 1 test
test test_add_const ... ok

> cargo test --test integration_test
running 2 test
test test_add_const ... ok
test test_add_const2 ... ok

集成测试中的子模块

tests目录下每个文件被编译成单独的crate,这些文件不共享行为(与src下的文件规则不同),但如果想创建一个公共的功能的函数,在多个集成测试文件中使用,比如一些setup函数,用于所有用例的初始化。如果在tests目录下直接创建一个存放setup的文件mod.rs,会发现执行cargo test的时候也会运行mod.rs,因为就是把mod.rs也当成测试用例文件了。

要达到子模块的目的,可在tests目录下创建一个common目录,再创建mod.rs文件:

mkdir test_lib/tests/common
touch test_lib/tests/common/mod.rs

test_lib/tests/common/mod.rs的内容

pub fn setup() {
    println!("setup run");
}

因为是子目录,cargo test不会视为单独的crate进行编译,也不会在运行测试用例时运行。使用方法如下:

(test_lib/tests/integration_test.rs的内容)

use test_lib;
mod common; // 导入子模块

#[test]
fn test_add_const() {
    common::setup();
    assert_eq!(9, test_lib::add_const(3));
}

针对binary crate的集成测试

如果项目是binary crate,只含有src/main.rs没有src/lib.rs:

  • 不能在tests目录下创建集成测试
  • 也无法把main.rs的函数导入作用域

只有library crate才能暴露函数给其它crate使用,binary crate只能独立运行,通常rust项目,会把代码逻辑放在lib.rs项目里,而main.rs通常只有一些简单的调用,这样可对lib rs创建测试代码,main.rs里的少量胶水代码则不用独立测试了。

posted @ 2023-10-31 00:43  00lab  阅读(12)  评论(0编辑  收藏  举报