跳转到内容

Rust 测试

Rust 把测试功能直接做进了语言和 cargo 里,所以入门时不需要另外找一个测试库。这一页只讲刚好够用的内容,带你写出并运行第一个测试。

测试就是一个标了 #[test] 的普通函数:

fn add(a: i32, b: i32) -> i32 {
a + b
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn it_adds_two_numbers() {
assert_eq!(add(2, 3), 5);
}
}

有几点值得说明:

  • #[cfg(test)] 告诉 Rust,这个模块只在运行测试时才编译,它不是正常程序的一部分。
  • use super::*; 会把父模块里的所有东西(这里是 add 函数)引入进来,这样测试才能调用它。
  • #[test] 把某个函数标记为测试,cargo test 会运行它。

在项目文件夹里运行:

Terminal window
cargo test

你会看到类似这样的输出:

running 1 test
test tests::it_adds_two_numbers ... ok
test result: ok. 1 passed; 0 failed; 0 ignored

下面三个宏能满足你大部分的需求:

#[test]
fn checks() {
assert!(2 + 2 == 4); // 条件为 false 时,测试失败
assert_eq!(2 + 2, 4); // 两个值不相等时,测试失败
assert_ne!(2 + 2, 5); // 两个值相等时,测试失败
}

通常更推荐用 assert_eq! 和 assert_ne!,而不是简单的 assert!(a == b)。因为它们失败时会把两个值都打印出来,你一眼就能看出哪里不对,而不是只看到一句“assertion failed”(断言失败)。

fn add(a: i32, b: i32) -> i32 {
a + b
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn this_will_fail() {
assert_eq!(add(2, 2), 5);
}
}
test tests::this_will_fail ... FAILED
assertion `left == right` failed
left: 4
right: 5

测试失败时,你想看到的正是这种信息:它告诉你代码实际算出了什么(left),而测试期望的是什么(right)。

有时候,正确的行为就是 panic,比如一个应该拒绝非法输入的函数。#[should_panic] 就是用来测试这种情况的:

fn divide(a: i32, b: i32) -> i32 {
if b == 0 {
panic!("不能除以零");
}
a / b
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[should_panic]
fn dividing_by_zero_panics() {
divide(10, 0);
}
}

测试失败时,你希望越快知道原因越好。两个小小的补充就很有帮助。

自定义信息。 assert!、assert_eq! 和 assert_ne! 都可以在值的后面再接一条信息,写法和 println! 一样。只有检查失败时才会打印它。

should_panic 的 expected。 单纯的 #[should_panic] 遇到任何 panic 都会通过,哪怕那是另一个 bug 引起的。加上 expected = "..." 之后,只有 panic 信息里包含这段文字,测试才会通过。

下面还是 divide 函数,加了一个 main 让你能运行它,另外有两个测试分别用到了这两个技巧:

fn divide(a: i32, b: i32) -> i32 {
if b == 0 {
panic!("不能除以零");
}
a / b
}
fn main() {
println!("10 / 2 = {}", divide(10, 2));
println!("7 / 2 = {}", divide(7, 2));
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rounds_down() {
let result = divide(7, 2);
assert_eq!(result, 3, "7 / 2 应该向下取整,但得到的是 {result}");
}
#[test]
#[should_panic(expected = "除以零")]
fn dividing_by_zero_panics() {
divide(10, 0);
}
}

输出:

10 / 2 = 5
7 / 2 = 3
▶ 动手试一试

上面的输出是 cargo run 打印的,因为 main 不会运行测试。要运行测试,请用 cargo test。在 Rust Playground 里,打开 Run 旁边的菜单,选择 Test。你会看到:

running 2 tests
test tests::rounds_down ... ok
test tests::dividing_by_zero_panics - should panic ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

测试是并行运行的,所以两行 test ... 的顺序可能会变。现在假设测试期望的是 4 而不是 3。失败信息里就会出现你写的那句话,紧挨着两个值:

assertion `left == right` failed: 7 / 2 应该向下取整,但得到的是 3
left: 3
right: 4

