编写rust测试程序

2023-04-24 10:35:31 浏览数 (1)

编写rust测试

rust提供了编写测试的方式来让我们对程序编写测试用例。

测试函数

当使用 Cargo 创建一个 lib 类型的包时,它会为我们自动生成一个测试模块。先来创建一个 lib 类型的 adder 包。创建成功后,在 src/lib.rs 文件中可以发现如下代码:

代码语言:javascript复制
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 就是一个测试模块,it_works 则是测试函数,它对add函数进行了测试。测试函数需要使用 #[test] 属性进行标注。关于属性( attribute ),我们在之前的章节已经见过类似的 derive,使用它可以派生自动实现的 Debug 、Copy 等特征,同样的,使用 test 属性,我们也可以获取 Rust 提供的测试特性。

经过 test 标记的函数就可以被测试执行器发现,并进行运行。当然,在测试模块 tests 中,还可以定义非测试函数,这些函数可以用于设置环境或执行一些通用操作:例如为部分测试函数提供某个通用的功能,这种功能就可以抽象为一 个非测试函数。

执行测试

之前,对于library的package,我们是使用cargo build来构建的。对于测试而言,可以执行cargo test来执行项目中的所有测试。执行上面的

代码语言:javascript复制
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

   Doc-tests adder

running 0 tests

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

测试用例是分批执行的,running 1 test 表示下面的输出 test result 来自一个测试用例的运行结果。

test tests::it_works 中包含了测试用例的名称

test result: ok 中的 ok 表示测试成功通过

1 passed 代表成功通过一个测试用例(因为只有一个),0 failed : 没有测试用例失败,0 ignored 说明我们没有将任何测试函数标记为运行时可忽略,0 filtered 意味着没有对测试结果做任何过滤,

0 mesasured 代表基准测试(benchmark)的结果

还有一个很重要的点,输出中的 Doc-tests adder 代表了文档测试。我们这里没有文档,因此测试用例是0。

现在,我们来添加自己的函数以及对应的测试函数。例如:

代码语言:javascript复制
pub fn add(left: usize, right: usize) -> usize {
    left   right
}

pub fn hello() {
    println!("hello");
}

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

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

    #[test]
    fn hello_test() {
        assert_eq!((), hello())
    }
}

现在执行cargo test,输出如下所示:

代码语言:javascript复制
running 2 tests
test tests::hello_test ... ok
test tests::exploration ... ok

test result: ok. 2 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

可以看到,输出的test tests中的两个测试函数的名字分别是hello_test和exploration(我们更改了刚才的it_works的名称),同时测试结果是显示2 passed。

Rust 在默认情况下会为每一个测试函数启动单独的线程去处理,当主线程 main 发现有一个测试线程死掉时,main 会将相应的测试标记为失败。事实上,多线程运行测试虽然性能高,但是存在数据竞争的风险。

失败的测试用例

下面来看一下失败的测试结果是怎么样的。

代码语言:javascript复制
#[test]
fn another() {
    panic!("Make this test fail");
}

我们直接在test模块中加上上面这个测试函数,当它被执行的时候,会直接调用panic抛出错误。执行cargo test,结果如下所示:

代码语言:javascript复制
running 3 tests
test tests::exploration ... ok
test tests::hello_test ... ok
test tests::another ... FAILED

failures:

---- tests::another stdout ----
thread 'tests::another' panicked at 'Make this test fail', src/lib.rs:26:9
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
    tests::another

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

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

可以看到之前的两个测试函数通过了,新增的another失败了,并且给出了相应的失败输出 tests::another stdout ,因此最终的结果是测试失败。

自定义失败信息

默认的失败信息在有时候并不是我们想要的,来看一个例子:

代码语言:javascript复制
pub fn greeting(name: &str) -> String {
    format!("Hello {}!", name)
}

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

    #[test]
    fn greeting_contains_name() {
        let result = greeting("zhangsan");
        let target = "张三";
        assert!(result.contains(target), "你的问候中并没有包含目标姓名{target},你的问候是 `{result}`");
    }
}

