外观
第 10 章 给代码上保险
小螃蟹说
第 9 章我们做出了统计库,功能都是人肉测试的:我跑一遍 cargo run,输几个分数,看看报告对不对。
问题来了:以后我们给库加新功能、修 bug,每次都要重新人肉测一遍吗?如果一次改了 5 处,漏测了一处怎么办?程序一长大,人肉测试就靠不住了。
这一章,我们给统计库上保险——测试(testing):写一段"自动检查员"代码,一声令下(cargo test),它把全部功能从头到尾检查一遍,哪个坏了立刻喊你。检查员从不偷懒,从不漏项,而且跑一遍只要几毫秒。
:::
这一章会在第 9 章的 game_stats 项目里继续。你还会学到编程界最酷的武功之一:测试驱动开发(TDD)——先写"考试卷",再写"答案",让测试牵着代码走。
10.1 预览:我们要做什么
- 在统计库的
lib.rs里写单元测试:10 个自动检查员 - 在
tests/目录里写集成测试:2 个"正门检查员" - 运行
cargo test,一键检查全部
运行结果
text
running 12 tests
test tests::average_is_exact_for_quarters ... ok
test tests::largest_finds_biggest ... ok
test tests::largest_of_empty_slice_is_none ... ok
test tests::longest_text_returns_longer_one ... ok
test tests::longest_text_tie_returns_first ... ok
test tests::median_of_even_length_is_average_of_two_middle ... ok
test tests::median_of_odd_length_is_middle ... ok
test tests::mode_finds_most_common ... ok
test tests::mode_of_all_unique_is_none ... ok
test tests::smallest_finds_smallest ... ok
test result: ok. 10 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
Running tests\stats.rs
running 2 tests
test full_report_pipeline ... ok
test largest_works_from_outside ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out12 个检查员,全部通过,一个不偷懒。这一章你会学到:
| 知识 | 是什么 | 会用在哪儿 |
|---|---|---|
#[test] | 把一个函数变成"自动检查员" | 标记测试 |
assert_eq! | "两个必须相等"的断言 | 检查结果对不对 |
| 单元测试 | 藏在库内部的小检查员 | mod tests 写在 lib.rs 里 |
| 集成测试 | 从正门检查库的大检查员 | tests/ 目录 |
| TDD | 先写考卷,再写答案 | 练习三的极差函数 |
10.2 动手做
步骤一:怎么"检查"一个函数?
人肉检查是这样:跑程序,输入 [90, 75, 75, 60],看着报告说"嗯,最高分 90,对"。
自动检查的套路一模一样,只是把"眼睛看"换成"代码比"——断言(assert):
rust
assert_eq!(largest(&[90, 75, 75, 60]), Some(90));assert_eq! 念"断言相等":左边算出来的,必须等于右边;不等于,就当场失败、大声报警。 左边是我们的函数算的,右边是我们写下的正确答案。这就是一台"自动检查员":把函数的结果和正确答案比对。
断言还有两个兄弟:
assert!(条件):条件必须为真(不比较两个值,只问"是不是真的")assert_ne!(a, b):a 必须不等于 b("断言不相等")
步骤二:第一个测试
在 game_stats 项目的 src/lib.rs 最末尾,加上:
rust
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn largest_finds_biggest() {
assert_eq!(largest(&[3, 7, 2]), Some(7));
}
}三个新东西:
#[test] 是"贴上检查员标签":函数名前的 #[...] 叫属性(attribute),是贴在代码上的小标签。#[test] 告诉编译器"这个函数是测试"。贴上它,cargo test 就会把它找出来跑。
mod tests 是一个装着测试的模块(第 6 章)。它专门用来收留检查员,不参与正常程序。
use super::*; 是"把上面的东西全部借进来"——super 是父模块(第 6 章学过),* 是"全部"。测试要调用库里的 largest,得先把它请进门。
#[cfg(test)] 是"只有在测试时才编译"的标签:cfg 是 configuration 的缩写,意思是"按条件配置"。平时跑程序,这个模块整个被跳过;跑 cargo test,它才现身。这样测试代码不会拖累正式程序。
在终端跑:
bash
cargo test运行结果
text
running 1 test
test tests::largest_finds_biggest ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out1 passed——检查员通过了!tests::largest_finds_biggest 是测试的全名(模块 :: 函数),ok 是它交的答卷。
步骤三:测试大军
一个检查员太孤单。把第 9 章的工具全部装上保险——在 mod tests 里继续加:
rust
#[test]
fn largest_of_empty_slice_is_none() {
assert_eq!(largest::<u32>(&[]), None);
}
#[test]
fn smallest_finds_smallest() {
assert_eq!(smallest(&[3, 7, 2]), Some(2));
}
#[test]
fn average_is_exact_for_quarters() {
assert_eq!(average(&[90, 75, 75, 60]), 75.0);
}
#[test]
fn median_of_odd_length_is_middle() {
assert_eq!(median(&[3, 1, 2]), 2.0);
}
#[test]
fn median_of_even_length_is_average_of_two_middle() {
assert_eq!(median(&[4, 1, 2, 3]), 2.5);
}
#[test]
fn mode_finds_most_common() {
assert_eq!(mode(&[1, 2, 2, 3]), Some(2));
}
#[test]
fn mode_of_all_unique_is_none() {
assert_eq!(mode(&[1, 2, 3]), None);
}
#[test]
fn longest_text_returns_longer_one() {
assert_eq!(longest_text("小螃蟹", "旺财"), "小螃蟹");
}
#[test]
fn longest_text_tie_returns_first() {
assert_eq!(longest_text("小螃蟹", "小海马"), "小螃蟹");
}每个工具都配了检查员,而且注意我们的套路——一个工具,至少两种检查:
largest测了"能找到最大的"和"空列表给 None"median测了"奇数个取中间"和"偶数个取两个的平均"mode测了"有众数"和"全是独苗没有众数"longest_text测了"长的赢"和"一样长时谁先借谁赢"(平局)
这就是测试的黄金纪律:正反两面都要测,边界更要测。 空列表、平局、奇偶——最容易出 bug 的地方,恰恰是这些"边缘地带"。
测试名字是说明书
看这些名字:median_of_even_length_is_average_of_two_middle 翻译过来是"偶数个时中位数是中间两个的平均"。测试的名字要能当说明书读:看到名字,就知道这个测试在检查什么场景、期望什么结果。哪天真失败了,光看名字就能猜到是哪儿的问题。
largest::<u32>(&[]) 里有个新符号:函数名后面直接跟 ::<u32>,这叫涡轮鱼(turbofish)——泛型的 T 在空列表里猜不出类型时,我们用 ::<u32> 指名道姓告诉它:"这里的 T 是 u32!"平时编译器自己会猜,只有 &[] 这种啥都看不出来的情况才需要涡轮鱼。
再跑一次 cargo test:
运行结果
text
running 10 tests
test tests::average_is_exact_for_quarters ... ok
test tests::largest_finds_biggest ... ok
test tests::largest_of_empty_slice_is_none ... ok
test tests::longest_text_returns_longer_one ... ok
test tests::longest_text_tie_returns_first ... ok
test tests::median_of_even_length_is_average_of_two_middle ... ok
test tests::median_of_odd_length_is_middle ... ok
test tests::mode_finds_most_common ... ok
test tests::mode_of_all_unique_is_none ... ok
test tests::smallest_finds_smallest ... ok
test result: ok. 10 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out10 个检查员,全员通过。
步骤四:红灯!抓一个 bug 看看
测试不是摆设——我们来故意制造一个 bug,看看保险是怎么响的。把 smallest_finds_smallest 里的正确答案改错:
rust
#[test]
fn smallest_finds_smallest() {
assert_eq!(smallest(&[3, 7, 2]), Some(99)); // 故意写错!
}跑 cargo test:
运行结果
text
running 1 test
test tests::smallest_finds_smallest ... FAILED
failures:
---- tests::smallest_finds_smallest stdout ----
thread 'tests::smallest_finds_smallest' panicked at src\lib.rs:105:9:
assertion `left == right` failed
left: Some(2)
right: Some(99)
test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out保险响了!看报警器的信息:
left: Some(2)——程序算出来是 2right: Some(99)——测试里写的答案是 99- 还附带了位置:
src\lib.rs:105:9,第 105 行第 9 列——精确到行
我们把答案改回 Some(2),警报解除。这一红一绿,就是编程里最经典的红绿循环:
- 红:写一个测试,故意让它失败(或者先写测试还没实现)
- 绿:让测试通过
- 循环往复,每走一步,程序就多一分保险
步骤五:集成测试:从正门检查
单元测试藏在库内部(mod tests),能看到所有内部零件。还有一种检查员站在正门:只用库对外公开的功能,从外面检查——这就是集成测试(integration test)。
在项目里新建一个 tests 文件夹(注意:和 src 平级),里面放一个文件 tests/stats.rs:
rust
use game_stats::{average, largest, median, mode, smallest};
#[test]
fn largest_works_from_outside() {
assert_eq!(largest(&[9, 5, 7]), Some(9));
}
#[test]
fn full_report_pipeline() {
let scores = vec![90, 75, 75, 60];
assert_eq!(largest(&scores), Some(90));
assert_eq!(smallest(&scores), Some(60));
assert_eq!(average(&scores), 75.0);
assert_eq!(median(&scores), 75.0);
assert_eq!(mode(&scores), Some(75));
}注意区别:
- 集成测试文件不写
mod tests,直接写测试——tests/文件夹里的每个.rs文件,cargo test都会当成一个独立的小程序来编译运行 - 开头要
use game_stats::{...}——它和外面的用户一样,通过pub的大门进库(第 6 章私有性精神:门不开,啥都用不了) - 单元测试能偷看内部(私有函数),集成测试只能走正门(公开函数)——就像医院体检:内部检查看五脏六腑,正门检查走挂号处
full_report_pipeline 这个名字也值得品一品:它一口气检查了一整条流水线(最高分、最低分、平均分、中位数、众数全来一遍)——这模拟的是真实使用场景,比单个函数测试更能发现"零件之间配合不好"的 bug。
再跑 cargo test,这次看看完整输出:
运行结果
text
running 10 tests
test tests::average_is_exact_for_quarters ... ok
……(10 个单元测试全部 ok)
test result: ok. 10 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
Running tests\stats.rs
running 2 tests
test full_report_pipeline ... ok
test largest_works_from_outside ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out注意 cargo test 会分三波跑:先是 src/lib.rs 的单元测试(10 个),再是 src/main.rs 的测试(0 个,主程序没写),最后是 tests/ 的集成测试(2 个)。库 + 主程序 + 集成,一波都不放过。
步骤六:全绿收官
现在,统计库有 12 个自动检查员站岗。以后想改库(比如把 mode 的实现换一种写法),改完跑一遍 cargo test——如果某个功能改坏了,检查员立刻喊你,还告诉你是哪个测试、哪一行。
这就是"保险"的完整价值:改代码不再心惊胆战,因为有一群不偷懒的检查员盯着。
小贴士
想只跑某一个测试?cargo test median——只跑名字里带 median 的测试。检查员们随叫随到,还能点名。
10.3 知识深挖
10.3.1 测试:给程序的保险
测试的使命:用代码检查代码。人工检查会累、会漏、会偷懒;测试检查员不会。
核心是断言(assert)——把"应该怎样"写下来:
rust
assert_eq!(largest(&[3, 7, 2]), Some(7)); // 必须相等
assert!(score >= 60); // 必须为真
assert_ne!(a, b); // 必须不等断言失败时,程序会 panic(第 8 章),打印"left 是什么、right 是什么、在哪个文件哪一行"——比人工检查强一万倍:不但告诉你"错了",还告诉你"错哪儿了"。
红绿循环是测试的呼吸节奏:红(失败)→ 绿(通过)→ 红 → 绿……每一轮,程序都更可靠一点。
10.3.2 断言三兄弟
| 宏 | 意思 | 什么时候用 |
|---|---|---|
assert!(条件) | 条件必须为真 | 只问"是不是",不比两个值 |
assert_eq!(左, 右) | 左边必须等于右边 | 检查结果是不是正确值 |
assert_ne!(左, 右) | 左边必须不等于右边 | 检查"没有混进不该有的" |
失败时的报警信息各不相同:assert! 只说"条件为假",assert_eq! 会把左右两边都打印出来(像 left: Some(2) / right: Some(99)),方便对比。能用 assert_eq! 就用它,信息越多越好查。
10.3.3 单元测试 vs 集成测试
| 单元测试 | 集成测试 | |
|---|---|---|
| 住在哪 | 库内部 mod tests | tests/ 文件夹 |
| 怎么看 | 偷看内部(私有也能测) | 走正门(只用公开的) |
| 测什么 | 单个函数的细节 | 整个库配合使用 |
| 数量 | 多、细 | 少、粗 |
单元测试是"显微镜",看每个零件的细节;集成测试是"体检报告",看整台机器转不转得起来。两个都要:细节错了,集成测试发现不了(可能刚好被别的零件掩盖);配合错了,单元测试发现不了(每个零件单独都是好的)。
10.3.4 测试的纪律
写测试有讲究,三条纪律记牢:
- 名字是说明书:
largest_of_empty_slice_is_none比test1好一万倍。失败时扫一眼名字,就知道是哪个场景出事 - 正反面都测:成功的情况测,失败的情况更要测(空列表、输错、没有众数……)。第 3 章说过:边界是 bug 的老家
- 一个函数多个测试:
median奇数一个、偶数一个——一个测试只检查一件事,别贪心
这三条纪律配合我们的黄金规则:功能会变,测试跟着变。 改了代码,测试红了,不是测试的错——是代码真的变了,要想想这个变化是不是故意的。
10.3.5 测试驱动开发(TDD)
最酷的武功:先写考卷,再写答案。顺序反过来了:
- 红:先写测试(考卷),这时函数还不存在,测试当然失败
- 绿:写最少的代码让测试通过(答卷)
- 重构:改进代码,测试一直盯着,改坏了立刻报警
TDD 的好处:代码的每一步都被测试验证过;而且"考卷先写",逼你先想清楚"这个函数到底该干什么"。练习三,你会亲手走一遍红绿循环。
10.4 动脑筋练习
练习一:给评级上保险
第 9 章练习一,我们给库加过 Grade trait(分数评级:优秀/良好/及格/加油)。它还没有检查员!
把 Grade trait(如果没加,先加上)和四个测试加进 mod tests:90 分→优秀、89 分→良好、60 分→及格、59 分→加油。
提示:测试里调用 90u32.grade(),记得 trait 要能看见(use super::*; 已经借进来了)。90 是"优秀"的起点,59 是"加油"的终点——这正是边界,最容易测出 bug 的地方。
点开看答案
如果 lib.rs 里还没有 Grade,先在 use 后面加上(第 9 章练习一的代码)。
然后在 mod tests 里加四个测试:
rust
#[test]
fn grade_of_ninety_is_excellent() {
assert_eq!(90u32.grade(), "优秀");
}
#[test]
fn grade_of_eighty_nine_is_good() {
assert_eq!(89u32.grade(), "良好");
}
#[test]
fn grade_of_sixty_is_pass() {
assert_eq!(60u32.grade(), "及格");
}
#[test]
fn grade_of_fifty_nine_is_cheer_up() {
assert_eq!(59u32.grade(), "加油");
}跑 cargo test,单元测试变成 14 个,全绿。
看这四个数字多讲究:90、89、60、59——每个边界都从两边测。90 是"优秀"的起点(90 就该优秀),89 是它的邻居(89 只能良好);60 和 59 同理。如果 grade 里把 >= 90 错写成 > 90,89 的测试立刻报警——边界测试,专抓这种"差一点"的 bug。
练习二:正门检查员补课
tests/stats.rs 里再加一个集成测试:空列表从正门检查——largest、smallest、mode 对空列表都该返回 None。
点开看答案
在 tests/stats.rs 末尾加:
rust
#[test]
fn empty_scores_report_none_from_outside() {
let scores: Vec<u32> = Vec::new();
assert_eq!(largest(&scores), None);
assert_eq!(smallest(&scores), None);
assert_eq!(mode(&scores), None);
}跑 cargo test --test stats(只跑集成测试),3 个全绿。
注意这个测试从正门检查:它用 use game_stats::... 进口,和真正的用户一模一样。空列表这个"边界",单元测试内部查了一遍,集成测试正门又查了一遍——两道保险,双倍放心。
练习三:TDD:极差诞生记
"极差"是统计里一个简单概念:最高分减最低分。比如 [3, 7, 2, 10] 的极差是 8。
用 TDD 给它写一个 range 函数!严格按三步走:
- 红:先在
mod tests里写两个测试(正常列表的极差、空列表给 None)——注意range还不存在,测试会失败 - 跑
cargo test,亲眼看到"找不到 range"的红色警报 - 绿:在
lib.rs里实现range,再跑——全绿
提示:range 可以复用 largest 和 smallest——它俩都返回 Option,想想怎么把两个 Option 一起处理?(提示:可以 match (largest(numbers), smallest(numbers))——元组也能被 match!)
点开看答案
第一步:写考卷(红)
在 mod tests 里加:
rust
#[test]
fn range_is_biggest_minus_smallest() {
assert_eq!(range(&[3, 7, 2, 10]), Some(8));
}
#[test]
fn range_of_empty_is_none() {
assert_eq!(range(&[]), None);
}第二步:看红灯
跑 cargo test,输出里有:
text
error[E0425]: cannot find function `range` in this scope"找不到 range"——考卷交上去了,但答卷还没写。这正是 TDD 的第一步:先让测试失败,证明考卷真的在检查。
第三步:写答案(绿)
在 lib.rs 里,longest_text 前面加:
rust
pub fn range(numbers: &[u32]) -> Option<u32> {
match (largest(numbers), smallest(numbers)) {
(Some(max), Some(min)) => Some(max - min),
_ => None,
}
}(largest(numbers), smallest(numbers)) 是"两个结果装进一个元组",match 一次拆两个:(Some(max), Some(min)) 说明都成功了,极差 = 最大 - 最小;_ 兜底(空列表之类),给 None。
再跑 cargo test:
text
test result: ok. 12 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out12 个全绿——新工具 range 一出生就有保险,而且是从"考卷"里生出来的:TDD 完成!
这就是测试驱动开发的魅力:先想清楚"该怎样",再动手写代码。 考卷逼着你设计,答案跟着考卷走,代码永远在保险的注视下诞生。
10.5 完整代码清单
项目结构:
text
game_stats/
├── Cargo.toml
├── src/
│ ├── lib.rs (库 + 单元测试)
│ └── main.rs (主程序,和第 9 章一样)
└── tests/
└── stats.rs (集成测试)文件:Cargo.toml
toml
[package]
name = "game_stats"
version = "0.1.0"
edition = "2024"文件:src/lib.rs(第 9 章的库 + 测试模块)
rust
use std::collections::HashMap;
pub fn largest<T: PartialOrd + Copy>(numbers: &[T]) -> Option<T> {
if numbers.is_empty() {
return None;
}
let mut best = numbers[0];
for &number in numbers.iter() {
if number > best {
best = number;
}
}
Some(best)
}
pub fn smallest<T: PartialOrd + Copy>(numbers: &[T]) -> Option<T> {
if numbers.is_empty() {
return None;
}
let mut best = numbers[0];
for &number in numbers.iter() {
if number < best {
best = number;
}
}
Some(best)
}
pub fn average(numbers: &[u32]) -> f64 {
if numbers.is_empty() {
return 0.0;
}
let mut total = 0;
for &number in numbers.iter() {
total += number;
}
total as f64 / numbers.len() as f64
}
pub fn median(numbers: &[u32]) -> f64 {
if numbers.is_empty() {
return 0.0;
}
let mut sorted = numbers.to_vec();
sorted.sort();
let length = sorted.len();
if length % 2 == 1 {
sorted[length / 2] as f64
} else {
(sorted[length / 2 - 1] + sorted[length / 2]) as f64 / 2.0
}
}
pub fn mode(numbers: &[u32]) -> Option<u32> {
let mut counts: HashMap<u32, u32> = HashMap::new();
for &number in numbers.iter() {
let count = counts.entry(number).or_insert(0);
*count += 1;
}
let mut best: Option<u32> = None;
let mut best_count = 0;
for (number, count) in counts.iter() {
if *count > 1 && *count > best_count {
best_count = *count;
best = Some(*number);
}
}
best
}
pub fn longest_text<'a>(first: &'a str, second: &'a str) -> &'a str {
if first.chars().count() >= second.chars().count() {
first
} else {
second
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn largest_finds_biggest() {
assert_eq!(largest(&[3, 7, 2]), Some(7));
}
#[test]
fn largest_of_empty_slice_is_none() {
assert_eq!(largest::<u32>(&[]), None);
}
#[test]
fn smallest_finds_smallest() {
assert_eq!(smallest(&[3, 7, 2]), Some(2));
}
#[test]
fn average_is_exact_for_quarters() {
assert_eq!(average(&[90, 75, 75, 60]), 75.0);
}
#[test]
fn median_of_odd_length_is_middle() {
assert_eq!(median(&[3, 1, 2]), 2.0);
}
#[test]
fn median_of_even_length_is_average_of_two_middle() {
assert_eq!(median(&[4, 1, 2, 3]), 2.5);
}
#[test]
fn mode_finds_most_common() {
assert_eq!(mode(&[1, 2, 2, 3]), Some(2));
}
#[test]
fn mode_of_all_unique_is_none() {
assert_eq!(mode(&[1, 2, 3]), None);
}
#[test]
fn longest_text_returns_longer_one() {
assert_eq!(longest_text("小螃蟹", "旺财"), "小螃蟹");
}
#[test]
fn longest_text_tie_returns_first() {
assert_eq!(longest_text("小螃蟹", "小海马"), "小螃蟹");
}
}文件:src/main.rs(和第 9 章一模一样,没有改动)
rust
use std::io;
use game_stats::{average, largest, median, mode, smallest};
fn main() {
println!("欢迎来到游戏得分统计器!");
let scores = read_scores();
println!();
println!("== 得分报告 ==");
println!("参赛人数:{}", scores.len());
match largest(&scores) {
Some(score) => println!("最高分:{}", score),
None => println!("没有分数!"),
}
match smallest(&scores) {
Some(score) => println!("最低分:{}", score),
None => println!("没有分数!"),
}
println!("平均分:{:.1}", average(&scores));
println!("中位数:{:.1}", median(&scores));
match mode(&scores) {
Some(score) => println!("众数:{}", score),
None => println!("没有众数!"),
}
}
fn read_scores() -> Vec<u32> {
let mut scores = Vec::new();
loop {
let input = read_input("输入一个得分(输入\"结束\"完成):");
if input == "结束" {
break;
}
match input.parse() {
Ok(number) => scores.push(number),
Err(_) => println!("要输入数字哦。"),
}
}
scores
}
fn read_input(prompt: &str) -> String {
println!("{}", prompt);
let mut input = String::new();
io::stdin().read_line(&mut input).expect("读取输入失败");
input.trim().to_string()
}文件:tests/stats.rs
rust
use game_stats::{average, largest, median, mode, smallest};
#[test]
fn largest_works_from_outside() {
assert_eq!(largest(&[9, 5, 7]), Some(9));
}
#[test]
fn full_report_pipeline() {
let scores = vec![90, 75, 75, 60];
assert_eq!(largest(&scores), Some(90));
assert_eq!(smallest(&scores), Some(60));
assert_eq!(average(&scores), 75.0);
assert_eq!(median(&scores), 75.0);
assert_eq!(mode(&scores), Some(75));
}怎么运行:
bash
cd game_stats
cargo test运行检查单:
cargo test 输出 10 个单元测试,全部 ok
再往下看,有 2 个集成测试,全部 ok
故意把某个测试的答案改错,跑 cargo test,能看到 FAILED 和 left/right 对比
改回正确答案,cargo test 恢复全绿cargo test median 只跑中位数相关的测试
给 mode 的实现换个写法(比如用 max_by_key),跑测试,功能没变就还是全绿