基础篇-测试

  • 木头
  • 更新于 2023-03-02 11:21
  • 阅读 2115

编写测试用例,单元测试,集成测试

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

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

让我们看看 Rust提供的专门用来编写测试的功能:test 属性、一些宏和 should_panic属性。

测试函数剖析

作为最简单例子,Rust 中的测试就是一个带有test 属性注解的函数。属性(attribute)是关于Rust 代码片段的元数据;为了将一个函数变成测试函数,需要在 fn行之前加上#[test]。当使用 cargo test命令运行测试时,Rust会构建一个测试执行程序用来调用被标注的函数,并报告每一个测试是通过还是失败。

每次使用Cargo 新建一个库项目时,它会自动为我们生成一个测试模块和一个测试函数。这个模块提供了一个编写测试的模板,为此每次开始新项目时不必去查找测试函数的具体结构和语法了。因为这样当然你也可以额外增加任意多的测试函数以及测试模块!

在实际编写测试代码之前,让我们先通过尝试那些自动生成的测试模版来探索测试是如何工作的。接着,我们会写一些真正的测试,调用我们编写的代码并断言他们的行为的正确性。

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

$ cargo new adder --lib
     Created library `adder` package
$ cd adder 

adder 库中 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);
    }
}

现在让我们暂时忽略tests 模块和#[cfg(test)] 注解并只关注函数本身。注意 fn行之前的 #[test]:这个属性表明这是一个测试函数,这样测试执行者就知道将其作为测试处理。tests模块中也可以有非测试的函数来帮助我们建立通用场景或进行常见操作,必须每次都标明哪些函数是测试。

示例函数体通过使用 assert_eq! 宏来断言 22 等于4。一个典型的测试的格式,就是像这个例子中的断言一样。接下来运行就可以看到测试通过,cargo test 命令会运行项目中所有的测试:

$ cargo test

    Finished test [unoptimized + debuginfo] target(s) in 0.00s
     Running unittests src/lib.rs (target/debug/deps/adder-1cc740d39bd05cf3)

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

Cargo 编译并运行了测试。可以看到 running 1 test 这一行。下一行显示了生成的测试函数的名称,它是 it_works,以及测试的运行结果,ok。接着可以看到全体测试运行结果的摘要:test result: ok. 意味着所有测试都通过了。1 passed; 0 failed 表示通过或失败的测试数量。

让我们开始自定义测试来满足我们的需求。首先给 it_works 函数起个不同的名字,比如 exploration,像这样:

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

并再次运行cargo test。现在输出中将出现exploration 而不是 it_works

$ cargo test

   Compiling adder v0.1.0 (/rust/projects/adder)
    Finished test [unoptimized + debuginfo] target(s) in 0.36s
     Running unittests src/lib.rs (target/debug/deps/adder-1cc740d39bd05cf3)

running 1 test
test tests::exploration ... ok

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

现在让我们增加另一个测试,不过这一次是一个会失败的测试!当测试函数中出现 panic时测试就失败了。每一个测试都在一个新线程中运行,当主线程发现测试线程异常了,就将对应测试标记为失败。最简单的造成panic 的方法:调用 panic! 宏。写入新测试 another 后:

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

    #[test]
    fn another() {
        print!("测试失败");
    }
}

再次 cargo test 运行测试。exploration 测试通过了而 another失败了:

% cargo test

    Finished test [unoptimized + debuginfo] target(s) in 0.00s
     Running unittests src/lib.rs (target/debug/deps/adder-1cc740d39bd05cf3)

running 2 tests
thread 'tests::another' panicked at '测试失败', src/lib.rs:10:9
stack backtrace:
test tests::exploration ... ok
...
test tests::another ... FAILED

failures:

failures:
    tests::another

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

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

test tests::another 这一行是 FAILED 而不是ok了。在单独测试结果和摘要之间多了两个新的部分:第一个部分显示了测试失败的详细原因。在这个例子中,我们看到 another 因为在 src/lib.rs 的第 10panicked at '测试失败'而失败的详细信息。

使用 assert! 宏来检查结果

assert! 宏由标准库提供,在希望确保测试中一些条件为true时非常有用。需要向 assert! 宏提供一个求值为布尔值的参数。如果值是 trueassert! 什么也不做,同时测试会通过。如果值为 falseassert! 调用 panic! 宏,这会导致测试失败。assert!宏帮助我们检查代码是否以期望的方式运行。

在之前的基础篇-方法中有一个Rectangle 结构体我们再次使用他们。将他们放进 src/lib.rs 并使用 assert! 宏编写一些测试:

struct Rectangle {
    width: u32,
    height: u32,
}

impl Rectangle {
    fn area(&self) -> u32 {
        self.width * self.height
    }

    fn compare(&self, other: &Rectangle) -> bool {
        self.area() > other.area()
    }
}

compare 方法返回一个布尔值,这意味着它完美符合 assert! 宏的使用场景:

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

    #[test]
    fn larger_can_hold_smaller() {
        let larger = Rectangle {
            width: 40,
            height: 50,
        };

        let smaller = Rectangle {
            width: 30,
            height: 50,
        };
        assert!(larger.compare(&smaller));
    }
}

注意在 tests模块中新增加了一行:use super::*;。tests 是一个普通的模块。因为这是一个内部模块,要测试外部模块中的代码,需要将其引入到内部模块的作用域中。这里选择使用 glob全局导入,以便在 tests模块中使用所有在外部模块定义的内容

$ cargo test

   Compiling adder v0.1.0 (/rust/projects/adder)
    Finished test [unoptimized + debuginfo] target(s) in 0.96s
     Running unittests src/lib.rs (target/debug/deps/adder-1cc740d39bd05cf3)

running 1 test
test tests::larger_can_hold_smaller ... ok

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

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

测试功能的一个常用方法是将需要测试代码的值与期望值做比较,并检查是否相等。可以通过向 assert!宏传递一个使用== 运算符的表达式来做到。不过这个操作实在是太常见了,以至于标准库提供了一对宏来更方便的处理这些操作 —— assert_eq!assert_ne!。这两个宏分别比较两个值是相等还是不相等。当断言失败时他们也会打印出这两个值具体是什么,以便于观察测试为什么 失败,而assert!只会打印出它从 ==表达式中得到了 false 值,而不是打印导致false的两个值。

让我们编写一个对其参数加二并返回结果的函数 add_two。接着使用 assert_eq! 宏测试这个函数:

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

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

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

我们传递给assert_eq! 宏的第一个参数4,它等于调用 add_two(2) 的结果。测试结果是通过的,直行在代码中引入一个 bug 来看看使用 assert_eq! 的测试失败是什么样的。

[should_panic] 标识

如果你的测试函数没完成,或没有更新,或是故意让它崩溃,但为了让测试能够顺利完成,我们主动可以给测试函数加上 #[should_panic]标识,就不会让 cargo test 报错了:

#[cfg(test)]
mod tests {

    #[test]
    #[should_panic]
    fn it_works() {
        assert!(false);
    }
}

[ignore] 标识

有时候,某个测试函数非常耗时,或暂时没更新,我们想不让它参与测试,但是又不想删除它,这时, #[ignore] 就派上用场了:

#[cfg(test)]
mod tests {

    #[test]
    #[ignore]
    fn expensive_test() {
        // code that takes an hour to run
    }
}

测试的结构

Rust 社区倾向于根据测试的两个主要分类来考虑问题:单元测试(unit tests)集成测试(integration tests)。单元测试倾向于更小而更集中,在隔离的环境中一次测试一个模块,或者是测试私有接口。而集成测试对于你的库来说则完全是外部的。它们与其他外部代码一样,通过相同的方式使用你的代码,只测试公有接口而且每个测试都有可能会测试多个模块。

单元测试

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

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

测试模块的 #[cfg(test)]注解告诉 Rust 只在执行 cargo test时才编译和运行测试代码,而在运行cargo build时不这么做。这在只希望构建库的时候可以节省编译时间,并且因为它们并没有包含测试,所以能减少编译产生的文件的大小。与之对应的集成测试因为位于另一个文件夹,所以它们并不需要#[cfg(test)]注解。然而单元测试位于与源码相同的文件中,所以你需要使用 #[cfg(test)]来指定他们不应该被包含进编译结果中。

第一部分新建的 adder 项目,Cargo 为我们生成了如下代码:

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

上述代码就是自动生成的测试模块。cfg 属性代表configuration,它告诉Rust其之后的项只应该被包含进特定配置选项中。在这个例子中,配置选项是 test,即Rust 所提供的用于编译和运行测试的配置选项。通过使用 cfg属性,Cargo 只会在我们主动使用 cargo test运行测试时才编译测试代码。这包括测试模块中可能存在的帮助函数,以及标注为 #[test]的函数。

测试私有函数

