UP | HOME

测试

Table of Contents

Edsger W. Dijkstra 在其 1972 年的文章"The Humble Programmer"中说到"Program testing can be a very effective way to show the presence of bugs, but it is hopelessly inadequate for showing their absence"

这并不意味着我们不该尽可能地测试软件!

程序的正确性意味着代码如期望的那样运行。Rust 是一个相当注重正确性的编程语言,不过正确性是一个难以证明的复杂主题。Rust 的类型系统在此问题上下了很大的功夫,不过它不可能捕获所有种类的错误。为此,Rust 也在语言本身包含了编写软件测试的支持

例如,可以编写一个叫做 add_two 的将传递给它的值加二的函数。它的签名有一个整型参数并返回一个整型值

当实现和编译这个函数时,Rust 会进行所有目前我们已经见过的类型检查和借用检查,例如,这些检查会确保我们不会传递 String 或无效的引用给这个函数

Rust 所不能检查的是这个函数是否会准确的完成我们期望的工作:返回参数加二后的值,而不是比如说参数加 10 或减 50 的值!这也就是测试出场的地方

我们可以编写测试断言,比如说,当传递 3 给 add_two 函数时,返回值是 5。无论何时对代码进行修改,都可以运行测试来确保任何现存的正确行为没有被改变

测试是一项复杂的技能:虽然不能在一个章节的篇幅中介绍如何编写好的测试的每个细节,但还是会讨论 Rust 测试功能的机制。我们会讲到编写测试时会用到的 注解 ,运行测试的 默认行为 和选项,以及如何将测试 组织 成单元测试和集成测试

如何编写测试

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

  1. 设置任何所需的数据或状态
  2. 运行需要测试的代码
  3. 断言其结果是所期望的

看看 Rust 提供的专门用来编写测试的功能: test 属性 、一些 should_panic 属性

测试函数剖析

作为最简单例子,Rust 中的测试就是一个带有 test 属性 注解的 函数 。属性是关于 Rust 代码片段的元数据

   前面的结构体中用到的 derive 属性就是一个例子

为了将一个函数变成测试函数,需要在 fn 行 之前加上 #[test] 。当使用 cargo test 命令运行测试时,Rust 会构建一个测试执行程序用来调用标记了 test 属性的函数,并报告每一个测试是通过还是失败

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

     这有助于开始编写测试,因为这样每次开始新项目时不必去查找测试函数的具体结构和语法了

     当然也可以额外增加任意多的测试函数以及测试模块!

创建一个新的库项目 adder,adder 库中 src/lib.rs 的内容应该看起来:

#[cfg(test)]
mod tests {
    #[test]
    fn it_works() {
        assert_eq!(2 + 2, 4);
    }
}
   现在暂时忽略 tests 模块和 #[cfg(test)] 注解,并只关注函数来了解其如何工作

注意: fn 行之前的 #[test] :这个属性表明这是一个测试函数,这样测试执行者就知道将其作为测试处理

   因为也可以在 tests 模块中拥有非测试的函数来帮助我们建立通用场景或进行常见操作,所以需要使用 #[test] 属性标明哪些函数是测试

函数体通过使用 assert_eq! 宏 来断言 2 加 2 等于 4。一个典型的测试的格式,就是像这个例子中的断言一样

接下来运行就可以看到测试通过。 cargo test 命令会运行项目中所有的测试,如下所示:

$ cargo test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished dev [unoptimized + debuginfo] target(s) in 0.22 secs
     Running target/debug/deps/adder-ce99bcc2479f4607

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

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

   Doc-tests adder

running 0 tests

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

