Skip to content

35.2 测试

单元测试

Rust 内置了一套单元测试框架。单元测试是一种目前业界广泛使用的,可以显著提升代码可靠性的工程管理手段。Rust 里面的单元测试代码可以直接和业务代码写在一个文件中,非常有利于管理,方便更新。执行单元测试也非常简单,一条cargo test命令即可。

一般情况下,如果我们新建一个 library 项目,cargo 工具会帮我们在src/lib.rs中自动生成如下代码:

rust
#[cfg(test)]
mod tests {
    #[test]
    fn it_works() {
    }
}

这就是最基本的单元测试框架。下面详细介绍一下这里面的各个要素。

首先,Rust 里面有一个特殊的 attribute,叫作#[cfg],它主要是用于实现各种条件编译。比如 #[cfg(test)] 意思是:the configuration option is test,这部分代码只在 test 这个开关打开的时候才会被编译。它还有更高级的用法,比如

rust
#[cfg(any(unix, windows))]

#[cfg(all(unix, target_pointer_width = "32"))]

#[cfg(not(foo))]
#[cfg(any(not(unix), all(target_os="macos", target_arch = "powerpc")))]

我们还可以自定义一些功能开关。比如在Cargo.toml中加入这样的代码:

toml
[features]
# 默认开启的功能开关
default = []

# 定义一个新的功能开关,以及它所依赖的其他功能
# 我们定义的这个功能不依赖其他功能,默认没有开启
my_feature_name = []

之后就可以在代码中使用这个功能开关,某部分代码可以根据这个开关的状态决定编译还是不编译:

rust
#[cfg(feature = "my_feature_name")]
mod sub_module_name {
}

这个开关究竟是开还是关,可以通过编译选项传递进去:

sh
cargo build --features "my_feature_name"

当我们使用cargo test命令的时候,被#[cfg(test)]标记的代码就会被编译执行;否则直接被忽略。

我们还是用一个示例来说明。我们现在准备实现一个辗转相除法求最大公约数的功能。新建一个名叫 gcd 的项目:

sh
cargo new --lib gcd

辗转相除法的细节就不展开了。实现代码如下所示:

rust
pub fn gcd(a: u64, b: u64) -> u64
{
    let (mut l, mut g) = if a < b {
        (a, b)
    } else {
        (b, a)
    };

    while l != 0 {
        let m = g % l;
        g = l;
        l = m;
    }
    return g;
}

接下来添加一个最基本的测试:

rust
#[cfg(test)]
mod tests {
    #[test]
    fn it_works() {
        assert_eq!(gcd(2, 3), 1);
    }
}

使用cargo test命令执行这个测试。这一次发生了编译错误,编译器找不到 gcd 这个函数。这是因为我们把测试用例写在了一个单独的模块中,在子模块中并不能直接访问父模块中的内容。在 mod 内部加一句use gcd;或者use super::*;可以解决这个问题。

    Compiling gcd v0.1.0 (file:///projects/gcd)
        Finished dev [unoptimized + debuginfo] target(s) in 2.33 secs
            Running target/debug/deps/gcd-1658b34b1de16a01

running 1 test
test tests::it_works ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

    Doc-tests gcd

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

打印出来的结果非常清晰易读。前面一部分是执行测试模块中的测试用例的结果,后面一部分是执行文档中的测试用例的结果。1 passed代表通过了1个测试。0 failed代表失败了0个测试。0 ignored代表忽略了0个测试。

用户可以用#[ignore]标记测试用例,暂时忽略这个测试。比如:

rust
#[test]
#[ignore]
fn it_works() {
    assert_eq!(gcd(2, 3), 1);
}

如果只想运行被忽略的测试,可以使用 cargo test -- --ignored。或者运行所有的测试,不管它们是否被忽略,可以运行 cargo test -- --include-ignored

0 measured代表跑了0个 benchmark 性能测试。我们可以用#[bench]添加性能测试用例:

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

    #[bench]
    fn big_num(b: &mut Bencher) {
        b.iter(|| gcd(12345, 67890) )
    }
}

这个功能目前还没有稳定,需要用户在当前 crate 中开启 feature gate:

rust
#![feature(test)]
extern crate test;

然后使用cargo bench就可以执行这个性能测试。这时就可以看到测试结果中有1 measured的结果。

测试结果中还有0 filtered out统计数据。这个数据代表的是用户跑测试的时候过滤出来了多少测试用例。比如我们有许多测试用例,但是只想执行某一个具体的测试用例it_works,可以这样做:cargo test it_works。或者可以用包含特定单词的方式过滤出多个测试用例,比如cargo test add。还有更多的用法可以参见cargo test -h

