Loading

Rust 编写自动化测试

本文在原文基础上有删减,原文请参考


如何编写测试

Rust 中的测试函数是用来验证非测试代码是否是按照期望的方式运行的,测试函数体通常执行如下三种操作:

  • 设置任何所需的数据或状态
  • 运行需要测试的代码
  • 断言其结果是我们所期望的

测试函数剖析

Rust 中的测试就是一个带有 test 属性注解的函数,属性(attribute)是关于 Rust 代码片段的元数据。
在 fn 行之前加上 #[test] 将一个函数变成测试函数,使用 cargo test 命令运行测试,Rust 会构建一个测试执行程序用来调用被标注的函数并报告测试结果。

每次使用 Cargo 新建一个库项目时,它会自动为我们生成一个测试模块和一个测试函数

创建一个新的库项目 adder,它会将两个数字相加:

cargo new adder --lib

adder 库中 src/lib.rs 的内容如下:

//由 cargo new 自动生成的测试模块和函数
#[cfg(test)]
mod tests {
    #[test]  //这个属性表明这是一个测试函数
    fn it_works() {
        let result = 2 + 2;
        assert_eq!(result, 4);
    }
}

注:tests 模块中也可以有非测试的函数来帮助我们建立通用场景或进行常见操作,必须每次都标明哪些函数是测试

执行 cargo test 命令会运行项目中所有的测试,输出如下:

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
  • 第二行:显示了生成的测试函数的名称,它是 it_works,以及测试的运行结果,ok。
  • 第三行:全体测试运行结果的摘要:test result: ok. 意味着所有测试都通过了。
  • 1 passed; 0 failed: 表示通过或失败的测试数量。
  • 0 ignored:没有过滤需要运行的测试,所以摘要中会显示0 filtered out。
  • 0 measured: 统计是针对性能测试的。
  • Doc-tests adder:所有文档测试的结果,现在并没有任何文档测试,忽略此输出。

给 it_works 函数起个不同的名字,并增加第二个因调用了 panic! 而失败的测试:

#[cfg(test)]
mod tests {
    #[test]
    fn exploration() {
        assert_eq!(2 + 2, 4);
    }

    #[test]
    fn another() {
        panic!("Make this test fail");
    }
}

再次 cargo test 运行测试。输出如下:

running 2 tests
test tests::another ... FAILED
test tests::exploration ... ok

使用 assert! 宏来检查结果

assert! 宏由标准库提供,在希望确保测试中一些条件为 true 时非常有用,条件为true时测试才会通过

Rectangle 结构体和其 can_hold 方法:

#[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 的测试函数:

#[cfg(test)]
mod tests {
    //选择使用 glob 全局导入,以便在 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));
    }

    //检查一个更小的矩形不能放下一个更大的矩形
    #[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));
    }
}

运行测试查看测试结果,引入bug后再进行测试对比结果,如将 can_hold 方法中比较长度时本应使用大于号的地方改成小于号。

使用 assert_eq! 和 assert_ne! 宏来测试相等

assert_eq! 和 assert_ne!这两个宏分别比较两个值是相等还是不相等,当断言失败时它们也会打印出这两个值具体是什么。assert! 只会打印出它从 == 表达式中得到了 false 值,而不是打印导致 false 的两个值。

使用 assert_eq! 宏测试 add_two 函数:

//对其参数加二并返回结果
pub fn add_two(a: i32) -> i32 {
    //正常代码
    a + 2
    //bug 代码
    //a + 3
}

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

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

切换正常方法和bug方法,对比两次测试结果的信息。

在一些语言和测试框架中,断言两个值相等的函数的参数被称为 expected 和 actual,而且指定参数的顺序非常重要。然而在 Rust 中,它们则叫做 leftright,同时指定期望的值和被测试代码产生的值的顺序并不重要

assert_eq!assert_ne! 宏在底层分别使用了 == 和 != ,当断言失败时宏会使用调试格式打印出其参数,这意味着被比较的值必须实现了 PartialEqDebug trait。所有的基本类型和大部分标准库类型都实现了这些 trait,对于自定义的结构体和枚举,通常可以直接在结构体或枚举上添加 #[derive(PartialEq, Debug)] 注解

自定义失败信息

可以向 assert!、assert_eq! 和 assert_ne! 宏传递一个可选的失败信息参数,可以在测试失败时将自定义失败信息一同打印出来。任何在 assert! 的一个必需参数和 assert_eq! 和 assert_ne! 的两个必需参数之后指定的参数都会传递给 format! 宏,所以可以传递一个包含 {} 占位符的格式字符串和需要放入占位符的值。

有一个根据人名进行问候的函数,测试将传递给函数的人名显示在输出中:

pub fn greeting(name: &str) -> String {
    //正常代码
    format!("Hello {}!", name)
    // bug代码
    // String::from("Hello!")
}

#[cfg(test)]
mod tests {
    use super::*;
    
    #[test]
    fn greeting_contains_name() {
        let result = greeting("Carol");
        //避免需求改变时需要更新测试,仅断言输出的文本中包含输入参数
        assert!(
            result.contains("Carol"),
            //增加一个自定义失败信息参数:带占位符的格式字符串,以及 greeting 函数的值
            "Greeting did not contain name, value was `{}`",
            result
        );
    }   
}

使用 should_panic 检查 panic

除了检查返回值之外,检查代码是否按照期望处理错误也是很重要的,可以通过对函数增加另一个属性 should_panic 来实现这些。
这个属性在函数中的代码 panic 时会通过,而在其中的代码没有 panic 时失败。

一个检查 Guess::new 是否按照期望出错的测试:

pub struct Guess {
    value: i32,
}

impl Guess {
    //Guess 实例仅有的值范围在 1 到 100,创建一个超出范围的值的 Guess 实例会 panic
    pub fn new(value: i32) -> Guess {
        //正常代码
        if value < 1 || value > 100 {
        //bug代码
        //if value < 1 {
            panic!("Guess value must be between 1 and 100, got {}.", value);
        }

        Guess { value }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
 
    //#[should_panic] 属性位于 #[test] 之后,对应的测试函数之前
    #[test]
    #[should_panic]  
    fn greater_than_100() {
        Guess::new(200);
    }
}

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

一个会带有特定错误信息的 panic! 条件的测试:

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 = "less than or equal to 100")]
    fn greater_than_100() {
        Guess::new(200);
    }
}

尝试引入一个 bug,将 if value < 1 和 else if value > 100 的代码块对换,查看测试结果。

将 Result<T, E> 用于测试

可以使用 Result<T, E> 编写测试,失败时返回 Err 而非 panic:

#[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(()),在测试失败时返回带有 String 的 Err:

  • 不能对这些使用 Result<T, E> 的测试使用 #[should_panic] 注解
  • 不要使用对 Result<T, E> 值使用问号表达式(?),而是使用 assert!(value.is_err())断言。

控制测试如何运行

可以指定命令行参数来改变 cargo test 的默认行为,运行 cargo test --help 会提示 cargo test 的有关参数,而运行 cargo test -- --help 可以提示在分隔符之后使用的有关参数。

并行或连续的运行测试

当运行多个测试时,Rust 默认使用线程来并行运行。如果不希望测试并行运行,或者想要更加精确的控制线程的数量,可以传递 --test-threads 参数和希望使用线程的数量给测试二进制文件:

cargo test -- --test-threads=1

将测试线程设置为 1,花费更多时间但可以避免多线程干扰。

显示函数输出

默认情况下,当测试通过时 Rust 的测试库会截获打印到标准输出的所有内容,当测试失败时则会看到所有标准输出和其他错误信息。

一个会通过的测试和一个会失败的测试:

//打印出其参数的值并接着返回 10
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 将只会看到测试失败时的标准输出,可以在结尾加上 --show-output 告诉 Rust 显示成功测试的输出:

cargo test -- --show-output

通过指定名字来运行部分测试

可以向 cargo test 传递所希望运行的测试名称的参数来选择运行哪些测试,创建不同名称的三个测试:

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

运行单个测试

可以向 cargo test 传递任意测试的名称来只运行这个测试:

cargo test one_hundred

运行结果:

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 表明还存在比本次所运行的测试更多的测试没有被运行。
不能像这样指定多个测试名称,只有传递给 cargo test 的第一个值才会被使用

过滤运行多个测试

可以指定部分测试的名称,任何名称匹配这个名称的测试会被运行。前两个测试的名称包含 add,可以通过 cargo test add 来运行这两个测试:

cargo test add

运行结果:

running 2 tests
test tests::add_three_and_two ... ok
test tests::add_two_and_two ... ok

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

测试所在的模块也是测试名称的一部分,所以可以通过模块名来运行一个模块中的所有测试

除非特别指定否则忽略某些测试

有时一些特定的测试执行起来是非常耗费时间的,可以使用 ignore 属性来标记耗时的测试并排除它们:

#[test]
fn it_works() {
    assert_eq!(2 + 2, 4);
}

#[test]
#[ignore]  //在 #[test] 之后增加了 #[ignore] 行
fn expensive_test() {
    // 需要运行一个小时的代码
}
  • 当需要快速运行其它测试时,可以执行 cargo test
  • 当需要运行 ignored 的测试时,可以执行 cargo test -- --ignored
  • 当不管是否忽略都要运行全部测试时,可以运行 cargo test -- --include-ignored