Cargo 编译并运行了测试。在 Compiling、Finished 和 Running 这几行之后:

  • 首先看到 running 1 test 这一行
  • 下一行显示了生成的 测试函数的名称 ,它是 it_works ,以及测试的运行结果 ok
  • 接着可以看到 全体测试运行结果摘要
    • test result: ok. 意味着 所有测试都通过
    • 1 passed; 0 failed 表示 通过失败 的测试数量
    • 因为之前并 没有将任何测试标记为忽略 ,所以摘要中会显示 0 ignored
    • 没有过滤 需要运行的测试,所以摘要中会显示 0 filtered out
    • 0 measured 统计是针对 性能测试
  • 测试输出中的以 Doc-tests adder 开头的这一部分是所有 文档测试 的结果
     现在并没有任何文档测试,不过 Rust 会编译任何在 API 文档中的代码示例

     这个功能帮助我们使文档和代码保持同步

改变测试的名称并看看这如何改变测试的输出。给 it_works 函数起个不同的名字,比如 exploration,像这样:

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

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

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

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

   Doc-tests adder

running 0 tests

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

再增加另一个测试,不过这一次是一个会失败的测试:

  • 当测试函数中出现 panic 时测试就 失败
  • 每一个测试都在一个新线程中运行,当主线程发现测试线程异常了,就将对应测试标记为失败
   已经知道最简单的造成 panic 的方法:调用 panic! 宏

写入新测试 another 后, src/lib.rs 现在看起来如示例所示:

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

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

再次 cargo test 运行测试。输出它表明 exploration 测试通过了而 another 失败了:

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

failures:

---- tests::another stdout ----
thread 'tests::another' panicked at 'Make this test fail', src/lib.rs:10:9
note: Run with `RUST_BACKTRACE=1` for a backtrace.

failures:
    tests::another

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

error: test failed

test tests::another 这一行是 FAILED 而不是 ok 了。在单独测试结果和摘要之间多了两个新的部分:

  • 第一个部分:显示了测试 失败的详细原因
     在这个例子中,another 因为在src/lib.rs 的第 10 行 panicked at 'Make this test fail' 而失败
  • 第二个部分:列出了 所有失败的测试
     这在有很多测试和很多失败测试的详细输出时很有帮助

     可以通过使用失败测试的名称来只运行这个测试,以便调试

最后是 摘要行 :总体上讲,测试结果是 FAILED 。有一个测试通过和一个测试失败

     现在见过不同场景中测试结果是什么样子的了,再来看看除 panic! 之外的一些在测试中有帮助的宏吧

使用 assert! 宏来检查结果

assert! 宏标准库 提供,在希望确保测试中一些条件为 true 时非常有用。需要向 assert! 宏提供一个求值为 布尔值 的参数:

  • 如果值是 true,assert! 什么也不做,同时测试会通过
  • 如果值为 false,assert! 调用 panic! 宏,这会导致测试失败
assert! 宏用来检查代码是否以期望的方式运行

前面有一个 Rectangle 结构体和一个 can_hold 方法,这里将他们放进 src/lib.rs:

#[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
    }
}

在下面代码中,编写一个 larger_can_hold_smaller 方法来测试,这里创建一个长为 8 宽为 7 的 Rectangle 实例,并假设它可以放得下另一个长为 5 宽为 1 的 Rectangle 实例:

fn main() {}
#[cfg(test)]
mod 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));
    }
}

注意:在 tests 模块中新增加了一行:_use super::*;_ 。tests 是一个普通的模块,它遵循模块的可见性规则:

  • 因为这是一个内部模块,要测试外部模块中的代码,需要将其引入到内部模块的作用域中
  • 这里选择使用 glob 全局导入,以便在 tests 模块中使用所有在外部模块定义的内容
     将测试命名为 larger_can_hold_smaller,并创建所需的两个 Rectangle 实例

     接着调用 assert! 宏并传递 larger.can_hold(&smaller) 调用的结果作为参数

     这个表达式预期会返回 true,所以测试应该通过。让我们拭目以待!

运行 cargo test:

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

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

再来增加另一个测试,这一回断言一个更小的矩形不能放下一个更大的矩形:

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

    #[test]
    fn larger_can_hold_smaller() {
        // --snip--
    }

    #[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));
    }
}
     因为这里 can_hold 函数的正确结果是 false ,所以将这个结果取反后传递给 assert! 宏

     同样can_hold 返回 false 时测试就会通过:
running 2 tests
test tests::smaller_cannot_hold_larger ... ok
test tests::larger_can_hold_smaller ... ok

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

现在看看如果引入一个 bug 的话测试结果会发生什么。将 can_hold 方法中比较长度时本应使用大于号的地方改成小于号:

// --snip--

impl Rectangle {
    fn can_hold(&self, other: &Rectangle) -> bool {
        self.width < other.width && self.height > other.height
    }
}

现在运行测试会产生:

running 2 tests
test tests::smaller_cannot_hold_larger ... ok
test tests::larger_can_hold_smaller ... FAILED

failures:

---- tests::larger_can_hold_smaller stdout ----
thread 'tests::larger_can_hold_smaller' panicked at 'assertion failed:
larger.can_hold(&smaller)', src/lib.rs:22:9
note: Run with `RUST_BACKTRACE=1` for a backtrace.

failures:
    tests::larger_can_hold_smaller

test result: FAILED. 1 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out
     测试捕获了 bug!

     因为 larger.length 是 8 而 smaller.length 是 5,can_hold 中的长度比较现在因为 8 不小于 5 而返回 false

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

      测试功能的一个常用方法是将需要“测试代码的值”与“期望值”做比较,并检查是否相等

      这可以通过向 assert! 宏传递一个使用 == 运算符的表达式来做到

      不过这个操作实在是太常见了

标准库提供了一对宏来更方便的处理这些操作 assert_eq!assert_ne!

  • 这两个宏分别 比较 两个值是 相等 还是 不相等
  • 当断言失败时他们也会打印出这两个值具体是什么,以便于观察测试 为什么 失败
assert! 只会打印出它从 == 表达式中得到了 false 值,而不是导致 false 的两个值

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

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

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

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

测试通过了:

running 1 test
test tests::it_adds_two ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
      传递给 assert_eq! 宏的第一个参数 4 ,等于调用 add_two(2) 的结果

      测试中的这一行 test tests::it_adds_two ... ok 中 ok 表明测试通过!

在代码中引入一个 bug 来看看使用 assert_eq! 的测试失败是什么样的。修改 add_two 函数的实现使其加 3:

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

再次运行测试:

running 1 test
test tests::it_adds_two ... FAILED

failures:

---- tests::it_adds_two stdout ----
thread 'tests::it_adds_two' panicked at 'assertion failed: `(left == right)`
  left: `4`,
 right: `5`', src/lib.rs:11:9
note: Run with `RUST_BACKTRACE=1` for a backtrace.

failures:
    tests::it_adds_two

test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out
测试捕获到了 bug!

it_adds_two 测试失败,显示信息 assertion failed: `(left == right)` 并表明 left 是 4 而 right 是 5

这个信息有助于开始调试:它说 assert_eq! 的 left 参数是 4,而 right 参数,也就是 add_two(2) 的结果,是 5

在一些语言和测试框架中,断言两个值相等的函数的参数叫做 expectedactual ,而且指定参数的顺序是很关键的。然而在 Rust 中,他们则叫做 leftright ,同时指定期望的值和被测试代码产生的 值的顺序并不重要

      这个测试中的断言也可以写成 assert_eq!(add_two(2), 4)

      这时失败信息会变成 assertion failed: `(left == right)` 其中 left 是 5 而 right 是 4

assert_ne! 宏在传递给它的两个值不相等时通过,而在相等时失败

      在代码按预期运行,不确定值“会”是什么,不过能确定值绝对“不会”是什么的时候,这个宏最有用处

      例如,如果一个函数保证会以某种方式改变其输出,不过这种改变方式是由运行测试时是星期几来决定的,这时最好的断言可能就是函数的输出不等于其输入