在测试用例内部,我们一般用assert_eq!宏来检查真实结果和预期结果是否一致。 除此之外,也还有其他的检查方法。assert!宏可以用于检查一个 bool 类型结果是否为trueassert_ne!宏可以用于检查两个数据是否不相等。另外,我们还可以在这些宏里面自定义错误信息。比如我们用0来测试上面这个gcd函数。因为0作为除数没有意义,所以我们希望任何一个参数为0的时候,输出结果都是0,可以写这样的测试用例:

rust
#[test]
fn with_zero() {
    assert_eq!(gcd(10, 0), 0, "division by zero has no meaning");
}

如果测试失败,失败消息中会显示我们指定的那条信息。

另外,有些时候测试结果无法用“等于不等于”这种条件表达,比如发生 panic。测试框架也允许我们用#[should_panic]做这种测试。假设,我们修改一下上面的gcd函数的逻辑,不允许参数为0,如果参数是0直接发生 panic:

rust
pub fn gcd(a: u64, b: u64) -> u64
{
    if a == 0 || b == 0 {
        panic!("Parameter should not be zero");
    }

}

为了测试这种情况,我们可以写如下测试用例:

rust
#[test]
#[should_panic]
fn with_zero() {
    gcd(10, 0);
}

一般我们都把测试用例放到单独的 mod 里面,打上#[cfg(test)]条件编译的标签,这样编译正常代码的时候就可以把测试相关的整个模块忽略掉。这个测试模块一般直接放在被测试代码的同一个文件中:一方面是比较直观容易管理;另一方面,根据 Rust 的模块可见性规则,这个测试模块有权访问它父模块的私有元素,这样比较方便测试。

用户也可以自己组织测试用例的代码结构。比如单独使用一个新的文件夹来管理测试用例,这都是没问题的。毕竟测试代码也不过就是一个普通的模块而已,我们可以自由选择如何管理这个模块。

Rust 默认的测试框架毕竟还只是一个轻量级的框架。功能比许多其他语言中的大型测试框架要差一些。这也是目前 Rust 设计组比较关心的一个领域。他们正在设计一个方案,使用户可以比较方便地实现自定义测试框架。这样可以由社区开发一些功能更强大的测试框架作为替代品,供大家使用。

35.2.1 控制测试的行为

关于测试,还有很多其他控制选项,帮助我们更好的完成相应的测试要求。运行cargo help test 可以打印出关于测试命令的详细介绍。还可以参考官网 cargo 文档的 Tests 章

这里介绍一些常用的的控制选项:

默认情况下,是并行运行多个测试的,这样测试完成运行的速度更快,我们可以更快地获得反馈。

sh
$ cargo test -- --test-threads=1

通过将测试线程的数量设置为 1 ,禁止并行运行测试。这样运行测试会花费更长的时间,如果是共享状态,测试是不会相互干扰的。

默认情况下,对于通过的测试,Rust 的测试库会捕获任何打印到标准输出的内容,所以我们不会在输出中看到代码中有关 println! 的打印信息;

sh
$ cargo test -- --show-output

这样就能让通过的测试,也能显示代码中相关的打印输出了。

35.2.2 集成测试

在 Rust 中,集成测试与你的库是完全分开的,而且只对库项目进行集成测试。集成测试就好像其他的使用者一样,这意味着它们只能调用属于库的公共 API 的部分。

为什么只能测试库项目(library crate)呢?可以这样理解:对于一个二进制项目(binary crates)来说,除了入口 src/main.rs 文件中少量的代码外,几乎所有重要代码都可以合理拆分为库项目,而这些库项目是可以集成测试的,所以对于 src/main.rs 中的少部分代码能正常运行就可以了。

要创建集成测试,首先需要先在 src 同级新建 tests 目录,cargo 会在 tests 目录中查找集成测试文件,类似下面的目录结构:

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

tests 目录下的每个文件都是一个单独的 crate。这里注意 tests 下的所有子目录中的文件都不会被识别为测试文件而运行,但是这里的 mod.rs,属于旧命名约定,rust 可以理解。通过这种方式,避免了被识别为测试文件,也方便我们在其中定义一些测试帮助函数。

tests/integration_test.rs 内容:

rust
use adder;

mod common;

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

然后在 it_adds_two 函数中,我们可以调用 tests/common/mod.rs 中的 common::setup() 帮助函数。

如果一个单元测试失败,紧接着的集成测试和文档测试都不会有任何输出,只有在所有单元测试都通过的情况下,其他的测试才会正常运行。

如果我们只想运行某个集成测试文件中的所有代码,可以通过命令类似下面的命令:

sh
$ cargo test --test integration_test

上面的命令,只运行了 tests/integration_test.rs 文件中的所有测试。

参考

Released under the MIT License