工程:把代码组织起来
测试与 cargo test
它在解决什么
runoob 那套 Rust 教程完全没有测试这一篇,而测试在 Rust 里的地位 和在别的语言里不太一样:它不是一个第三方框架,是语言和工具链自带的。
没有 JUnit、没有 pytest、不需要在 Cargo.toml 里加任何依赖。
#[test] 是内置属性,cargo test 是内置命令。
⚠️ 这一篇里 cargo test 的那些输出不是语言层面的,
跑不进本站那套「示例真跑 rustc」的体系。它们由另一道闸门
(npm run test:cargo)每次部署前真的 cargo new + 写测试 + 跑一遍得到。
单元测试:和被测代码住在同一个文件里
这是 Rust 和 Java / Go 最明显的差别 —— 测试不在 test/ 目录,
就写在被测代码下面:
fn double(x: i32) -> i32 { x * 2 }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn double_works() {
assert_eq!(double(21), 42);
}
}
跑起来是这样(实测输出):
running 2 tests
test tests::double_works ... ok
test tests::double_zero ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
🚨 #[cfg(test)] 的代码在发布产物里根本不存在
这不是「会被优化掉」,是压根没被编译。
最硬的证据在这条示例里: 测试模块里调用了一个完全不存在的函数,而整个程序照样编译通过。
🚨 那条示例的对照项是被闸门改出来的:我本以为只要删掉 #[cfg(test)]
就会报错,实测两边都过 —— 因为 #[test] 属性自己也会在非测试
编译下把函数移除。两层保险叠在一起,
必须两个都删掉才看得到 E0425。
⇒ 所以「测试和实现放一起会让二进制变大」这个顾虑是不成立的。 代价是零,而收益是测试能访问私有函数 —— 因为它就在同一个模块里。
assert_eq! 而不是 assert!
失败时它会把两边都打出来,
还带上你写的那句消息。assert!(a == b) 只会告诉你「false」。
⚠️ 代价是它要求类型实现 PartialEq 和 Debug ——
前者用来比,后者用来打印。所以自定义类型上那行
#[derive(Debug, PartialEq)] 基本是写测试的前提
(见泛型与 trait 那篇)。
三个常用宏:
| 宏 | 用途 |
|---|---|
assert!(条件, "消息") |
条件为真 |
assert_eq!(a, b, "消息") |
相等,失败时打出两边 |
assert_ne!(a, b) |
不等 |
三种测试,三个位置
| 类型 | 放哪 | 能看见什么 |
|---|---|---|
| 单元测试 | 被测文件里的 #[cfg(test)] mod tests |
包括私有项 |
| 集成测试 | tests/ 目录,每个文件是独立 crate |
只有公开 API |
| 文档测试 | /// 注释里的代码块 |
只有公开 API |
⭐ 集成测试「只能看见公开 API」不是限制,是特性:它逼着你从使用者的角度 写一遍,那正是最容易发现 API 难用的时候。
文档测试:注释里的代码会被真的跑
/// 把一个数翻倍。
///
/// ```
/// assert_eq!(mylib::double(21), 42);
/// ```
pub fn double(x: i32) -> i32 { x * 2 }
cargo test 会把那个代码块抠出来编译并运行。
⇒ 这解决了一个所有语言都有的老问题:文档里的示例会过期。 在 Rust 里它过期了就是测试挂了。
📌 本站这套 Rust 教程做的事在精神上是一样的 —— 每条示例都真跑一遍 rustc,
只是因为示例存在数据文件里而不是 /// 注释里,所以自己写了一套闸门。
退出码:CI 能用的那一半
实测确认过:全部通过时 cargo test 退出码 0,有测试失败时是 101。
🚨 这一条看起来是废话,但它是 CI 里唯一真正起作用的东西 —— 如果失败时也返回 0,那么「跑了测试」和「测试通过」就没有区别了, 而流水线会一直是绿的。本站的闸门里专门有一条断言盯着它。
几个常用参数
cargo test double # 只跑名字里含 double 的
cargo test -- --nocapture # 让测试里的 println! 真的打出来
cargo test -- --test-threads=1 # 单线程跑(测试默认并行)
cargo test --doc # 只跑文档测试
⚠️ 默认并行跑测试,所以别让两个测试共享可变的全局状态
(写同一个文件、用同一个端口)。撞上了先用 --test-threads=1
确认是不是这个原因。
下一步
最后补上天天都会用到的那几个容器 —— 见《Vec、HashMap 与字符串的三种形态》。
全部篇目见 Rust 教程首页。
本篇示例
下面每一条都是完整的、能单独编译的程序,由npm run test:rust 在每次构建前用真的 rustc 跑一遍。 「编译不过、报 E0382」这种话在这里是被验证过的断言。 报错原文和对照项的结果由同一道闸门自动回写,会随工具链更新,但不作为断言。
`#[cfg(test)]` 的代码在普通编译下**根本不存在**
#[cfg(test)]
mod tests {
#[test]
fn t() {
this_function_does_not_exist();
}
}
fn main() {
println!("ok");
}编译通过 · 输出 "ok\n"
对照:把 `#[cfg(test)]` **和** `#[test]` 都删掉
mod tests {
fn t() {
this_function_does_not_exist();
}
}
fn main() {
println!("ok");
}编译不过:error[E0425]
⭐ 正例里那个 `this_function_does_not_exist()` **压根不存在**,而它照样编译通过 —— 因为在非测试编译下它被整个删掉了。 🚨 但这条示例的对照项是被闸门改出来的:我本以为只删 `#[cfg(test)]` 就会报错,**实测两边都过** —— 因为 `#[test]` 属性**自己也会**在非测试编译下把函数移除。两层保险叠在一起,所以必须两个都删掉才看得到 `E0425`。 ⇒ 结论不变而且更强了:把测试和被测代码放同一个文件,对发布产物**零成本**。
`assert_eq!` 失败时会把两边都打出来
fn main() {
let expected = 5;
let actual = 2 + 2;
assert_eq!(actual, expected, "加法坏了");
}编译通过,但运行时 panic · 退出码 101
运行时说了什么
thread 'main' panicked at test-assert-eq-shows-both-sides.rs:4:5:
assertion `left == right` failed: 加法坏了
left: 4
right: 5对照:把期望改成对的
fn main() {
let expected = 4;
let actual = 2 + 2;
assert_eq!(actual, expected, "加法坏了");
}编译通过,无输出
展开「运行时说了什么」能看到它同时打出了 `left` / `right` 和你写的那句消息。⇒ 这就是为什么**几乎不该用 `assert!(a == b)`** —— 那样失败时只会告诉你「false」,而 `assert_eq!` 会告诉你两边分别是什么。
`assert_eq!` 要求类型能比较、能打印
struct P {
x: i32,
}
fn main() {
assert_eq!(P { x: 1 }, P { x: 1 });
}编译不过 · error[E0369]
rustc 原文
error[E0369]: binary operation `==` cannot be applied to type `P`
--> test-assert-eq-needs-traits.rs:6:5
|
6 | assert_eq!(P { x: 1 }, P { x: 1 });
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
| |
| P
| P
|
note: an implementation of `PartialEq` might be missing for `P`
--> test-assert-eq-needs-traits.rs:1:1
|
1 | struct P {
| ^^^^^^^^ must implement `PartialEq`
help: consider annotating `P` with `#[derive(PartialEq)]`
|
1 + #[derive(PartialEq)]
2 | struct P {
|
error[E0277]: `P` doesn't implement `Debug`
--> test-assert-eq-needs-traits.rs:6:5
|
6 | assert_eq!(P { x: 1 }, P { x: 1 });
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the trait `Debug` is not implemented for `P`
|
help: consider annotating `P` with `#[derive(Debug)]`
|
1 + #[derive(Debug)]
2 | struct P {
|
error[E0277]: `P` doesn't implement `Debug`
--> test-assert-eq-needs-traits.rs:6:5
|
6 | assert_eq!(P { x: 1 }, P { x: 1 });
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the trait `Debug` is not implemented for `P`
|
help: consider annotating `P` with `#[derive(Debug)]`
|
1 + #[derive(Debug)]
2 | struct P {
|对照:加一行 `#[derive(Debug, PartialEq)]`
#[derive(Debug, PartialEq)]
struct P {
x: i32,
}
fn main() {
assert_eq!(P { x: 1 }, P { x: 1 });
}编译通过,无输出
`assert_eq!` 需要 `PartialEq`(才能比)和 `Debug`(失败时才能打印出来)。⇒ 这就是为什么自定义类型上那行 `#[derive(Debug, PartialEq)]` 几乎是写测试的前提 —— 见[泛型与 trait 那篇](/rust/types/traits/)。