assert_eq! 和 assert_ne! 宏在底层分别使用了 ==!= 。当断言失败时,这些宏会使用调试格式打印出其参数,这意味着 被比较的值 必需 实现PartialEqDebug trait

      所有的基本类型和大部分标准库类型都实现了这些 trait

      对于自定义的结构体和枚举,需要实现 PartialEq 才能断言他们的值是否相等,需要实现 Debug 才能在断言失败时打印他们的值

      因为这两个 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)
}

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

    #[test]
    fn greeting_contains_name() {
        let result = greeting("Carol");
        assert!(result.contains("Carol"));
    }
}
      这个程序的需求还没有被确定,因此问候文本开头的 Hello 文本很可能会改变

      然而并不想在需求改变时不得不更新测试,所以相比检查 greeting 函数返回的确切值,仅仅断言输出的文本中包含输入参数

通过将 greeting 改为不包含 name 来在代码中引入一个 bug 来测试失败时是怎样的:

pub fn greeting(name: &str) -> String {
    String::from("Hello!")
}

运行测试:

running 1 test
test tests::greeting_contains_name ... FAILED

failures:

---- tests::greeting_contains_name stdout ----
thread 'tests::greeting_contains_name' panicked at 'assertion failed:
result.contains("Carol")', src/lib.rs:12:9
note: Run with `RUST_BACKTRACE=1` for a backtrace.

failures:
    tests::greeting_contains_name
      结果仅仅告诉了断言失败了和失败的行号

      一个更有用的失败信息应该打印出 greeting 函数的值

为测试函数增加一个自定义失败信息参数:带占位符的格式字符串,以及 greeting 函数的值:

#[test]
fn greeting_contains_name() {
    let result = greeting("Carol");
    assert!(
        result.contains("Carol"),
        "Greeting did not contain name, value was `{}`", result
    );
}

再次运行测试,将会看到更有价值的信息:

running 1 test
test tests::greeting_contains_name ... FAILED

failures:

---- tests::greeting_contains_name stdout ----
thread 'tests::greeting_contains_name' panicked at 'Greeting did not contain name, value was `Hello!`', src/lib.rs:19:2
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
      在测试输出中看到所取得的确切的值,这会帮助理解真正发生了什么,而不是期望发生什么

使用 should_panic 检查 panic

除了检查代码是否返回期望的正确的值之外,检查代码是否 按照期望处理错误 也是很重要的

     例如,考虑前面创建的 Guess 类型。其他使用 Guess 的代码都是基于 Guess 实例仅有的值范围在 1 到 100 的前提

     可以编写一个测试来确保创建一个超出范围的值的 Guess 实例会 panic

通过对函数增加另一个属性 should_panic 来实现这些:

  • 函数中的代码 panic 时会通过
  • 其中的代码没有 panic 时失败

下面展示了一个检查 Guess::new 是否按照期望出错的测试:

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

#[should_panic] 属性位于 #[test] 之后,对应的测试函数之前。运行测试后:

running 1 test
test tests::greater_than_100 ... ok

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

在代码中引入 bug,移除 new 函数在值大于 100 时会 panic 的条件:

// --snip--

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

        Guess {
            value
        }
    }
}

再次运行测试:

running 1 test
test tests::greater_than_100 ... FAILED

failures:

failures:
    tests::greater_than_100

test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out
     这回并没有得到非常有用的信息,不过一旦观察测试函数,会发现它标注了 #[should_panic]

     这个错误意味着代码中测试函数 Guess::new(200) 并没有产生 panic

有时 should_panic 测试结果可能会非常含糊不清,因为它只是告诉我们代码并没有产生 panic。should_panic 甚至在一些不是期望的原因而导致 panic 时也会通过。为了使 should_panic 测试结果更精确,可以给 should_panic 属性增加一个可选的 expected 参数。测试工具会确保错误信息中包含其提供的文本。例如,修改过的 Guess,这里 new 函数根据其值是过大还或者过小而提供不同的 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 = "Guess value must be less than or equal to 100")]
    fn greater_than_100() {
        Guess::new(200);
    }
}
这个测试会通过,因为 should_panic 属性中 expected 参数提供的值是 Guess::new 函数 panic 信息的子串

