编写单元测试
本教程共 78 篇 · 第 52 篇 · 更新于 2026-08-08 · 约 9 分钟阅读
本节目标:学会用
#[test]标注测试函数、用断言宏验证结果、用#[should_panic]测试 panic,并用Result<T, E>返回测试结论,能看懂cargo test的输出。
写代码只是第一步,确认它真的按你想的那样工作,才是落地。Rust 把测试当成语言的一等公民,不需要额外装框架,标准库自带一套完整的测试能力。一个测试函数通常做三件事:准备好数据,跑一段被测代码,再判断结果对不对。
1-1 测试函数长什么样
用 cargo new adder --lib 新建一个库类型的包时,Cargo 会自动在 src/lib.rs 里塞一个测试模块。先新建看看:
cargo new adder --lib
cd adder
打开 src/lib.rs,你会看到这段:
#[cfg(test)]
mod tests {
#[test]
fn it_works() {
assert_eq!(2 + 2, 4);
}
}
it_works 就是测试函数。它前面那个 #[test] 叫属性(attribute),它的作用是告诉编译器:“这是一个测试,运行时要把它纳入测试清单”。没有这个标记的函数只是普通函数,测试执行器不会去跑它。
Note
tests这个模块名不是写死的,你可以叫tests以外任何名字。但#[test]标记不能少,因为模块里既可以有测试函数,也可以有给测试用的辅助函数,编译器靠#[test]来区分谁才是真正的测试。
1-2 运行测试与解读输出
在 adder 目录里运行:
cargo test
你会看到类似这样的输出:
running 1 test
test tests::it_works ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Doc-tests adder
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
几个关键点:
running 1 test表示下面这一批只跑了 1 个测试。test tests::it_works ... ok里,tests::it_works是测试的全名,由”模块名::函数名”拼成。test result: ok后面的数字依次是:通过、失败、忽略、基准、被过滤掉的数量。- 下面还有一段
Doc-tests adder,那是文档测试。现在没有文档测试,所以是 0 个。文档测试我们后面专门讲。
再写一个会失败的测试感受一下:
#[cfg(test)]
mod tests {
#[test]
fn exploration() {
assert_eq!(2 + 2, 4);
}
#[test]
fn another() {
panic!("让这个测试失败");
}
}
another 直接调用 panic!,所以运行后会失败,输出里会清楚标出 tests::another 失败,并给出 thread '...' panicked at '让这个测试失败' 的信息。
TipRust 默认给每个测试函数开一个独立的系统线程。某个测试线程崩了(panic),主线程就把它标记为失败,其它测试照常跑。所以一个测试挂掉不会连累其它测试。
1-3 断言宏:assert!、assert_eq!、assert_ne!
验证结果靠三类断言宏,它们都在标准库里,无需 use 直接可用。
assert! 接收一个布尔表达式,为 false 就 panic:
#[cfg(test)]
mod tests {
#[test]
fn larger_can_hold_smaller() {
let larger = (10, 10);
let smaller = (5, 5);
assert!(larger.0 > smaller.0);
}
}
assert_eq! 判断两个值相等,底层用 == 比较,不相等就 panic。它还能顺手打印出左右两边的值,方便排错:
#[test]
fn it_adds_two() {
assert_eq!(4, 2 + 2);
}
assert_ne! 正好相反,判断两个值不相等。它要求类型实现 PartialEq 和 Debug,因为失败时要把两边的值打印出来。
Warning
assert_eq!比较的两个值必须类型一致。写assert_eq!(4, add_two(2))时,如果add_two返回的是i64而你写的是i32字面量4,编译器会直接报错。遇到这种类型不一致,记得在字面量后面标注类型,例如4i64。
1-4 自定义失败信息
默认失败信息只告诉你”断言失败”和位置,往往不够用。给断言宏追加格式化参数,就能带上你关心的上下文:
pub fn greeting(name: &str) -> String {
format!("Hello {}!", name)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn greeting_contains_name() {
let result = greeting("小明");
let target = "小红";
assert!(
result.contains(target),
"问候语里没有包含目标姓名 {},实际内容是 `{}`",
target,
result
);
}
}
这段测试会失败,但报错信息会变成:“问候语里没有包含目标姓名 小红,实际内容是 Hello 小明!”。测试一多,这种信息能帮你省下大量排查时间。断言宏的格式化写法和 format! 一模一样。
1-5 用 #[should_panic] 测试 panic
有些函数设计上就是要 panic 的,比如参数越界时直接崩溃。要测这种”预期会崩”的行为,用 #[should_panic] 标注:
pub struct Guess {
value: i32,
}
impl Guess {
pub fn new(value: i32) -> Guess {
if value < 1 || value > 100 {
panic!("Guess 的值必须在 1 到 100 之间,实际得到 {}。", value);
}
Guess { value }
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[should_panic]
fn greater_than_100() {
Guess::new(200);
}
}
greater_than_100 传入 200,触发 panic,而我们用 #[should_panic] 声明”这个测试本来就该 panic”,于是测试通过。如果代码改坏了,传入 200 不再 panic,测试反而失败,提示”test did not panic as expected”。
还能用 expected 进一步精确匹配 panic 信息:
#[test]
#[should_panic(expected = "必须小于等于多少")]
fn greater_than_100() {
Guess::new(200);
}
expected 只需要是实际 panic 信息的前缀即可。比如实际信息是”Guess 的值必须 less than or equal to 100”,写成 expected = "Guess 的值必须" 也能通过。这样即使你后来调整了文案,只要前缀没变,测试就不会误伤。
1-6 用 Result<T, E> 返回测试结论
panic! 不是万能的。如果你想在测试里用 ? 做链式错误处理,可以不让测试函数 panic,而是返回一个 Result:
#[cfg(test)]
mod tests {
#[test]
fn it_works() -> Result<(), String> {
if 2 + 2 == 4 {
Ok(())
} else {
Err(String::from("二加二不等于四"))
}
}
}
返回 Ok(()) 表示通过,Err(...) 表示失败,错误字符串会作为失败信息打印。这种写法下不能再使用 #[should_panic],因为 panic 和 Result 是两套机制,二者只能选其一。
Tip初学阶段,绝大多数测试用
assert_eq!就够了。等你开始写偏 IO、解析类的代码,需要把错误向上抛时,再考虑Result形式的测试。
1-7 初学者常见误区
第一个坑:把测试写在 src/main.rs 里却指望它能跑。二进制包里也能写 #[cfg(test)] 测试,但库包(src/lib.rs)才是单元测试最自然的位置,因为库的函数本来就是要被外部调用的。建议把核心逻辑放进 lib,main 只负责”接参数、调库、打印结果”。
第二个坑:误以为 #[test] 函数能被业务逻辑直接调用。测试函数不参与正常编译产物,它们只在 cargo test 时才被编译进一个独立的测试二进制里。正常代码调用不到它们,这是好事,保证测试不会污染发布版本。
第三个坑:忘了 use super::*;。测试模块 tests 是 lib.rs 的子模块,要测父模块里的函数,得用 use super::*; 把父模块的东西引进来。模块体系这块如果还模糊,回头翻一下模块那一章。
1-8 测试到底写多少
很多人一听”要写测试”就头大,觉得纯属额外负担。其实测试不是写得越多越好,而是写得值。一个朴素的经验:核心逻辑、容易出错的边界条件,必须写;一眼就能看对的简单赋值,不必写。测试本身也是代码,也要维护,写太多低价值测试反而会拖慢你改需求的速度。
还有一种叫”测试驱动开发”(TDD)的做法:先写测试描述期望行为,再写实现让它通过。新手不一定要严格照做,但养成”改完代码顺手补一条测试”的习惯,能省掉无数回头 debug 的夜晚。另外,测试失败时的报错信息就是你的第一现场,所以断言里带上上下文(如本章讲的自定义失败信息)非常关键——报错越清楚,修得越快,而不是对着一行 assertion failed 发呆。
Tip别追求测试覆盖率的数字好看。比起”覆盖了 90% 行”,更重要是”关键的错都被拦得住”。一条能拦住真实 bug 的测试,胜过十条凑数的测试。
把这一章练熟,你就有能力给自己的函数写”保险”了。下一章我们看另一种测试:集成测试。