编写测试

ch11-01-writing-tests.md
commit 55b294f20fc846a13a9be623bf322d8b364cee77

测试用来验证非测试的代码按照期望的方式运行的 Rust 函数。测试函数体通常包括一些设置,运行需要测试的代码,接着断言其结果是我们所期望的。让我们看看 Rust 提供的具体用来编写测试的功能:test属性、一些宏和should_panic属性。

测试函数剖析

作为最简单例子,Rust 中的测试就是一个带有test属性注解的函数。属性(attribute)是关于 Rust 代码片段的元数据:第五章中结构体中用到的derive属性就是一个例子。为了将一个函数变成测试函数,需要在fn行之前加上#[test]。当使用cargo test命令运行测试函数时,Rust 会构建一个测试执行者二进制文件用来运行标记了test属性的函数并报告每一个测试是通过还是失败。

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

我们将先通过对自动生成的测试模板做一些试验来探索测试如何工作的一些方面内容,而不实际测试任何代码。接着会写一些真实的测试来调用我们编写的代码并断言他们的行为是正确的。

让我们创建一个新的库项目adder

$ cargo new adder
     Created library `adder` project
$ cd adder

adder 库中src/lib.rs的内容应该看起来像这样:

Filename: src/lib.rs

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

Listing 11-1: The test module and function generated automatically for us by cargo new

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

这个函数目前没有任何内容,这意味着没有代码会使测试失败;一个空的测试是可以通过的!让我们运行一下看看它是否通过了。

cargo test命令会运行项目中所有的测试,如列表 11-2 所示:

$ cargo test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished debug [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

   Doc-tests adder

running 0 tests

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

Listing 11-2: The output from running the one automatically generated test

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

这里并没有任何被标记为忽略的测试,所以总结表明0 ignored。在下一部分关于运行测试的不同方式中会讨论忽略测试。0 measured统计是针对测试性能的性能测试的。性能测试(benchmark tests)在编写本书时,仍只属于开发版 Rust(nightly Rust)。请查看附录 D 来了解更多开发版 Rust 的信息。

测试输出中以Doc-tests adder开头的下一部分是所有文档测试的结果。现在并没有任何文档测试,不过 Rust 会编译任何出现在 API 文档中的代码示例。这个功能帮助我们使文档和代码保持同步!在第十四章的“文档注释”部分会讲到如何编写文档测试。现在我们将忽略Doc-tests部分的输出。

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

Filename: src/lib.rs

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

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

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

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

让我们增加另一个测试,不过这一次是一个会失败的测试!当测试函数中出现 panic 时测试就失败了。第九章讲到了最简单的造成 panic 的方法:调用panic!宏!写入新函数后 src/lib.rs 现在看起来如列表 11-3 所示:

Filename: src/lib.rs

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

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

Listing 11-3: Adding a second test; one that will fail since we call the panic! macro

再次cargo test运行测试。输出应该看起来像列表 11-4,它表明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:9
note: Run with `RUST_BACKTRACE=1` for a backtrace.

failures:
    tests::another

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

error: test failed

Listing 11-4: Test results when one test passes and one test fails

test tests::another这一行是FAILED而不是ok了。在单独测试结果和总结之间多了两个新的部分:第一个部分显示了测试失败的详细原因。在这个例子中,another因为panicked at 'Make this test fail'而失败,这位于 src/lib.rs 的第 9 行。下一部分仅仅列出了所有失败的测试,这在很有多测试和很多失败测试的详细输出时很有帮助。可以使用失败测试的名称来只运行这个测试,这样比较方便调试;下一部分会讲到更多运行测试的方法。

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

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

使用assert!宏来检查结果

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

回忆一下第五章中,列表 5-9 中有一个Rectangle结构体和一个can_hold方法,在列表 11-5 中再次使用他们。将他们放进 src/lib.rs 而不是 src/main.rs 并使用assert!宏编写一些测试。

Filename: src/lib.rs

#[derive(Debug)]
pub struct Rectangle {
    length: u32,
    width: u32,
}

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

Listing 11-5: The Rectangle struct and its can_hold method from Chapter 5

can_hold方法返回一个布尔值,这意味着它完美符合assert!宏的使用场景。在列表 11-6 中,让我们编写一个can_hold方法的测试来作为练习,这里创建一个长为 8 宽为 7 的Rectangle实例,并假设它可以放得下另一个长为5 宽为 1 的Rectangle实例:

Filename: src/lib.rs

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

    #[test]
    fn larger_can_hold_smaller() {
        let larger = Rectangle { length: 8, width: 7 };
        let smaller = Rectangle { length: 5, width: 1 };

        assert!(larger.can_hold(&smaller));
    }
}

Listing 11-6: A test for can_hold that checks that a larger rectangle indeed holds a smaller rectangle

注意在tests模块中新增加了一行:use super::*;tests是一个普通的模块,它遵循第七章介绍的通常的可见性规则。因为这是一个内部模块,需要将外部模块中被测试的代码引入到内部模块的作用域中。这里选择使用全局导入使得外部模块定义的所有内容在tests模块中都是可用的。

我们将测试命名为larger_can_hold_smaller,并创建所需的两个Rectangle实例。接着调用assert!宏并传递larger.can_hold(&smaller)调用的结果作为参数。这个表达式预期会返回true,所以测试应该通过。让我们拭目以待!

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

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

它确实通过了!再来增加另一个测试,这一回断言一个更小的矩形不能放下一个更大的矩形:

Filename: src/lib.rs

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

    #[test]
    fn larger_can_hold_smaller() {
        let larger = Rectangle { length: 8, width: 7 };
        let smaller = Rectangle { length: 5, width: 1 };

        assert!(larger.can_hold(&smaller));
    }

    #[test]
    fn smaller_can_hold_larger() {
        let larger = Rectangle { length: 8, width: 7 };
        let smaller = Rectangle { length: 5, width: 1 };

        assert!(!smaller.can_hold(&larger));
    }
}

Cargo 编译并运行了测试。这里有两部分输出:本章我们将关注第一部分。第二部分是文档测试的输出,第十四章会介绍他们。现在注意看这一行:

test it_works ... ok

it_works文本来源于测试函数的名称。

这里也有一行总结告诉我们所有测试的聚合结果:

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

assert!

空的测试函数之所以能通过是因为任何没有panic!的测试都是通过的,而任何panic!的测试都算是失败。让我们使用`assert!宏来使测试失败:

Filename: src/lib.rs

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

assert!宏由标准库提供,它获取一个参数,如果参数是true,什么也不会发生。如果参数是false,这个宏会panic!。再次运行测试:

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

running 1 test
test it_works ... FAILED

failures:

---- it_works stdout ----
    thread 'it_works' panicked at 'assertion failed: false', src/lib.rs:5
note: Run with `RUST_BACKTRACE=1` for a backtrace.


failures:
    it_works

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

error: test failed

Rust 表明测试失败了:

test it_works ... FAILED

并展示了测试是因为src/lib.rs的第 5 行assert!宏得到了一个false`值而失败的:

thread 'it_works' panicked at 'assertion failed: false', src/lib.rs:5

失败的测试也体现在了总结行中:

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

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

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

下面是分别使用这两个宏其会测试通过的例子:

Filename: src/lib.rs

#[test]
fn it_works() {
    assert_eq!("Hello", "Hello");

    assert_ne!("Hello", "world");
}

也可以对这些宏指定可选的第三个参数,它是一个会加入错误信息的自定义文本。这两个宏展开后的逻辑看起来像这样:

// assert_eq! - panic if the values aren't equal
if left_val != right_val {
    panic!(
        "assertion failed: `(left == right)` (left: `{:?}`, right: `{:?}`): {}"
        left_val,
        right_val,
        optional_custom_message
    )
}

// assert_ne! - panic if the values are equal
if left_val == right_val {
    panic!(
        "assertion failed: `(left != right)` (left: `{:?}`, right: `{:?}`): {}"
        left_val,
        right_val,
        optional_custom_message
    )
}

看看这个因为hello不等于world而失败的测试。我们还增加了一个自定义的错误信息,greeting operation failed

Filename: src/lib.rs

#[test]
fn a_simple_case() {
    let result = "hello"; // this value would come from running your code
    assert_eq!(result, "world", "greeting operation failed");
}

毫无疑问运行这个测试会失败,而错误信息解释了为什么测试失败了并且带有我们的指定的自定义错误信息:

---- a_simple_case stdout ----
    thread 'a_simple_case' panicked at 'assertion failed: `(left == right)`
    (left: `"hello"`, right: `"world"`): greeting operation failed',
    src/main.rs:4

assert_eq!的两个参数被称为 "left" 和 "right" ,而不是 "expected" 和 "actual" ;值的顺序和硬编码的值并没有什么影响。

因为这些宏使用了==!=运算符并使用调试格式打印这些值,进行比较的值必须实现PartialEqDebug trait。Rust 提供的类型实现了这些 trait,不过自定义的结构体和枚举则需要自己实现PartialEq以便能够断言这些值是否相等,和实现Debug以便在断言失败时打印出这些值。因为第五章提到过这两个 trait 都是 derivable trait,所以通常可以直接在结构体或枚举上加上#[derive(PartialEq, Debug)]注解。查看附录 C 来寻找更多关于这些和其他 derivable trait 的信息。

使用should_panic测试期望的失败

可以使用另一个属性来反转测试中的失败:should_panic。这在测试调用特定的函数会产生错误的函数时很有帮助。例如,让我们测试第八章中的一些我们知道会 panic 的代码:尝试使用 range 语法和并不组成完整字母的字节索引来创建一个字符串 slice。在有#[test]属性的函数之前增加#[should_panic]属性,如列表 11-1 所示:

Filename: src/lib.rs
#[test]
#[should_panic]
fn slice_not_on_char_boundaries() {
    let s = "Здравствуйте";
    &s[0..1];
}

Listing 11-1: A test expecting a panic!

这个测试是成功的,因为我们表示代码应该会 panic。相反如果代码因为某种原因没有产生panic!则测试会失败。

使用should_panic的测试是脆弱的,因为难以保证测试不会因为一个不同于我们期望的原因失败。为了帮助解决这个问题,should_panic属性可以增加一个可选的expected参数。测试工具会确保错误信息里包含我们提供的文本。一个比列表 11-1 更健壮的版本如列表 11-2 所示:

Filename: src/lib.rs
#[test]
#[should_panic(expected = "do not lie on character boundary")]
fn slice_not_on_char_boundaries() {
    let s = "Здравствуйте";
    &s[0..1];
}

Listing 11-2: A test expecting a panic! with a particular message

请自行尝试当should_panic的测试出现 panic 但并不符合期望的信息时会发生什么:在测试中因为不同原因造成panic!,或者将期望的 panic 信息改为并不与字母字节边界 panic 信息相匹配。