可以指定期望的整个 panic 信息,在这个例子中是 Guess value must be less than or equal to 100, got 200. 。

expected 信息的选择取决于 panic 信息有多独特或动态,和希望测试有多准确

在这个例子中,错误信息的子字符串足以确保函数在 else if value > 100 的情况下运行

为了观察带有 expected 信息的 should_panic 测试失败时会发生什么,再次引入一个 bug,将 if value < 1 和 else if value > 100 的代码块对换:

if value < 1 {
    panic!("Guess value must be less than or equal to 100, got {}.", value);
} else if value > 100 {
    panic!("Guess value must be greater than or equal to 1, got {}.", value);
}

这一次运行 should_panic 测试,它会失败:

running 1 test
test tests::greater_than_100 ... FAILED

failures:

---- tests::greater_than_100 stdout ----
thread 'tests::greater_than_100' panicked at 'Guess value must be
greater than or equal to 1, got 200.', src/lib.rs:11:13
note: Run with `RUST_BACKTRACE=1` for a backtrace.
note: Panic did not include expected string 'Guess value must be less than or
equal to 100'

failures:
    tests::greater_than_100

test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out
     失败信息表明测试确实如期望 panic 了,不过 panic 信息中并没有包含 expected 信息 'Guess value must be less than or equal to 100'

     而我们得到的 panic 信息是 'Guess value must be greater than or equal to 1, got 200.'。这样就可以开始寻找 bug 在哪了!

将 Result<T, E> 用于测试

目前为止,编写的测试在失败时就会 panic。也可以使用 Result<T, E> 编写测试!这里是第一个例子采用了 Result:

#[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"))
        }
    }
}

现在 it_works 函数的返回值类型为 Result<(), String> 。在函数体中,不同于调用 assert_eq! 宏,而是:

  • 在测试通过时返回 Ok(())
  • 测试失败时返回带有 String 的 Err
     编写测试来返回 Result<T, E> 就可以在函数体中使用“问号”运算符,如此可以方便的编写任何运算符会返回 Err 成员的测试

不能 对这些使用 Result<T, E> 的测试使用 #[should_panic] 注解 。相反应该在测试失败时 直接返回 Err 值

运行测试

就像 cargo run 会编译代码并运行生成的二进制文件一样,cargo test 在测试模式下编译代码并运行生成的测试二进制文件。可以 指定命令行参数 来改变 cargo test 的默认行为

    例如,cargo test 生成的二进制文件的默认行为是并行的运行所有测试,并截获测试运行过程中产生的输出,阻止他们被显示出来,使得阅读测试结果相关的内容变得更容易

可以将一部分命令行参数传递给 cargo test ,而将另外一部分传递给 生成的测试二进制文件 。为了分隔这两种参数,需要先列出传递给 cargo test 的参数,接着是分隔符 -- ,再之后是传递给测试二进制文件的参数

    运行 cargo test --help 会提示 cargo test 的有关参数,这部分参数会传递给这个命令

    而运行 cargo test -- --help 可以提示在分隔符 -- 之后使用的有关参数,这部分会传递给生成的二进制文件

并行或连续的运行测试

当运行多个测试时, Rust 默认使用线程来并行运行。这意味着测试会更快地运行完毕,所以可以更快的得到代码能否工作的反馈。因为测试是在同时运行的,应该确保 测试不能相互依赖 ,或 依赖任何共享的状态 ,包括依赖 共享的环境 ,比如 当前工作目录 或者 环境变量

     举个例子,每一个测试都运行一些代码,假设这些代码都在硬盘上创建一个 test-output.txt 文件并写入一些数据

     接着每一个测试都读取文件中的数据并断言这个文件包含特定的值,而这个值在每个测试中都是不同的

     因为所有测试都是同时运行的,一个测试可能会在另一个测试读写文件过程中修改了文件。那么第二个测试就会失败,并不是因为代码不正确,而是因为测试并行运行时相互干扰

     一个解决方案是使每一个测试读写不同的文件;另一个解决方案是一次运行一个测试

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