测试的组织结构

Rust 社区倾向于根据测试的两个主要分类来考虑问题:单元测试(unit tests)与 集成测试(integration tests),区别如下:

  • 单元测试倾向于更小而更集中,在隔离的环境中一次测试一个模块,或者是测试私有接口。
  • 集成测试对于库来说则完全是外部的,只测试公有接口而且每个测试都有可能会测试多个模块。

单元测试

单元测试的目的是在与其他部分隔离的环境中测试每一个单元的代码,以便于快速而准确地验证某个单元的代码功能是否符合预期。
单元测试与它们要测试的代码共同存放在位于 src 目录下相同的文件中,规范是在每个文件中创建包含测试函数的 tests 模块,并使用 cfg(test) 标注模块。

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

测试模块的 #[cfg(test)] 注解告诉 Rust 只在执行 cargo test 时才编译和运行测试代码,而在运行 cargo build 时不这么做。
新建 adder 项目码时,自动生成的测试模块:

#[cfg(test)]
mod tests {
    #[test]
    fn it_works() {
        let result = 2 + 2;
        assert_eq!(result, 4);
    }
}

测试私有函数

测试社区中一直存在关于是否应该对私有函数直接进行测试的论战,Rust 的私有性规则确实允许你测试私有函数。
带有私有函数 internal_adder 的代码:

pub fn add_two(a: i32) -> i32 {
    internal_adder(a, 2)
}
//函数并没有标记为 pub
fn internal_adder(a: i32, b: i32) -> i32 {
    a + b
}

#[cfg(test)]
mod tests {
    //将 test 模块的父模块的所有项引入了作用域
    use super::*;

    //测试调用了 internal_adder
    #[test]
    fn internal() {
        assert_eq!(4, internal_adder(2, 2));
    }
}

集成测试

集成测试的目的是测试库的多个部分能否一起正常工作,一些单独能正确运行的代码单元集成在一起也可能会出现问题,所以集成测试的覆盖率也是很重要的。

tests 目录

为了编写集成测试,需要在项目根目录创建一个 tests 目录,与 src 同级,Cargo 知道如何去寻找这个目录中的集成测试文件。

接着可以随意在这个目录中创建任意多的测试文件,Cargo 会将每一个文件当作单独的 crate 来编译。

保留上面的示例代码,创建一个 tests 目录,新建一个文件 tests/integration_test.rs :

adder
├── Cargo.lock
├── Cargo.toml
├── src
│   └── lib.rs
└── tests
    └── integration_test.rs

tests/integration_test.rs 文件的代码如下:

use adder;

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

因为每一个 tests 目录中的测试文件都是完全独立的 crate,所以需要在每一个文件中导入库,需要在文件顶部添加 use adder。

不需要将 tests/integration_test.rs 中的任何代码标注为 #[cfg(test)], tests 文件夹在 Cargo 中是一个特殊的文件夹,Cargo 只会在运行 cargo test 时编译这个目录中的文件。

运行 cargo test 会有三个部分的输出:单元测试、集成测试和文档测试。如果一个部分的任何测试失败,之后的部分都不会运行。

集成测试部分以行 Running tests/integration_test.rs开头,每一个集成测试文件有对应的测试结果部分。

可以通过指定测试函数的名称作为 cargo test 的参数来运行特定集成测试,也可以使用 cargo test 的 --test 后跟文件的名称来运行某个特定集成测试文件中的所有测试

cargo test --test integration_test

这个命令只运行了 tests 目录中指定的文件 integration_test.rs 中的测试。

集成测试中的子模块

如果创建 一个tests/common.rs 文件并创建一个名叫 setup 的函数,这个函数能被多个测试文件的测试函数调用:

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

如果再次运行测试,将会在测试结果中看到一个新的对应 common.rs 文件的测试结果部分,即便这个文件并没有包含任何测试函数,也没有任何地方调用了 setup 函数。

为了不让 common 出现在测试输出中,需要创建 tests/common/mod.rs ,而不是创建 tests/common.rs ,项目目录结构如下:

├── Cargo.lock
├── Cargo.toml
├── src
│   └── lib.rs
└── tests
    ├── common
    │   └── mod.rs
    └── integration_test.rs

一旦拥有了 tests/common/mod.rs,就可以将其作为模块以便在任何集成测试文件中使用, tests/integration_test.rs 中调用 setup 函数示例:

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 中的少量代码也就会正常工作且不需要测试。

posted @ 2024-01-23 16:59  二次元攻城狮  阅读(69)  评论(0编辑  收藏  举报