工程化:测试、错误处理、文档
「能编译」在 Rust 里已经排除了一大类 bug,但离「能交付」还差三件事:测试证明逻辑对、错误处理让失败可控、文档让别人(和三个月后的你)会用。Rust 把这三件套做进了语言和工具链 —— 测试零配置、文档里的示例会被当测试真跑、错误处理有两个库让样板几乎归零。这一章带你把它们串起来。
测试:内建,零配置
别的语言里测试是「引一个框架、配一堆东西」。Rust 里测试是语言的一部分:给函数加个 #[test],cargo test 就跑它。不装 JUnit、不配 pytest,开箱即用。
fn add(a: i32, b: i32) -> i32 { a + b }
#[cfg(test)] // 只在测试时编译,不进最终产物
mod tests {
use super::*;
#[test]
fn it_adds() {
assert_eq!(add(2, 3), 5); // 相等断言
assert!(add(2, 3) > 4); // 布尔断言
}
#[test]
fn it_can_fail() {
assert_ne!(add(2, 2), 5);
}
}
约定俗成:单元测试就写在被测代码同一个文件里的 #[cfg(test)] mod tests 中 —— 这样它能测到私有函数(同模块可见)。集成测试放在项目根的 tests/ 目录,每个文件是独立的 crate,只能用你的公开 API —— 正好模拟真实用户怎么用你的库。cargo test 一次全跑。
文档测试:让文档永不过时
这是 Rust 一个让人眼前一亮的设计。你在文档注释(///)里写的代码示例,会被 cargo test 当成测试真的编译并运行:
/// 把两个数相加。
///
/// # 示例
/// ```
/// let sum = mycrate::add(2, 3);
/// assert_eq!(sum, 5);
/// ```
pub fn add(a: i32, b: i32) -> i32 { a + b }
那段 ```` ``` ```` 里的示例,不是死的文本 —— cargo test 会抽出来跑,断言不过就测试失败。这解决了文档最大的顽疾:文档和代码不同步。你改了 API 却忘了改文档里的例子?doc test 当场挂给你看。于是 Rust 库的文档里的示例,几乎永远是能跑的、最新的 —— 这份可信度,是别的语言的文档很难保证的。配合 cargo doc 一键生成漂亮的 HTML 文档站,整个体验非常顺。
别误会 —— 编译器强不代表不用写测试。编译器保证的是内存安全、类型正确、没有数据竞争这类结构性正确;但「你的业务逻辑对不对」(这个折扣该打九折还是九五折、这个排序稳不稳定)它管不了,那是测试的活。好消息是:因为编译器已经替你挡掉了一大类底层 bug,你的测试可以专注在业务逻辑上,不用像别的语言那样还要写一堆测试去防 null、防类型错误、防并发崩溃。测试的性价比在 Rust 里更高。
错误处理:库用 thiserror,应用用 anyhow
第 12 章讲了 Result 和 ?,但留了个尾巴:不同的底层错误(IO 错误、解析错误……)怎么优雅地统一成你函数对外的一个错误类型?答案不在语言里,在两个几乎人人都用的库里,而选哪个取决于你在写库还是应用:
| 写库(给别人用) | 写应用(自己跑) | |
|---|---|---|
| 用哪个 | thiserror | anyhow |
| 思路 | 定义精确的错误枚举,让调用者能 match 分别处理 | 不关心错误的具体类型,能一把梭往上抛、带上上下文就行 |
| 为什么 | 库的用户需要知道「失败有哪几种」,好分别应对 | 应用通常只需要「出错了 → 记日志 / 报错退出」 |
库里用 thiserror,它帮你把「定义错误枚举 + 实现各种转换」的样板压成几行注解:
use thiserror::Error;
#[derive(Error, Debug)]
pub enum ConfigError {
#[error("读取失败:{0}")]
Io(#[from] std::io::Error), // #[from] 自动生成转换,? 就能用
#[error("第 {line} 行解析失败")]
Parse { line: usize },
}
// 现在函数返回 Result<_, ConfigError>,里面用 ? 抛 io::Error 会自动转成 ConfigError::Io
应用里用 anyhow,它给你一个「什么错都能装」的 anyhow::Error,配上加上下文的 .context():
use anyhow::{Context, Result};
fn run() -> Result<()> { // 注意:anyhow 的 Result,错误类型省了
let text = std::fs::read_to_string("config.toml")
.context("读配置文件失败")?; // 出错时附上人类可读的上下文
let config = parse(&text).context("解析配置失败")?;
Ok(())
}
这两个库把第 12 章那个「? 会自动转换错误类型」的机制用到了极致 —— 你几乎不用手写任何 From 实现,错误处理就既类型安全又不啰嗦。「库用 thiserror、应用用 anyhow」是社区的黄金搭配,记住它,能省你无数样板。
顺手一提:格式化与 lint 进 CI
第 3 章装好的 cargo fmt 和 cargo clippy,在工程化阶段值得进 CI:cargo fmt --check 保证全员风格统一(再不用 code review 里吵缩进),cargo clippy -- -D warnings 把 lint 警告当错误、拦在合并之前。加上 cargo test,三行命令就是一套相当扎实的 CI 门禁 —— 而且全是官方工具,不用东拼西凑。
回流
测试:Java 的 JUnit、Kotlin 的 kotlin.test、JS 的 Jest 都是外部框架,要引依赖、配 runner。Rust 把它内建,#[test] + cargo test 零配置。而 doc test(文档示例即测试)在主流语言里相当少见 —— Python 有 doctest 但用得不多,JVM 生态基本没有。这是 Rust 一个挺独特的、保证「文档不撒谎」的机制。
错误处理:Java/Kotlin 用异常层级(class MyException extends Exception)+ try/catch。thiserror 的错误枚举 ≈ 你定义的异常类,但它是值、能被 match 穷尽处理(第 10 章)、且强制调用者面对。anyhow ≈ 「catch 一个宽泛的 Exception、包一层上下文再抛」,但同样是显式的值传递,没有隐藏的控制流。Go 的 fmt.Errorf("...: %w", err) 和 errors.Is/As 与 anyhow/thiserror 的思路很近。
这一章的一句话
Rust 把工程化做进了工具链:#[test] 零配置测试、文档示例被当测试真跑(doc test 让文档永不过时)、thiserror(库)+ anyhow(应用)让错误处理几乎零样板。编译器挡掉底层 bug 后,你的测试能专注在业务逻辑上。
语言、组织、工程化都齐了。倒数第二章,我给你一份最实用的东西 —— 一张从 Kotlin 到 Rust 的对照表:你已有的每一个概念,在 Rust 里对应什么、又差在哪。