$ cargo test -- --test-threads=1
     这里将测试线程设置为 1,告诉程序不要使用任何并行机制

     这也会比并行运行花费更多时间,不过在有共享的状态时,测试就不会潜在的相互干扰了

显示函数输出

默认情况下,当测试通过时,Rust 的测试库会 截获 打印到 标准输出 的所有内容

     比如在测试中调用了 println! 而测试通过了,将不会在终端看到 println! 的输出:只会看到说明测试通过的提示行

     如果测试失败了,则会看到所有标准输出和其他错误信息

例如,下面示例有一个无意义的函数,它打印出其参数的值并接着返回 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 将会看到这些测试的输出:

running 2 tests
test tests::this_test_will_pass ... ok
test tests::this_test_will_fail ... FAILED

failures:

---- tests::this_test_will_fail stdout ----
I got the value 8
thread 'tests::this_test_will_fail' panicked at 'assertion failed: `(left == right)`
  left: `5`,
 right: `10`', src/lib.rs:19:9
note: Run with `RUST_BACKTRACE=1` for a backtrace.

failures:
    tests::this_test_will_fail

test result: FAILED. 1 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out
     注意:输出中不会出现测试通过时打印的内容,即 I got the value 4,因为当测试通过时,这些输出会被截获

     失败测试的输出 I got the value 8 ,则出现在输出的测试摘要部分,同时也显示了测试失败的原因

如果希望也能看到通过的测试中打印的值,截获输出的行为可以通过 –nocapture 参数来禁用:

$ cargo test -- --nocapture

再次运行示例中的测试会显示如下输出:

running 2 tests
I got the value 8
thread 'I got the value 4
tests::this_test_will_failtest tests::this_test_will_pass ... ' panicked at 'okassertion failed: `(left == right)`
  left: `5`,
 right: `10`
', src/lib.rs:20:9
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace.
test tests::this_test_will_fail ... FAILED

failures:

failures:
    tests::this_test_will_fail

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

error: test failed, to rerun pass '--lib'
     注意:测试的输出和测试结果的输出是相互交叉的,这是由于测试是并行运行的

     尝试同时使用 --test-threads=1 和 --nocapture 功能来看看输出是什么样子!

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

     有时运行整个测试集会耗费很长时间

     如果你只负责特定位置的代码,可能会希望只运行与这些代码相关的测试

通过向 cargo test 传递所 希望运行的测试名称 的参数来选择运行哪些测试。为了展示如何运行部分测试,下面为 add_two 函数创建了三个测试,可以选择具体运行哪一个:

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

如果没有传递任何参数就运行测试,所有测试都会并行运行:

running 3 tests
test tests::add_two_and_two ... ok
test tests::add_three_and_two ... ok
test tests::one_hundred ... ok

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

运行单个测试

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

$ cargo test one_hundred
    Finished dev [unoptimized + debuginfo] target(s) in 0.0 secs
     Running target/debug/deps/adder-06a75b4a1f2515e9

running 1 test
test tests::one_hundred ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 2 filtered out
      只有名称为 one_hundred 的测试被运行了;因为其余两个测试并不匹配这个名称

      测试输出在摘要行的结尾显示了 2 filtered out 表明还存在比本次所运行的测试更多的测试被过滤掉了

不能像这样指定多个测试名称;只有传递给 cargo test 的 第一个值 才会被使用

过滤运行多个测试

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

$ cargo test add
    Finished dev [unoptimized + debuginfo] target(s) in 0.0 secs
     Running target/debug/deps/adder-06a75b4a1f2515e9

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

test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 1 filtered out
    这运行了所有名字中带有 add 的测试,也过滤掉了名为 one_hundred 的测试

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

忽略某些测试

    有时一些特定的测试执行起来是非常耗费时间的,所以在大多数运行 cargo test 的时候希望能排除他们

