Rust 测试进阶:mock、属性测试与基准测试
你已经会用 #[test] 和 assert_eq! 写测试了(还不会的话,先看 Rust 测试)。对小程序来说,这已经够用了。可一到真实项目,新问题很快就冒出来:测试应该放在哪里?发邮件或者读取时钟的代码要怎么测?怎样找出那个你没想到的输入?你那个“更快”的版本,真的更快吗?
可以把它想象成一个汽车修理厂。基础测试是检查每个零件能不能用。这一页补齐修理厂里剩下的设备:给零件用的测试台(fake 和 mock)、用成千上万次随机颠簸来摇晃汽车的机器(属性测试)、记录上次样子的照片(快照测试),以及一块值得信赖的秒表(基准测试)。
开始之前先坦白一句:Rust Playground 的 Run 按钮运行的是 main,不是测试,而且 Playground 里也没有下面用到的测试 crate。所以这里的大部分示例展示的是在普通电脑上的一个小项目里得到的真实 cargo test 和 cargo bench 输出。有 main 的程序照常提供“动手试一试”按钮。
组织你的测试
Section titled “组织你的测试”下面是本页用到的小库 shop 的目录结构:
shop/├── Cargo.toml├── src/│ ├── lib.rs <- 代码 + `mod tests` 里的单元测试│ └── orders.rs├── tests/│ ├── checkout.rs <- 一个集成测试│ └── common/│ └── mod.rs <- 集成测试共用的辅助函数└── benches/ └── stock.rs <- 一个基准测试Rust 有三种测试,cargo test 会把它们全部运行:
- 单元测试和代码放在一起,写在
#[cfg(test)] mod tests块里。它们连私有函数也能访问。 - 集成测试放在
tests/文件夹里。每个文件都会被编译成一个单独的 crate,像用户一样从外部使用你的库,所以它们只能调用pub项。 - 文档测试就是
///注释里的代码示例。它们证明你的文档依然可用。Rust 文档一页会详细介绍。
下面是 src/lib.rs 的一部分。注意 apply_discount 上的文档测试,以及 mod tests 里的辅助函数和 fixture 函数:
/// 购物车里的一行商品。价格以分为单位。#[derive(Debug, Clone, PartialEq)]pub struct Item { pub name: String, pub price_cents: u32, pub qty: u32,}
pub fn cart_total(items: &[Item]) -> u32 { items.iter().map(|i| i.price_cents * i.qty).sum()}
/// 从价格中减去百分之 `percent`。////// ```/// use shop::apply_discount;////// assert_eq!(apply_discount(1000, 25), 750);/// ```pub fn apply_discount(price_cents: u32, percent: u32) -> u32 { assert!(percent <= 100, "discount must be at most 100%, got {percent}%"); price_cents - price_cents * percent / 100}
/// 把 "12.50" 转成 1250 分。pub fn parse_price(text: &str) -> Result<u32, String> { /* ... */ }
/// 把 1250 分转成 "12.50"。pub fn format_price(cents: u32) -> String { format!("{}.{:02}", cents / 100, cents % 100)}
#[cfg(test)]mod tests { use super::*;
// 辅助函数:调用起来很短,每个测试都用同样的方式创建商品。 fn item(name: &str, price_cents: u32, qty: u32) -> Item { Item { name: name.to_string(), price_cents, qty } }
// fixture:一辆贴近真实情况的购物车,供几个测试共用。 fn sample_cart() -> Vec<Item> { vec![item("coffee", 450, 2), item("bagel", 300, 1)] }
#[test] fn total_of_sample_cart() { assert_eq!(cart_total(&sample_cart()), 1200); }
#[test] fn empty_cart_costs_nothing() { assert_eq!(cart_total(&[]), 0); }
#[test] #[should_panic(expected = "at most 100%")] fn discount_over_100_panics() { apply_discount(1000, 150); }
// 测试可以返回 Result,这样里面就能用 `?`。 #[test] fn parses_a_price() -> Result<(), String> { let cents = parse_price("12.50")?; assert_eq!(cents, 1250); Ok(()) }
#[test] #[ignore = "slow: checks ten million prices"] fn every_price_round_trips() { for cents in 0..10_000_000 { assert_eq!(parse_price(&format_price(cents)), Ok(cents)); } }}有几点值得注意:
- 辅助函数和 fixture 只是普通函数。 Rust 没有专门的 fixture 机制,你也很少会想念它。因为它们放在
#[cfg(test)]里,所以永远不会进入你真正的程序。 #[should_panic(expected = "...")]只有在 panic 信息包含这段文字时才会通过,所以另一个 bug 引起的 panic 不算数。Rust 测试一页有更多例子。- 返回
Result的测试可以使用?。如果测试返回Err,它就会失败并打印出错误,例如Error: "missing '.' in \"12,50\""。 #[ignore]默认会跳过一个很慢或者特殊的测试。=后面的文字是原因,cargo test会把它打印出来。
集成测试通过公有 API 使用这个库。它共用的辅助函数放在 tests/common/mod.rs 里:
use shop::Item;
pub fn breakfast_cart() -> Vec<Item> { vec![ Item { name: "coffee".into(), price_cents: 450, qty: 2 }, Item { name: "bagel".into(), price_cents: 300, qty: 1 }, ]}mod common;
use shop::{apply_discount, cart_total, format_price};
#[test]fn discounted_breakfast_total() { let total = cart_total(&common::breakfast_cart()); let to_pay = apply_discount(total, 10); println!("to pay: {}", format_price(to_pay)); assert_eq!(format_price(to_pay), "10.80");}运行 cargo test,每种测试各占一段输出:
Running unittests src/lib.rs (target/debug/deps/shop-ffc1628aec947def)
running 5 teststest tests::every_price_round_trips ... ignored, slow: checks ten million pricestest tests::parses_a_price ... oktest tests::empty_cart_costs_nothing ... oktest tests::total_of_sample_cart ... oktest tests::discount_over_100_panics - should panic ... ok
test result: ok. 4 passed; 0 failed; 1 ignored; 0 measured; 0 filtered out; finished in 0.08s
Running tests/checkout.rs (target/debug/deps/checkout-c1e4aa0cf6375c2a)
running 1 testtest discounted_breakfast_total ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Doc-tests shop
running 1 testtest src/lib.rs - apply_discount (line 16) ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s选择要运行哪些测试
Section titled “选择要运行哪些测试”日常开发中,你不一定每次都想运行所有测试。下面这些命令能帮上忙。单独一个 -- 后面的内容会传给测试程序本身,而不是传给 Cargo。
cargo test discount # 只运行名字里包含 "discount" 的测试cargo test --test checkout # 只运行 tests/checkout.rs 这个文件cargo test --lib # 只运行 src/ 里的单元测试cargo test --doc # 只运行文档测试cargo test -- --ignored # 只运行标了 #[ignore] 的测试cargo test -- --include-ignored # 全部运行,包括被忽略的cargo test -- --nocapture # 显示通过的测试里 println! 的输出cargo test -- --test-threads=1 # 一次只运行一个测试过滤会匹配完整测试名的一部分,范围覆盖所有测试文件。这里的 discount 找到了一个单元测试和一个集成测试:
Running unittests src/lib.rs (target/debug/deps/shop-ffc1628aec947def)
running 1 testtest tests::discount_over_100_panics - should panic ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 6 filtered out; finished in 0.09s
Running tests/checkout.rs (target/debug/deps/checkout-630939367802bf1e)
running 1 testtest discounted_breakfast_total ... ok那个很慢的测试只有在你要求时才会运行。在这台电脑上,一千万次往返转换大约花了 3 秒:
running 1 testtest tests::every_price_round_trips ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 4 filtered out; finished in 3.20s默认情况下,Rust 会隐藏通过的测试里 println! 的输出,让报告保持清爽。--nocapture 会把它显示出来:
$ cargo test --test checkout -- --nocapturerunning 1 testto pay: 10.80test discounted_breakfast_total ... ok测试替身:用 trait 来“造假”
Section titled “测试替身:用 trait 来“造假””有些代码要和外部世界打交道:发邮件、调用 API、读取时钟。你不希望测试真的发出邮件。如果代码总是去问真实的时钟,你也没法测试“晚上 9 点会发生什么”。
Rust 惯用的做法根本不需要任何库:把这个依赖描述成一个 trait,让函数接受任何实现了它的类型,再为测试写一个小小的 fake(假实现)。真实程序传入真的那个,测试传入 fake。
下面这个例子里,确认订单时会给顾客发一封邮件,大订单还会通知销售团队:
trait Mailer { fn send(&mut self, to: &str, body: &str);}
// 真正的邮件发送器。在真实应用里,这里会调用邮件服务。struct ConsoleMailer;
impl Mailer for ConsoleMailer { fn send(&mut self, to: &str, body: &str) { println!("邮件发往 {to}:{body}"); }}
struct Order { id: u32, email: String, total_cents: u32,}
fn confirm_order(order: &Order, mailer: &mut impl Mailer) { let body = format!("Order #{} confirmed", order.id); mailer.send(&order.email, &body); if order.total_cents >= 10_000 { }}
fn main() { let mut mailer = ConsoleMailer; confirm_order(&small, &mut mailer); confirm_order(&big, &mut mailer);}
#[cfg(test)]mod tests { use super::*;
// fake:它什么都不发送,只是把每次调用记下来。 #[derive(Default)] struct FakeMailer { sent: Vec<(String, String)>, }
impl Mailer for FakeMailer { fn send(&mut self, to: &str, body: &str) { self.sent.push((to.to_string(), body.to_string())); } }
#[test] fn small_order_sends_one_email() { let mut fake = FakeMailer::default();
confirm_order(&order, &mut fake);
assert_eq!(fake.sent.len(), 1); }
#[test] fn big_order_also_tells_sales() { let mut fake = FakeMailer::default();
confirm_order(&order, &mut fake);
assert_eq!(fake.sent.len(), 2); }}输出:
邮件发往 [email protected]:Order #7 confirmed邮件发往 [email protected]:Order #8 confirmed邮件发往 [email protected]:Big order #8邮件正文(Order #7 confirmed 等)保持英文,因为测试和下面的 mockall 示例都会原样检查这些文字。这个例子没有用到外部 crate,所以在 Playground 里也可以从 Run 旁边的菜单中选择 Test:
running 2 teststest tests::small_order_sends_one_email ... oktest tests::big_order_also_tells_sales ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00sconfirm_order 不知道也不关心自己拿到的是哪个邮件发送器。窍门就这么多。同样的思路也适用于 Clock trait(测试里用一个停在晚上 9 点的时钟)、PaymentGateway,或者测试时用 HashMap 实现的 Storage trait。下面的练习会让你动手写时钟的版本。
用 mockall 做 mock
Section titled “用 mockall 做 mock”一两个 trait 的话,手写 fake 完全没问题。trait 一多,mockall(英文)这个 crate 就能帮你自动生成。把它添加为 dev-dependency(开发依赖),这样它只用于测试和示例(Rust Cargo 与 crate 介绍了 Cargo.toml 的各个部分):
cargo add --dev mockall在 trait 上加 #[automock],mockall 就会生成一个 MockMailer 类型。cfg_attr(test, ...) 的意思是“只在测试时这样做”,所以 mock 永远不会进入正式构建。Rust Cargo feature 与条件编译(#[cfg]) 会更详细地讲解 cfg。
#[cfg(test)]use mockall::automock;
#[cfg_attr(test, automock)]pub trait Mailer { fn send(&mut self, to: &str, body: &str);}
// Order 和 confirm_order 与上面一样。
#[cfg(test)]mod tests { use super::*;
#[test] fn big_order_also_tells_sales() { let mut mailer = MockMailer::new(); mailer .expect_send() .times(1) .return_const(()); mailer .expect_send() .times(1) .return_const(());
confirm_order(&order, &mut mailer); }}每个 expect_send() 都是一个期望(expectation):“我期望 send 被调用,参数符合 withf 的条件,而且正好调用一次”。return_const(()) 说明 mock 要返回什么(这里什么都不返回)。测试结束、mock 被丢弃时,mockall 会检查每个期望是否都满足了。
想看它抓 bug 的样子,可以把 confirm_order 里的规则改成 order.total_cents > 12_000。这样销售邮件就永远不会发出:
thread 'orders::tests::big_order_also_tells_sales' (12345) panicked at src/orders.rs:4:18:MockMailer::send: Expectation(<function>) called 0 time(s) which is fewer than expected 1意思是:这个期望被调用了 0 次,少于预期的 1 次。
该用 fake 还是 mock?先从手写的 fake 开始。它就是普通的 Rust 代码,好读,也不需要额外的依赖。当你有很多 trait 要模拟,或者你确实很在意“正好用这些参数调用了 N 次”时,再用 mockall。
用 proptest 做基于属性的测试
Section titled “用 proptest 做基于属性的测试”普通测试检查的是你挑出来的一个例子:“12.50 解析成 1250”。属性测试(property test)则写出一条对所有输入都应该成立的规则,然后让一个库尝试几百个随机输入来打破它。这就像请来一位极有耐心的人,把你没想到的各种奇怪值都试一遍。
好的属性通常长这样:
- 做两次就会抵消:把列表反转两次,得到原来的列表。
- 一个来回能回到起点:
parse(format(x)) == x。 - 某个事实永远成立:排好序的列表和输入的长度相同。
用 cargo add --dev proptest 添加 proptest(英文),然后在 proptest! 宏里写测试。每一行 name in strategy 说明要生成什么样的随机值:
use proptest::prelude::*;use shop::{format_price, parse_price};
proptest! { #[test] fn reversing_twice_gives_back_the_original(list in prop::collection::vec(any::<i32>(), 0..100)) { let mut copy = list.clone(); copy.reverse(); copy.reverse(); prop_assert_eq!(copy, list); }
#[test] fn price_survives_format_then_parse(cents in 0u32..10_000_000) { prop_assert_eq!(parse_price(&format_price(cents)), Ok(cents)); }}any::<i32>() 表示“任意 i32”,0u32..10_000_000 表示这个范围内的一个数,vec(..., 0..100) 会生成最多 99 个元素的列表。默认情况下,每个属性会运行 256 个随机用例。在 proptest! 里要用 prop_assert_eq! 代替 assert_eq!,这样 proptest 才能妥善处理失败。两个测试都通过了:
running 2 teststest price_survives_format_then_parse ... oktest reversing_twice_gives_back_the_original ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s注意,这和前面那个很慢的 #[ignore] 测试检查的是同一件事。那个测试用 3 秒试遍了全部一千万个价格。属性测试只用百分之一秒随机试 256 个,所以每次都可以运行。
现在我们故意犯个错。有人“改进”了 format_price,让它打印千位分隔符,比如 1,000.00。看起来是更漂亮了,可他忘了 parse_price:
pub fn format_price(cents: u32) -> String { let euros = cents / 100; let euros = if euros >= 1000 { format!("{},{:03}", euros / 1000, euros % 1000) } else { euros.to_string() }; format!("{euros}.{:02}", cents % 100)}下面是 cargo test --test properties 的真实输出,稍微删减了一点:
---- price_survives_format_then_parse stdout ----proptest: Saving this and future failures in tests/properties.proptest-regressions
thread 'price_survives_format_then_parse' (12345) panicked at tests/properties.rs:4:1:Test failed: assertion failed: `(left == right)` left: `Err("bad euros in \"1,000.00\"")`, right: `Ok(100000)` at tests/properties.rs:15.minimal failing input: cents = 100000看 minimal failing input: cents = 100000(最小失败输入)。最先失败的随机值很可能是 7,384,912 这样乱七八糟的数。随后 proptest 会进行收缩(shrink):它不断尝试更小、但仍然会失败的值,最后报告最简单的那个。100000 分正好是 1,000.00,也就是第一个会出现逗号的价格。这直接指向了 bug。
proptest 还会把失败的用例保存到测试旁边的 .proptest-regressions 文件里,以后每次运行都会先试它。把这个文件提交到版本库,这个 bug 就永远不会悄悄回来。如果你更喜欢小一点的库,quickcheck(英文)也能做同样的事,只是功能少一些。
用 insta 做快照测试
Section titled “用 insta 做快照测试”有些输出很长:一张收据、一份错误报告、一个很大的 JSON 文档。把整段期望文本写进 assert_eq! 很累人。快照测试(snapshot test)会在第一次运行时把输出保存到文件里,以后每次运行都拿新的输出和这张保存好的“照片”比较。
用 insta(英文,cargo add --dev insta)的话,测试只要一行:
pub fn receipt(items: &[Item]) -> String { let mut out = String::new(); for i in items { let line = format_price(i.price_cents * i.qty); out.push_str(&format!("{:<10} x{} {:>7}\n", i.name, i.qty, line)); } out.push_str(&format!("{:<14}{:>7}\n", "TOTAL", format_price(cart_total(items)))); out}
#[test]fn receipt_layout() { insta::assert_snapshot!(receipt(&sample_cart()));}收据用了 {:<10} 这样的对齐格式,所以里面的文字保持英文:中文字符在屏幕上占两个字符宽,会把对齐打乱。第一次运行会故意失败,并写出一个 .snap.new 文件让你检查。接受它之后(用 cargo-insta 工具的 cargo insta review,或者把文件改名为 .snap),它就保存在 src/snapshots/ 里:
---source: src/lib.rsassertion_line: 84expression: receipt(&sample_cart())---coffee x2 9.00bagel x1 3.00TOTAL 12.00以后如果有人把 "TOTAL" 改成 "Total",测试就会失败,并给出清晰的差异:
-old snapshot+new results────────────┬─────────────────────────────────────────────────────────────────── 1 1 │ coffee x2 9.00 2 2 │ bagel x1 3.00 3 │-TOTAL 12.00 3 │+Total 12.00────────────┴───────────────────────────────────────────────────────────────────To update snapshots run `cargo insta review` or set `INSTA_UPDATE=always`如果这个改动是有意的,就接受新的快照。如果不是,你就刚刚发现了一个 bug。快照很适合做“这个不应该被意外改动”的检查,但它不能告诉你输出是对的。在接受第一张快照之前,请仔细读一遍。
用 criterion 做基准测试
Section titled “用 criterion 做基准测试”想知道代码快不快,就去测量。靠猜的话,错得多得让人吃惊。在 #[test] 里计时效果也不好:测试是在没有优化的情况下构建的,而且只跑一次的结果噪声太大。
criterion(英文)是稳定版 Rust 上常用的工具。它会把你的代码运行很多次,替你做统计,还会记住上一次的结果,告诉你变快了还是变慢了。用 cargo add --dev criterion 添加它,然后把基准测试文件告诉 Cargo,并关掉内置的测试框架(harness):
[[bench]]name = "stock"harness = false这个基准测试要回答的问题是:要检查某个商品编码有没有库存,在 1,000 个编码里查找时,Vec 和 HashSet 哪个更快?
use std::collections::HashSet;use std::hint::black_box;
use criterion::{criterion_group, criterion_main, Criterion};
fn stock_lookup(c: &mut Criterion) { // 1,000 个商品编码,比如 "SKU-0"、"SKU-1"…… let codes: Vec<String> = (0..1000).map(|n| format!("SKU-{n}")).collect(); let code_set: HashSet<String> = codes.iter().cloned().collect(); let wanted = String::from("SKU-999"); // 对 Vec 来说最坏的情况:最后一个
c.bench_function("vec contains", |b| { b.iter(|| codes.contains(black_box(&wanted))) }); c.bench_function("hashset contains", |b| { b.iter(|| code_set.contains(black_box(&wanted))) });}
criterion_group!(benches, stock_lookup);criterion_main!(benches);b.iter(...) 里面就是被测量的代码。std::hint::black_box 会把一个值对优化器“藏起来”。如果没有它,编译器可能会发现答案永远不变,于是只算一次,结果你测的其实是空气。
用 cargo bench 运行它,它总是以优化(release)模式构建。在你的电脑上,数字会不一样:
vec contains time: [2.4222 µs 2.4470 µs 2.4742 µs]Found 2 outliers among 100 measurements (2.00%) 1 (1.00%) low mild 1 (1.00%) high mild
hashset contains time: [18.514 ns 18.891 ns 19.312 ns]Found 8 outliers among 100 measurements (8.00%) 4 (4.00%) high mild 4 (4.00%) high severe怎么读:三个时间是一个范围。中间那个是最佳估计,两边的意思是“真实值很可能就在这两者之间”。这里 HashSet 大约要 19 纳秒,Vec 大约要 2.4 微秒,所以在这种情况下集合快了 100 多倍。“Outliers”(离群值)是少数几次异常慢或异常快的运行,通常是电脑上的其他程序造成的。有几个是正常的。
改动代码后再运行一次,criterion 会和上一次比较:
vec contains time: [2.5124 µs 2.5430 µs 2.5773 µs] change: [−0.5279% +0.8368% +2.1133%] (p = 0.22 > 0.05) No change in performance detected.变化不到 1%,可能只是噪声,所以 criterion 说“No change”(没有变化)。它还会在 target/criterion/ 里生成一份 HTML 报告。至于什么时候该(以及什么时候不必)在意速度,请看 Rust 性能优化。
在比较老的代码里,你可能还会看到 #[bench] 函数。它们使用内置的 test::Bencher,只能在 nightly 编译器上用:
#![feature(test)] // 仅限 nightlyextern crate test;
#[bench]fn bench_total(b: &mut test::Bencher) { b.iter(|| (1..=1000u64).sum::<u64>());}在稳定版 Rust 上,请用 criterion。
覆盖率和 CI
Section titled “覆盖率和 CI”有了测试之后,还有两类工具能帮上忙。它们都用 cargo install 安装,和 Rust 工具与环境配置里的其他工具一样。
cargo-nextest 是一个更快的测试运行器。它让每个测试在自己的进程里运行,输出更清晰,还能重试不稳定的测试。有一点要知道:它不运行文档测试,所以还要另外运行 cargo test --doc。
cargo install cargo-nextest --lockedcargo nextest run覆盖率(coverage)告诉你测试实际执行了哪些代码行。cargo-llvm-cov 使用编译器内置的覆盖率支持,在 Linux、macOS 和 Windows 上都能用:
cargo install cargo-llvm-covcargo llvm-cov # 在终端里显示汇总表cargo llvm-cov --html # 在 target/llvm-cov/html 里生成可浏览的报告cargo-tarpaulin 是一个更老、也很流行的替代品。它在 Linux 上效果最好,是 CI 里的常见选择:
cargo install cargo-tarpaulincargo tarpaulin --out Html把覆盖率当作一张地图,而不是一个分数。它能告诉你哪些代码从来没有被任何测试碰到过,却没法告诉你断言检查的是不是正确的东西。80% 的覆盖率配上好的断言,胜过 100% 的覆盖率配上无力的断言。
在 CI(GitHub Actions、GitLab 等)里,一个不错的起点是运行和你本地一样的三条命令,这样检查失败在哪里都意味着同一件事:
cargo fmt --checkcargo clippy --all-targets -- -D warningscargo test给只有二进制的 crate 写集成测试。 如果你的项目只有 src/main.rs,tests/ 里的文件就没法从它导入任何东西:
error[E0432]: unresolved import `todo` --> tests/tasks.rs:1:5 |1 | use todo::add_task; | ^^^^ use of unresolved module or unlinked crate `todo`意思是:无法解析的导入 todo,它既不是已知的模块,也不是已链接的 crate。修复方法:把逻辑移到 src/lib.rs,让 main.rs 保持精简。这样 main.rs 调用这个库,你的测试也调用它。代码也会更容易复用。
把共用的辅助函数放在 tests/common.rs 里。 tests/ 下面直接放着的每个 .rs 文件都会被当作一个独立的测试文件,所以输出里会多出一段没用的内容:
Running tests/common.rs (target/debug/deps/common-634ea0b624054b77)
running 0 tests修复方法:改用 tests/common/mod.rs,并在每个需要它的测试文件里写 mod common;。
把测试用的 crate 放进 [dependencies]。 proptest、mockall、insta 和 criterion 应该放在 [dev-dependencies] 下(用 cargo add --dev)。否则,你的 crate 的每个用户都得下载并编译它们。
测试之间共享状态。 测试是并行运行的。如果两个测试写同一个文件,或者修改同一个环境变量,它们就会随机失败。给每个测试一个自己的临时文件夹(tempfile 这个 crate 能帮忙),实在不行再用 --test-threads=1 运行。
做基准测试时不用 black_box,或者在测试里计时。 优化器可能会删掉结果没被用到的计算,而 debug 模式下 #[test] 的计时几乎说明不了什么。请用 criterion 配合 black_box。
1. 一个可以测试的问候语
Section titled “1. 一个可以测试的问候语”下面这个函数没法可靠地测试,因为它总是去问真实的时钟:
fn greeting() -> &'static str { let hour = current_hour(); // 读取系统时间 match hour { 5..=11 => "早上好", 12..=17 => "下午好", _ => "晚上好", }}把它改写成接受一个 Clock trait,这个 trait 只有一个方法 fn hour(&self) -> u64。写一个总是返回同一个小时数的 FixedClock,然后测试边界:11 和 12,17 和 18。提示:struct FixedClock(u64); 就够了。真实的应用会传入一个读取当前时间的 SystemClock。
查看答案
trait Clock { fn hour(&self) -> u64; // 0 到 23}
// 一个总是报同一个小时的时钟。拿来测试正合适。struct FixedClock(u64);
impl Clock for FixedClock { fn hour(&self) -> u64 { self.0 }}
fn greeting(clock: &impl Clock) -> &'static str { match clock.hour() { 5..=11 => "早上好", 12..=17 => "下午好", _ => "晚上好", }}
fn main() { for hour in [8, 12, 21] { println!("{hour}:00 -> {}", greeting(&FixedClock(hour))); }}
#[cfg(test)]mod tests { use super::*;
#[test] fn morning_ends_at_noon() { assert_eq!(greeting(&FixedClock(11)), "早上好"); assert_eq!(greeting(&FixedClock(12)), "下午好"); }
#[test] fn evening_starts_at_six() { assert_eq!(greeting(&FixedClock(17)), "下午好"); assert_eq!(greeting(&FixedClock(18)), "晚上好"); }
#[test] fn very_early_is_still_evening() { assert_eq!(greeting(&FixedClock(3)), "晚上好"); }}输出:
8:00 -> 早上好12:00 -> 下午好21:00 -> 晚上好greeting 不再自己决定时间从哪里来,而是交给调用者决定。就这么一个改动,每个小时都能在几毫秒内测完。在 Playground 的菜单里选择 Test,3 个测试全部通过。
2. 找出规则,再让 proptest 来检查
Section titled “2. 找出规则,再让 proptest 来检查”下面这个函数会把 " Web Dev " 这样的博客标签变成 "web-dev":
fn normalize_tag(tag: &str) -> String { tag.to_lowercase().split_whitespace().collect::<Vec<_>>().join("-")}用 proptest 写两个属性测试:对一个已经规范化的标签再规范化一次,结果不变;结果里永远不包含空白字符。提示:策略 ".*" 会生成随机字符串(它是一个正则表达式)。检查“没有空白字符”可以用 prop_assert!(!s.contains(char::is_whitespace))。
查看答案
fn normalize_tag(tag: &str) -> String { tag.to_lowercase().split_whitespace().collect::<Vec<_>>().join("-")}
fn main() { for tag in ["Rust", " Web Dev ", "GAME dev"] { println!("{tag:?} -> {:?}", normalize_tag(tag)); }}
#[cfg(test)]mod tests { use super::*; use proptest::prelude::*;
proptest! { #[test] fn normalizing_twice_changes_nothing(tag in ".*") { let once = normalize_tag(&tag); prop_assert_eq!(normalize_tag(&once), once); }
#[test] fn result_has_no_whitespace(tag in ".*") { let normalized = normalize_tag(&tag); prop_assert!(!normalized.contains(char::is_whitespace)); } }}输出:
"Rust" -> "rust"" Web Dev " -> "web-dev""GAME dev" -> "game-dev"在 Playground 里 Run 可以用,但 Test 不行,因为 Playground 里没有 proptest。在你自己的项目里,用 cargo add --dev proptest 添加它,再运行 cargo test:
running 2 teststest tests::result_has_no_whitespace ... oktest tests::normalizing_twice_changes_nothing ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.02s这些随机字符串里会有制表符、换行符和非英文字母,这些你大概永远不会在手写的测试里输入。“做两次和做一次一样”是一条值得为任何清理类函数检查的属性。
- 单元测试放在代码旁边的
#[cfg(test)] mod tests里,集成测试放在tests/里(共用的辅助函数放在tests/common/mod.rs),文档测试写在///注释里。 - 慢的测试用
#[ignore]标记,用cargo test -- --ignored运行;用cargo test some_name按名字过滤;用-- --nocapture查看println!的输出。 - 要测试和外部世界打交道的代码,就把这个依赖放到一个 trait 后面,测试时传入 fake。手写 fake 变得繁琐时,就用 mockall。
- 属性测试(proptest)用几百个随机输入检查一条规则,并把失败收缩成最简单的例子。快照测试(insta)能发现大段输出里的意外改动。
- 用 criterion 和
cargo bench测量速度,并用std::hint::black_box包住输入。#[bench]需要 nightly。 - cargo-nextest 让测试跑得更快;cargo-llvm-cov 和 cargo-tarpaulin 能显示测试覆盖了哪些代码行。
小测验
1. tests/ 里有好几个文件需要同一个辅助函数。它应该放在哪里?
2. cargo test -- --ignored 会做什么?
3. 一个 proptest 属性失败了。proptest 报告的最小失败输入(minimal failing input)是什么?
4. 为什么 criterion 基准测试要用 std::hint::black_box 包住值?
得分: 0 / 4
- 编写自动化测试 — Rust 程序设计语言:官方教程中讲怎样编写、运行和组织单元测试与集成测试的章节。
- 测试 — 通过例子学 Rust:单元测试、文档测试、集成测试和开发依赖的简短示例。
- The proptest book(英文):策略、收缩、配置,以及 proptest 和 quickcheck 的比较。
- mockall documentation(英文):
#[automock]、期望、参数匹配、返回值,以及对结构体做 mock。 - Criterion.rs user guide(英文):入门、怎样读懂输出、比较多个函数,以及 HTML 报告。