Rust 工程实践:测试、性能与调试的优先级
课程概览 · 第 19 章
Rust 的类型系统在编译期消灭了一大类 bug,但业务逻辑错误、边界输入、资源关闭顺序这些仍要靠测试兜底。本章用一套任务状态转移的测试矩阵收尾整个课程:先明确测什么(可观测行为),再给单元/集成/doctest/should_panic 的确切分工,然后把调试和性能优化放进同一套"证据先行"的流程。
学习目标与默认选择
学完本章你应当能够:
- 用任务状态转移矩阵规划测试:正常转移、非法转移、空白输入、缺失数据、资源/worker 关闭五类各有归属;
- 分清单元测试(同模块私有可见)、集成测试(
tests/目录、走公共 API)、doctest(文档即测试)、#[should_panic](守护 panic 边界)的边界; - 走完一条可复现的调试路径:最小失败输入 -> 第一个编译器错误 ->
Debug/dbg!->RUST_BACKTRACE=1-> 回归测试; - 建立性能工作的证据顺序:release 基线 -> profile -> 改算法/分配/IO/锁 -> 复测。
默认选择:测试写行为不写实现(断言"完成后的状态是 Done",不断言"内部调用了 finish 三次");优化前必有基线;unwrap/expect/dbg! 只出现在测试与已证明的不变量上。
概念讲解:测试矩阵与四种测试的分工
测什么:五类场景映射到被测对象
| 场景 | 被测行为 | 测试形态 |
|---|---|---|
| 正常转移 | Todo -> InProgress -> Done 状态可见地推进 | 单元测试 |
| 非法转移 | Done 后再 finish 返回 Err 且状态不被污染 | 单元测试 |
| 空白输入 | 构造器拒绝空白标题 | 单元测试 |
| 缺失数据 | 加载器带行号报告坏行 | 单元测试(解析器)+ 集成测试(文件级) |
| 资源/worker 关闭 | 关闭后 submit 返回错误而非 panic | 单元测试 |
前三类是纯函数式规则,单元测试最便宜;后两类涉及外部形态(文件、队列生命周期),值得集成测试补充。共同点:全部断言可观测行为(返回值、状态、错误类型),没有一个测试窥探私有字段或 mock 内部函数。
被测对象:src/lib.rs
一个自包含的任务域库(完整文件,直接可用)。它综合了课程词汇:TaskId 拒绝 0、TaskState 互斥枚举、转移方法返回 Result、加载器带行号报错、Worker 关闭后拒绝任务。本节与下一节的代码块是 lib 形态:放入 src/lib.rs(配套 Cargo.toml 的 [package] name = "task_domain")后以 cargo +stable test 验证,与下文测试模块合跑共 11 个单元测试 + 1 个 doctest:
1 | |
(本例只用标准库。)
文件顶部的 //! 文档注释带一个可执行示例–那就是 doctest:cargo test 会编译并运行它,文档示例永远不会悄悄失效。doctest 属于库的公共契约示例,放在 /// 或 //! 注释里,用 text fence 展示时记得它在源码中位于注释内。
测试模块:#[cfg(test)]
把五类场景写进同一个测试模块(追加到 src/lib.rs 末尾,cargo test 直接可跑)。注意每个测试都在** Arrange-Act-Assert**结构里断言可观测结果:
1 | |
运行 cargo test 的结果:11 个单元测试 + 1 个 doctest 全部通过。
四种测试的分工边界:
- 单元测试(
#[cfg(test)] mod tests):与实现同文件,能看到私有项;测规则与不变量。测试代码不进发布二进制。 - 集成测试(
tests/xxx.rs):把库当外部用户用,只能走pubAPI;适合文件加载、多模块协作这类跨边界行为。本例的load_tasks若从真实文件读,就应在tests/里写。 - doctest(文档注释里的
rust ``` 代码块):守护"文档示例可运行";它编译的是文档里的字面代码,不是库的内部。 #[should_panic(expected = "...")]:只用于"panic 本身是契约"的场合(如便捷构造器对不变量的expect)。expected子串让测试不会因错误的原因 panic 而误通过。
doctest 的放置位置
doctest 在源码中位于 /// 或 //! 注释内(上文 src/lib.rs 顶部已包含一个)。若想在单文件里快速体验同样效果,可运行版如下:
1 | |
(库形态时 # fn main() 包裹行会被隐藏;单文件运行时它保证 main 存在。cargo test 对两种形态都会执行文档示例。)
概念讲解:可复现的调试路径
bug 出现时按固定顺序走,避免随机改动:
- 最小失败输入:把失败场景压缩到最小数据(一条空白行、一个 0 id、一次双重 close)。上文的测试矩阵就是现成的最小化框架。
- 第一个编译器错误:从头读第一处错误,后面的错误常是连锁噪声。Rust 的错误带行号和帮助文本(如 E0505 会画出借用生命周期),照着改,不要盲试。
Debug与dbg!:#[derive(Debug)]让{:?}可打印;dbg!(&task)在打印的同时返回值,适合临时插桩。提交前删掉–它走 stderr 且每次调用都格式化。RUST_BACKTRACE=1:panic 时打印栈回溯,定位到出错行而不是只看到消息。- 回归测试:修完立刻把最小失败输入写成
#[test],名字说明场景(load_reports_blank_title_with_line_number)。没有回归测试的修复等于没修。
日志红线:不记录 secret/PII。token、密码、身份证号、手机号不进 dbg!/println!/日志;调试时打印长度、哈希或脱敏样本。
概念讲解:证据主导的性能工作
性能问题的默认状态是"不知道"。顺序:
- release 基线:debug 构建的耗时没有参考价值(无优化 + 溢出检查策略不同)。
cargo build --release后测。 - profile:用数据定位热点,而不是猜。
cargo bench/Criterion 找函数级差异;系统级用Instruments(macOS)/perf(Linux)采样。 - 按层次改:算法(复杂度)> 分配(预分配/复用)> I/O(批量/缓冲)> 锁(缩小临界区)> 微优化(仅在基准数据支持时)。
- 复测:改动后跑同一基准对比;没有改善就回滚。
容量复用的判据:已知容量时才预分配(如 ids.len() * 4 这类可估算上限);未知容量时 with_capacity 只是浪费内存。
Criterion:完整基准项目
可选基准工具(课程里第 19 章的第二个依赖示例)。完整 manifest 与基准对:
1 | |
1 | |
要点:harness = false 让 Criterion 接管 main(默认 libtest 不适用于基准);throughput 声明单位产出让报告显示吞吐量;基准函数是普通函数,可直接单元测试。运行 cargo bench,输出形如:
1 | |
Criterion 的价值在于统计稳健性(多次采样、离群值处理、变化检测 --bench --baseline before),它回答"改动是否真的更快",而不是"这次运行快不快"。
边界与失败场景
- 测试窥探实现:断言
task.state字段(私有)会让重构寸步难行;断言task.state()(公共方法)才稳。 #[should_panic]没写expected:任何 panic 都通过,包括你没想到的那个;必须写期望子串。- doctest 里的隐藏状态:
#隐藏行仍会执行;文档示例依赖全局状态时彼此污染,每个 doctest 保持独立。 - 基准在 debug 下跑:数值无意义;基准必须 release。
- 基准测错了东西:把 I/O、日志、时钟混进被测函数,测的是噪声不是代码;隔离它们(本例
render_ids是纯内存函数)。 - 并发测试的陷阱:死锁/竞态不会稳定失败;并发测试要有超时(第 16 章的 timeout 模式)并接受"降低复现概率"的现实,配合 loom 这类工具做确定性检查。
为什么可行:反馈环缩短到分钟级
质量闭环(fmt + clippy + test)能在本地一两分钟内跑完,这改变了工程的经济学:任何改动都立刻得到类型、lint、行为三重反馈,于是可以小步提交、随时回滚。调试路径的本质是"把不确定性变成最小复现",每一步(最小输入、第一个错误、回溯、回归测试)都在收窄问题空间。性能工作同理:基线把"感觉慢"变成数字,profile 把数字归因到函数,基准对比把改动变成可保留或可回滚的决策。Rust 的编译期检查并没有取消这套流程–它只是把其中一类错误(内存/所有权)提前到了写代码的瞬间,剩下的(业务逻辑、边界、性能)仍靠这套证据环。
常见误区
- “Rust 快,所以不用测性能”:类型安全与运行速度无关;错误的算法在 Rust 里一样是 O(n²)。
- 测试覆盖率数字当目标:100% 覆盖的五类矩阵不如 60% 覆盖但含非法转移、空白输入、关闭顺序的测试;覆盖率先于行为。
- 修完不写回归测试:同一个 bug 一定会回来;最小失败输入写进测试只要两分钟。
dbg!混进生产代码:每次调用都格式化到 stderr;提交前grep dbg!。- 为了快而
clone全删:借用检查报错就 clone 是钝刀,全删是快刀切手;按热点数据决定哪一处值得消除。 - 日志记敏感信息:调试期间打印 token "只是临时"也会进终端历史与日志文件;打印长度/哈希替代。
自测
- 测试矩阵的五类场景分别守护什么可观测行为?哪类最适合集成测试?
答的方向:正常/非法转移、空白输入、缺失数据、worker 关闭各自对应的断言对象;缺失数据若走真实文件(I/O 边界)适合tests/集成测试。 #[should_panic(expected = "...")]的expected子串为什么必须写?它防止哪种假阳性?
答的方向:不写时任何 panic 都算通过,包括错误原因导致的 panic;子串确保 panic 消息匹配预期边界。- doctest 与单元测试的执行物有什么区别?
答的方向:doctest 编译运行的是文档注释里的字面代码(对外契约示例);单测运行的是库内部测试模块(可访问私有项)。 - 为什么性能基线必须在 release 下建立?Criterion 相比手写
Instant::now()计时强在哪?
答的方向:debug 无优化且检查策略不同,数字不可比;Criterion 提供统计采样、离群值处理、基线对比与变化判定。 - 调试五步中"最小失败输入"为什么排在读错误之前?
答的方向:先把问题空间压缩到最小,后续每一步(读错误、插桩、回溯)的输出才可解读;大输入的错误信息淹没在噪声里。