虽然可以通过参数列举出所有希望运行的测试来做到,也可以使用 ignore 属性来标记耗时的测试并排除他们,如下所示:

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

#[test]
#[ignore]
fn expensive_test() {
    // 需要运行一个小时的代码
}

对于想要排除的测试,在 #[test] 之后增加了 #[ignore] 行。现在如果运行测试,就会发现 it_works 运行了,而 expensive_test 没有运行:

$ cargo test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished dev [unoptimized + debuginfo] target(s) in 0.24 secs
     Running target/debug/deps/adder-ce99bcc2479f4607

running 2 tests
test expensive_test ... ignored
test it_works ... ok

test result: ok. 1 passed; 0 failed; 1 ignored; 0 measured; 0 filtered out
expensive_test 被列为 ignored

如果只希望运行被忽略的测试,可以使用 cargo test – –ignored

$ cargo test -- --ignored
    Finished dev [unoptimized + debuginfo] target(s) in 0.0 secs
     Running target/debug/deps/adder-ce99bcc2479f4607

running 1 test
test expensive_test ... ok

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

组织测试

测试是一个复杂的概念,而且不同的开发者也采用不同的技术和组织。Rust 社区倾向于根据测试的两个主要分类来考虑问题:

  • 单元测试:倾向于更小而更集中,在 隔离的环境 中一次测试 一个模块 ,或者是测试 私有 接口。
  • 集成测试:对于你的库来说则完全是外部的。它们与其他外部代码一样,通过相同的方式使用你的代码,只测试 公有 接口而且每个测试都有可能会测试 多个模块
  为了保证你的库能够按照预期运行,从独立和整体的角度编写这两类测试都是非常重要的

单元测试

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

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

测试模块的 #[cfg(test)] 注解告诉 Rust 只在执行 cargo test 时才编译和运行测试代码,而在运行 cargo build 时不这么做。这在只希望构建库的时候可以节省编译时间,并且因为它们并没有包含测试,所以能减少编译产生的文件的大小

      与之对应的集成测试因为位于另一个文件夹,所以它们并不需要 #[cfg(test)] 注解

      然而单元测试位于与源码相同的文件中,所以需要使用 #[cfg(test)] 来指定他们不应该被包含进编译结果中

回忆这章一开始新建的 adder 项目吗,Cargo 为我们生成了如下代码:

#[cfg(test)]
mod tests {
    #[test]
    fn it_works() {
        assert_eq!(2 + 2, 4);
    }
}
      上述代码就是自动生成的测试模块。cfg 属性代表 configuration ,它告诉 Rust 其之后的项只应该被包含进特定配置选项中

      在这个例子中,配置选项是 test,即 Rust 所提供的用于编译和运行测试的配置选项

通过使用 cfg 属性,Cargo 只会在主动使用 cargo test 运行测试时才编译测试代码:需要编译的不仅仅有标注为 #[test] 的函数之外,还包括测试模块中可能存在的帮助函数

测试私有函数

    测试社区中一直存在关于是否应该对私有函数直接进行测试的论战,而在其他语言中想要测试私有函数是一件困难的,甚至是不可能的事

Rust 的私有性规则确实允许测试私有函数。考虑下面带有私有函数 internal_adder 的代码:

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

注意:internal_adder 函数并没有标记为 pub,不过因为 tests 也仅仅是另一个模块,完全可以在测试中导入和调用 internal_adder

      如果你并不认为应该测试私有函数,Rust 也不会强迫你这么做

集成测试

在 Rust 中,集成测试对于需要测试的库来说完全是外部的。同其他使用库的代码一样使用库文件,也就是说它们只能调用一部分库中的公有 API

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

为了创建集成测试,你需要先创建一个 tests 目录。

tests 目录

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

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

use adder;