执行cargo test测试上面的代码,输出结果如下所示:

代码语言:javascript复制
running 1 test
test tests::greeting_contains_name ... FAILED

failures:

---- tests::greeting_contains_name stdout ----
thread 'tests::greeting_contains_name' panicked at '你的问候中并没有包含目标姓名张三,你的问候是 `Hello zhangsan!`', src/lib.rs:62:9
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
    tests::greeting_contains_name

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

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

assert宏

在 Rust 中,assert 宏接受两个参数:

  1. condition:要检查的条件表达式,它的值必须是布尔型(bool)。
  2. message:可选的错误信息字符串,如果断言失败,该信息将被打印到标准输出流(stdout)中

Rust 还提供了 debug_assert 宏,它只在调试模式下检查条件,并在发布模式下忽略它。这个宏的语法与 assert 宏相同。

测试 panic

如果一个函数本来就会 panic ,而我们想要检查测试是否发生了panic。对此rust提供了 should_panic 属性注解,就和test注解一样。

代码语言:javascript复制
#[allow(unused)]
pub struct Guess {
    value: i32,
}

impl Guess {
    pub fn new(value: i32) -> Guess {
        if value < 1 || value > 100 {
            panic!("Guess value must be between 1 and 100, got {}.", value);
        }

        Guess { value }
    }
}

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

    #[test]
    #[should_panic]
    fn greater_than_100() {
        Guess::new(200);
    }
}

这段代码执行cargo test之后输出如下所示:

代码语言:javascript复制
running 1 test
test tests::greater_than_100 - should panic ... 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

可以看到测试结果是成功,这意味着它确实发生了崩溃。如果我们将代码中的Guess::new(200)改为Guess::new(20),那么should_panic测试会失败,因为测试并没有按照预期发生 panic。

虽然 panic 被成功测试到,但是如果代码发生的 panic 和我们预期的 panic 不符合呢?因为一段糟糕的代码可能会在不同的代码行生成不同的 panic。鉴于此,我们可以使用可选的参数 expected 来说明预期的 panic 长啥样。

except

代码语言:javascript复制
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 = "Guess value must be less than or equal to 100")]     // except
    fn greater_than_100() {
        Guess::new(200);
    }
}

这段代码会通过测试,因为通过增加了 expected ,我们成功指定了期望的 panic 信息。如果注意看,你会发现 expected 的字符串和实际 panic 的字符串可以不同,前者只需要是后者的字符串前缀即可,如果改成 #[should_panic(expected = “Guess value must be less than”)],一样可以通过测试。

测试用例的并行或顺序执行

当运行多个测试函数时,默认情况下是为每个测试都生成一个线程,然后通过主线程来等待它们的完成和结果。这种模式的优点很明显,那就是并行运行会让整体测试时间变短很多,但是有利就有弊,并行测试最大的问题就在于共享状态的修改,因为你难以控制测试的运行顺序,因此如果多个测试共享一个数据,那么对该数据的使用也将变得不可控制。例如,我们有多个测试,它们每个都会往该文件中写入一些自己的数据,最后再从文件中读取这些数据进行对比。由于所有测试都是同时运行的,当测试 A 写入数据准备读取并对比时,很有可能会被测试 B 写入新的数据,导致 A 写入的数据被覆盖,然后 A 再读取到的就是 B 写入的数据。结果 A 测试就会失败,而且这种失败还不是因为测试代码不正确导致的!解决办法也有,我们可以让每个测试写入自己独立的文件中,当然,也可以让所有测试一个接着一个顺序运行:

代码语言:javascript复制
cargo test -- --test-threads=1

第一个–是用来分割参数到底是传给谁的。在第一个–之前的参数是传递给cargo的,而之后是传递给编译后的可执行程序的。线程数不仅仅可以指定为 1,还可以指定为 4、8,当然,想要顺序运行,就必须是 1。

