工程:把代码组织起来

测试与 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/)。