#[test]
fn it_adds_two() {
    assert_eq!(4, adder::add_two(2));
}
  • 与单元测试不同,需要在文件顶部添加 use adder 。这是因为每一个 tests 目录中的测试文件都是完全 独立的 crate ,所以需要在每一个文件中导入库
  • 并不需要将 tests/integration_test.rs 中的任何代码标注为 #[cfg(test)]
tests 文件夹在 Cargo 中是一个特殊的文件夹, Cargo 只会在运行 cargo test 时编译这个目录中的文件

运行 cargo test :

$ cargo test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished dev [unoptimized + debuginfo] target(s) in 0.31 secs
     Running target/debug/deps/adder-abcabcabc

running 1 test
test tests::internal ... ok

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

     Running target/debug/deps/integration_test-ce99bcc2479f4607

running 1 test
test it_adds_two ... ok

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

   Doc-tests adder

running 0 tests

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

现在有了四个部分的输出:

  1. 单元测试:每个单元测试一行(示例中有一个叫做 internal 的测试)
  2. 集成测试:
    • 行 Running target/debug/deps/integration-test-ce99bcc2479f4607(在输出最后的哈希值可能不同)开头
    • 每一行是一个集成测试中的测试函数
    • 一个位于 Doc-tests adder 部分之前的集成测试的摘要行
  3. 文档测试
  4. 整个测试的摘要行
      单元测试函数越多,单元测试部分的结果行就会越多。同样的,在集成文件中增加的测试函数越多,也会在对应的测试结果部分增加越多的结果行

      每一个集成测试文件有对应的测试结果部分,所以如果在 tests 目录中增加更多文件,测试结果中就会有更多集成测试结果部分

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

$ cargo test --test integration_test
    Finished dev [unoptimized + debuginfo] target(s) in 0.0 secs
     Running target/debug/integration_test-952a27e0126bb565

running 1 test
test it_adds_two ... ok

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

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

集成测试中的子模块

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

      将每个集成测试文件当作其自己的 crate 来对待,这更有助于创建单独的作用域,这种单独的作用域能提供更类似与最终使用者使用 crate 的环境

      然而,tests 目录中的文件不能像 src 中的文件那样共享相同的行为

当你有一些在多个集成测试文件都会用到的帮助函数,尝试将他们提取到一个通用的模块中时, tests 目录中不同文件的行为就会显得很明显。例如,如果可以创建 一个tests/common.rs 文件并创建一个名叫 setup 的函数,希望这个函数能被多个测试文件的测试函数调用:

pub fn setup() {
    // 编写特定库测试所需的代码
}

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

running 1 test
test tests::internal ... ok

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

     Running target/debug/deps/common-b8b07b6f1be2db70

running 0 tests

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

     Running target/debug/deps/integration_test-d993c68b431d39df

running 1 test
test it_adds_two ... ok

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

   Doc-tests adder

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
    并不想要common 出现在测试结果中显示 running 0 tests 。只是希望其能被其他多个集成测试文件中调用罢了

为了不让 common 出现在测试输出中,将创建 tests/common/mod.rs ,而不是创建 tests/common.rs

这是一种 Rust 的命名规范,这样命名告诉 Rust 不要将 common 看作一个集成测试文件

将 setup 函数代码移动到 tests/common/mod.rs 并删除 tests/common.rs 文件之后,测试输出中将不会出现这一部分

tests 目录中的子目录不会被作为单独的 crate 编译或作为一个测试结果部分出现在测试输出中

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

use adder;

mod common;

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

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

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

总结

Rust 的测试功能提供了一个确保即使改变了函数的实现方式,也能继续以期望的方式运行的途径:

  • 单元测试:独立地验证库的不同部分,也能够测试私有函数实现细节
  • 集成测试:检查多个部分是否能结合起来正确地工作,并像其他外部代码那样测试库的公有 API

即使 Rust 的类型系统和所有权规则可以帮助避免一些 bug,不过测试对于减少代码中不符合期望行为的逻辑 bug 仍然是很重要的

Next:构建命令行小程序

Previous:泛型

Home: 目录