测试社区中一直存在关于是否应该对私有函数直接进行测试的论战,而在其他语言中想要测试私有函数是一件困难的,甚至是不可能的事。不过无论你坚持哪种测试意识形态,Rust 的私有性规则确实允许你测试私有函数:

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 中,集成测试对于你需要测试的库来说完全是外部的。同其他使用库的代码一样使用库文件,也就是说它们只能调用一部分库中的公有API。集成测试的目的是测试库的多个部分能否一起正常工作。一些单独能正确运行的代码单元集成在一起也可能会出现问题,所以集成测试的覆盖率也是很重要的。为了创建集成测试,你需要先创建一个 tests 目录。

tests 目录

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

让我们来创建一个集成测试。中 src/lib.rs 的代码。创建一个 tests目录,新建一个文件 tests/integration_test.rs。目录结构应该看起来像这样:

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

文件名:tests/integration_test.rs

use adder; // 或者使用 extern crate adder;

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

因为每一个 tests 目录中的测试文件都是完全独立的 crate,所以需要在每一个文件中导入库。为此与单元测试不同,我们需要在文件顶部添加use adder,由于是同一个项目,cargo 会自动找。记得需要把lib.rsadd函数的pub加回去,因为集成测试是不予许调用私有函数。

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

$ cargo test
   Compiling adder v0.1.0 (/rust/projects/adder)
    Finished test [unoptimized + debuginfo] target(s) in 0.34s
     Running unittests src/lib.rs (target/debug/deps/adder-1cc740d39bd05cf3)

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-f783f5ecbc6ff423)

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

现在有了三个部分的输出:单元测试、集成测试和文档测试。注意如果一个部分的任何测试失败,之后的部分都不会运行。例如如果一个单元测试失败,则不会有任何集成测试和文档测试的输出,因为这些测试只会在所有单元测试都通过后才会执行。

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

$ cargo test --test integration_test
    Finished test [unoptimized + debuginfo] target(s) in 0.00s
     Running tests/integration_test.rs (target/debug/deps/integration_test-f783f5ecbc6ff423)

running 1 test
test it_works ... ok

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

集成测试中的子模块

随着集成测试的增加,你可能希望在tests目录增加更多文件以便更好的组织他们,例如根据测试的功能来将测试分组。正如我们之前提到的,每一个 tests目录中的文件都被编译为单独的 crate

将每个集成测试文件当作其自己的crate来对待,这更有助于创建单独的作用域,这种单独的作用域能提供更类似与最终使用者使用crate 的环境。tests目录中的文件不能像 src 中的文件那样共享相同的行为。

tests 目录中不同文件的行为就会显得很明显。例如,如果我们可以创建 一个tests/common.rs 文件并创建一个名叫 setup 的函数,我们希望这个函数能被多个测试文件的测试函数调用:

pub fn setup() {
    println!("setup 执行");
    // setup code specific to your library's tests would go here
}

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

$ cargo test
   Compiling adder v0.1.0 (/Users/hekang/rust/projects/adder)
    Finished test [unoptimized + debuginfo] target(s) in 0.49s
     Running unittests src/lib.rs (target/debug/deps/adder-1cc740d39bd05cf3)

running 0 tests

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

     Running tests/common.rs (target/debug/deps/common-876d01e163df105e)
.....

我们并不想要common 出现在测试结果中显示running 0 tests。我们只是希望其能被其他多个集成测试文件中调用罢了。

为了不让common出现在测试输出中,我们将创建 tests/common/mod.rs ,而不是创建 tests/common.rs 。现在项目目录结构看起来像这样:

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

这样命名告诉Rust不要将 common看作一个集成测试文件。将setup 函数代码移动到 tests/common/mod.rs 并删除tests/common.rs文件之后,测试输出中将不会出现这一部分。tests目录中的子目录不会被作为单独的 crate 编译或作为一个测试结果部分出现在测试输出中。

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

use adder; // 或者使用 extern crate adder;

mod common;

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

注意 mod common;声明模块。接着在测试函数中就可以调用 common::setup() 了:

$ cargo test --package adder --test integration_test --  --nocapture 

    Finished test [unoptimized + debuginfo] target(s) in 0.00s
     Running tests/integration_test.rs (target/debug/deps/integration_test-f783f5ecbc6ff423)

running 1 test
setup 执行
test it_works ... ok

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

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

  • 原创
  • 学分: 1
  • 分类: Rust
  • 标签:
点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。
125 订阅 31 篇文章

0 条评论

请先 登录 后评论
木头
木头
0xC020...10cf
江湖只有他的大名,没有他的介绍。