测试中使println!生效

默认情况下,如果测试通过,那写入标准输出的内容是不会显示在测试结果。不过可以通过增加--show-output参数来使得成功的测试中的println可以输出。例如:

代码语言:javascript复制
#[allow(unused)]
fn add(a:i32, b:i32) -> i32{
    println!("test output: 0000000000");
    a   b

}

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

    #[test]
    fn t1() {
        let res = add(1, 2);
        assert_eq!(res, 3);
    }


    #[test]
    fn t2() {
        let res = add(1, 2);
        assert_eq!(res, 2);
    }
}

执行cargo test,输出结果如下所示:

代码语言:javascript复制
running 2 tests
test tests::t1 ... ok
test tests::t2 ... FAILED

failures:

---- tests::t2 stdout ----
test output: 0000000000
thread 'tests::t2' panicked at 'assertion failed: `(left == right)`
left: `3`,
right: `2`', src/lib.rs:152:9
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
    tests::t2

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

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

结果显示t2测试失败,t2调用的add函数中的println进行了输出,但是t1函数中的println输出的结果没有进行展示。如果想要成功的测试也输出println中的内容,可以使用cargo test – --show-output来执行程序。

指定运行一部分测试

有时候每次都运行全部测试是不可接受的(因为测试可能特别耗时),特别是你的工作仅仅是项目中的一部分时。

运行单个测试

这个很简单,只需要将指定的测试函数名作为参数即可,例如:

代码语言:javascript复制
cargo test t1

通过名称来过滤测试

我们可以通过指定部分名称的方式来过滤运行相应的测试,例如上面我们有两个测试函数t1和t2,那么我们可以指定名称的一部分t,这样t1和t2就会被执行。事实上,你不仅可以使用前缀,还能使用名称中间的一部分。我们来换一个例子,上面例子中名字太短了。

代码语言:javascript复制
#[cfg(test)]
mod tests{
    use super::*;

    #[test]
    fn add_test1() {
        let res = add(1, 2);
        assert_eq!(res, 3);
    }


    #[test]
    fn add_test2() {
        let res = add(1, 2);
        assert_eq!(res, 2);
    }
}

OK,我们更新了上面函数的名字,然后可以分别执行cargo test t1, cargo test t2, cargo test add来试一试,其分别会执行add_test1,add_test2以及全部执行。还可以通过指定模块名来过滤测试,例如:

代码语言:javascript复制
#[cfg(test)]
mod tests{
    use super::*;

    #[test]
    fn test1() {
        let res = add(1, 2);
        assert_eq!(res, 3);
    }
}

#[cfg(test)]
mod addmodule_test{

    use super::*;

    #[test]
    fn test2() {
        let res = add(1, 2);
        assert_eq!(res, 2);
    }
}

当执行cargo test的时候,所有的测试模块都会被执行,其中的所有的测试函数都会被执行。我们可以通过指定模块名称来执行测试模块cargo test addmodulecargo test tests来分别执行addmodule测试模块和tests测试模块。

忽略部分测试

有时候,一些测试会非常耗时间,因此我们希望在 cargo test 中对它进行忽略,如果使用之前的方式,我们需要将所有需要运行的名称指定一遍,这非常麻烦,好在 Rust 允许通过 ignore 关键字来忽略特定的测试用例:

代码语言:javascript复制
#[test]
fn it_works() {
    assert_eq!(2   2, 4);
}

#[test]
#[ignore]   // 使用cargo test的时候忽略该测试函数
fn expensive_test() {
    // 这里的代码需要几十秒甚至几分钟才能完成
}

当然,也可以通过cargo test -- --ignored方式运行被忽略的测试函数。

组合过滤

代码语言:javascript复制
#[cfg(test)]
mod tests {
    use super::*;

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

    #[test]
    #[ignore]
    fn expensive_test() {
        // 这里的代码需要几十秒甚至几分钟才能完成
    }