如果 divide panic 时的信息不一样,expected 检查就会发现(这里假设写的是 expected = "负数"):

note: panic did not contain expected string
panic message: "不能除以零"
expected substring: "负数"

这几行是 Rust 自己输出的,意思是:“panic 信息里没有包含预期的字符串。”下面分别列出了实际的 panic 信息和期望包含的子串。

说实话:如果只是一个很短的脚本,你大概不会写测试。一旦代码要被复用或者以后会修改,测试就开始显出价值了。有了测试,你可以放心地重构,因为一旦改坏了什么,cargo test 会立刻告诉你,而不用等用户来告诉你。

测试也可以写在文档里:/// 文档注释里的代码示例同样会由 cargo test 运行,所以你的示例永远不会过时。Rust 文档一页会教你怎么做。

两道练习的答案里都有一个可以运行的 main,还有用 cargo test(或 Playground 里的 Test)运行的测试。

写一个 fn grade(score: u32) -> char:90 分及以上返回 'A',80 到 89 分返回 'B',70 到 79 分返回 'C',更低的返回 'F'。然后写测试检查边界值,比如 90 和 89。提示:bug 最喜欢藏在边界上。专门测试正好 90 分,就能抓住把 >= 写成 > 这种经典错误。

查看答案
fn grade(score: u32) -> char {
if score >= 90 {
'A'
} else if score >= 80 {
'B'
} else if score >= 70 {
'C'
} else {
'F'
}
}
fn main() {
for score in [95, 85, 72, 40] {
println!("{score} -> {}", grade(score));
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn edges_of_a() {
assert_eq!(grade(90), 'A');
assert_eq!(grade(89), 'B');
}
#[test]
fn edges_of_c() {
assert_eq!(grade(70), 'C');
assert_eq!(grade(69), 'F');
}
#[test]
fn zero_is_f() {
assert_eq!(grade(0), 'F', "0 分应该是不及格");
}
}

输出:

95 -> A
85 -> B
72 -> C
40 -> F
▶ 动手试一试

cargo test 应该报告 3 passed。试着把 score >= 90 改成 score > 90,再运行一次测试:edges_of_a 会失败,并显示 left: 'B' 和 right: 'A'。

写一个 fn safe_divide(a: i32, b: i32) -> Option<i32>,当 b 为 0 时返回 None,而不是 panic。写一个测试检查正常的除法,再写一个测试检查除以零的情况。提示:assert_eq! 也能用于 Option,所以可以写 assert_eq!(safe_divide(1, 0), None)。

查看答案
fn safe_divide(a: i32, b: i32) -> Option<i32> {
if b == 0 {
None
} else {
Some(a / b)
}
}
fn main() {
println!("{:?}", safe_divide(10, 2));
println!("{:?}", safe_divide(1, 0));
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn divides_normally() {
assert_eq!(safe_divide(10, 2), Some(5));
}
#[test]
fn zero_gives_none() {
assert_eq!(safe_divide(1, 0), None);
}
}

输出:

Some(5)
None
▶ 动手试一试

和上面的 divide 函数对比一下。那里除以零会 panic,所以测试需要 #[should_panic]。这里它只是一个普通的返回值,用简单的 assert_eq! 就够了。返回 Option 或 Result 的函数通常更容易测试。

  • 给函数标上 #[test],它就成了测试;用 cargo test 运行所有测试。
  • assert!、assert_eq! 和 assert_ne! 能完成日常大部分检查。优先用 assert_eq!/assert_ne!,失败信息更清楚。
  • #[should_panic] 用来测试函数在该 panic 的时候确实 panic 了。加上 expected = "...",还能确认它是因为正确的原因 panic 的。
  • 可以给任何断言宏加一条自定义信息,在失败时说明出了什么问题。
  • 对那些你预计以后会修改或复用的代码,测试最有价值。

小测验

1. 哪个属性能把函数标记为 cargo test 要运行的测试?

2. 为什么通常更推荐用 assert_eq! 和 assert_ne!,而不是简单的 assert!(a == b)?

3. #[should_panic] 有什么作用?

得分: 0 / 3