    #[test]
    #[ignore]
    fn expensive_run() {
        // 这里的代码需要几十秒甚至几分钟才能完成
    }
}

可以通过执行cargo test run -- --ignored来运行名称中带 run 且被忽略的测试函数。也可以通过执行cargo test tests -- --ignored运行 tests 模块中的被忽略的测试函数。

更漂亮的测试输出,使用pretty_assertions库

它可以用来扩展标准库中的 assert_eq! 和 assert_ne!,例如提供彩色字体的结果对比。将其指定为[dev-dependencies] ,它将仅用于编译测试、示例和基准测试。这样cargo build不会影响编译时间!当你使用cargo add pretty_assertions的时候,默认添加pretty_assertions到[dependencies]下面,例如:

代码语言:javascript复制
[dependencies]
pretty_assertions = "1.3.0"

需要将pretty_assertions放到[dev-dependencies]下面

代码语言:javascript复制
[dev-dependencies]
pretty_assertions = "1.3.0"

另外,还要添加#[cfg(test)]到use语句中,如下所示:

代码语言:javascript复制
#[cfg(test)]
use pretty_assertions::{assert_eq, assert_ne};

生成测试二进制文件

在有些时候,我们可能希望将测试与别人分享,这种情况下生成一个类似 cargo build 的可执行二进制文件是很好的选择。

事实上,在 cargo test 运行的时候,系统会自动为我们生成一个可运行测试的二进制可执行文件:

代码语言:javascript复制
Compiling diff v0.1.13
Compiling yansi v0.5.1
Compiling pretty_assertions v1.3.0
Compiling adder v0.1.0 (/home/ubuntu/learn/rust/adder)
 Finished test [unoptimized   debuginfo] target(s) in 0.97s
  Running unittests src/lib.rs (target/debug/deps/adder-bedc2f4c9b465bb8)

这里的target/debug/deps/adder-bedc2f4c9b465bb8就是测试程序的路径和名称。可以直接执行它来进行测试。

代码语言:javascript复制
./target/debug/deps/adder-bedc2f4c9b465bb8

条件编译 #[cfg(test)]

代码中的 #[cfg(test)] 标注可以告诉 Rust 只有在 cargo test 时才编译和运行模块 tests,其它时候当这段代码是空气即可,例如在 cargo build 时。这么做有几个好处:

  1. 节省构建代码时的编译时间
  2. 减小编译出的可执行文件的体积

其实集成测试就不需要这个标注,因为它们被放入单独的目录文件中,而单元测试是跟正常的逻辑代码在同一个文件,因此必须对其进行特殊的标注,以便 Rust 可以识别。

#[cfg(test)] 中,cfg 是配置 configuration 的缩写,它告诉 Rust :当 test 配置项存在时,才运行下面的代码,而 cargo test 在运行时,就会将 test 这个配置项传入进来,因此后面的 tests 模块会被包含进来。

单元测试

单元测试目标是测试某一个代码单元(一般都是函数),验证该单元是否能按照预期进行工作,例如测试一个 add 函数,验证当给予两个输入时,最终返回的和是否符合预期。

在 Rust 中,单元测试的惯例是将测试代码的模块跟待测试的正常代码放入同一个文件中,例如 src/lib.rs 文件中有如下代码:

代码语言:javascript复制
pub fn add_two(a: i32) -> i32 {
    a   2
}

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

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

add_two 是我们的项目代码,为了对它进行测试,我们在同一个文件中编写了测试模块 tests,并使用 #[cfg(test)] 进行了标注。

测试私有函数

关于私有函数能否被直接测试,编程社区里一直争论不休,甚至于部分语言可能都不支持对私有函数进行测试或者难以测试。无论你的立场如何,反正 Rust 是支持对私有函数进行测试的。

代码语言:javascript复制
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));
    }
}

但是在上述代码中,我们使用 use super:

0 人点赞