Rust 工程实践:测试、性能与调试的优先级
课程概览 · 第 21 章
类型系统能提前发现一类内存与所有权错误,但业务转移、输入边界、关闭顺序和性能仍需要可复查的证据。本章先给可观察行为矩阵,再用独立任务库展示测试边界,最后建立复现、剖析、对照基准与复测的工作流,不宣称测试通过就代表全部逻辑正确。
前置知识与学习目标
需要第 07/08 章的数据建模、第 13 章的错误、第 14/15 章的测试边界及第 20 章的 Cargo 命令。学完本章应能:
- 为正常、非法、输入与关闭路径建立可观察行为矩阵。
- 区分单元测试、集成测试、库 doctest 与 panic 测试。
- 记录最小失败输入和环境,避免随机修改或记录敏感值。
- 在一致 profile 下建立基线、提出假设并使用对照基准复测。
可观察行为矩阵
| 场景 | 被测行为 | 证据位置 |
|---|---|---|
| 正常转移 | Todo → InProgress → Done | 单元测试与公共库 doctest |
| 非法转移 | Todo 不能直接 finish;终态拒绝后续转移且不污染状态 | 单元测试 |
| 空白输入 | 标题空白被拒绝,合法输入保留业务约定 | 单元测试 |
| 缺失或损坏数据 | 错误带真实行号;文件输入可正确形成任务 | 解析器单元测试与文件级集成测试 |
| worker 关闭 | close 后 submit 返回 WorkerClosed,已接收任务保留 | 单元测试及外部 API 驱动 |
断言返回值、公共状态与错误类型,不断言“内部调用了三次方法”。同一场景可以在不同边界出现,但应各有目的:集成测试验证文件到公开接口的路径,不再照抄单元测试所有断言。本章 Worker 是同步的内存队列模型,close 只拒绝新提交,不暗示启动了后台线程或完成了排队任务;异步通道协议见第 18 章。
小型测试库:task_domain
这是独立库项目,只用标准库。TaskId 拒绝 0,Task 拒绝空白标题,TaskState 表示互斥状态;修改方法在返回错误时保留原状态。它与第 08 章返回新状态的方法是不同 API:这里显式使用 &mut self 修改 Task,不要求跨章拼装。模型只包含本章实际使用的数据,不复制标签字段。
多文件示例(创建库的命令;在新项目父目录执行,后续命令在 task_domain 根执行):
1 | |
new 创建的 Cargo.toml 提供 package.name = “task_domain”、edition = “2024”,无需第三方依赖。以下源码顶部的波浪线外层 fence 可以包裹 Rustdoc 的内部反引号。
多文件示例,文件 task_domain/src/lib.rs(库实现完整内容;仅标准库,无 main;随后把下一块测试模块追加到此文件):
1 | |
state() 保留本例既有的 Option 形状,但此实现始终返回 Some;这不是第 08 章状态枚举 API 的别名。时间值仅作为转移记录,本章测试不宣称验证了时间先后业务规则。若实际业务有额外约束,应单独建模并添加对应证据,而不是从已有断言推断。
单元测试:按行为断言
代码片段(仅标准库;把整个测试模块追加到上块 task_domain/src/lib.rs,依赖其全部类型与 load_tasks;在项目根执行 cargo +stable test):
1 | |
这些测试使用已知合法数据中的 unwrap 来建立测试前提;生产输入仍走 Result。没有只检查错误 trait 接线的测试,也不重复测试标准库“Err.unwrap 会 panic”这一已知行为。具体执行数量由选定目标、过滤条件和之后的测试变动决定,不写固定通过数量。
集成测试:文件到公共 API
集成测试在库外编译,只能走公开接口。下面验证真实文件读取、正常任务字段和未在上述单元测试重复的非数字 ID 分支,临时目录只由本次测试创建和删除。
多文件示例,文件 task_domain/tests/public_api.rs(完整;依赖上面的 task_domain 库,仅标准库;在项目根执行 cargo +stable test --test public_api):
1 | |
doctest 与 panic 测试的边界
库顶部的 //! 文档示例通过公开 crate 名导入类型。cargo +stable test --doc 编译并执行库文档中的示例;Cargo 默认不对 bin target 跑 doctest。Rustdoc 的隐藏 # 行仍参与编译与执行,隐藏 main 包裹也不会把二进制目标变成库文档测试。
#[should_panic] 用于 panic 本身就是被测边界的场景;expected 可以约束消息子串,减少因其他原因 panic 而误通过,但不是完整调用路径证明。Result 错误应断言 Err,不先 unwrap 制造 panic。真实索引运行期 panic 的测试见第 15 章,不在这里再复制标准库 unwrap 的演示。
可复现调试
先记录失败输入、工具链、target、features、相关环境和执行命令。无法稳定复现的并发错误还应记录关闭顺序和同步事件,不要用一次成功就认定已修复。
- 最小化失败输入与调用路径,保留真正触发错误的条件。
- 编译错误从第一处相关诊断开始,后续错误可能只是连锁结果;不要机械按错误编号套修复。
- 对非敏感结构使用 Debug 或临时 dbg,确认状态和分支;观察结束后移除插桩。
- panic 时按需要启用
RUST_BACKTRACE=1,结合调用路径定位;Result 业务错误不会自动产生 panic 回溯。 - 修复根因后让最小失败场景成为回归测试,并记录它覆盖的边界。
代码片段(调试命令;在上述 task_domain 项目根执行,只定位选定测试;依赖库与单元测试模块):
1 | |
日志优先记录必要的非敏感结构信息,例如错误类别、行号、消息数量和阶段,不记录 token、密码、PII,也不要把它们的稳定哈希或指纹当作“安全替代”。即使不打印原文,长度、脱敏片段或指纹也可能泄露信息;只有确实必要且已审查的信息才记录。dbg 会输出到 stderr,不能因临时调试就放宽数据边界。
性能工作:一致 profile、对照与复测
先建立与实际运行环境相符的优化构建基线,再 profile 找到热点。debug 的测量可以用于开发体验或定位问题,但默认 debug 与 release 的优化和检查策略不同,不能直接比较并归因于某次代码优化。
改动应对应可证伪假设:算法、分配、I/O 批量化、锁临界区等;不要因“Rust 很快”跳过测量,也不要无差别删掉 clone。复测保持输入、profile、工具链、features 与环境一致;若统计证据不足,就不宣称改进。未知长度也可以根据可靠估计预分配,但要接受估计不足会扩容、估计过大占内存。
Criterion:同语义的双实现
下面项目独立于 task_domain,只使用 Criterion 0.5 作为开发依赖。基线使用 String::new,对照使用 String::with_capacity,写入循环完全一致;先检查语义相同,再比较测量。ids.len() * 4 只是对本样例输入的容量估计,不是任意 u64 的最大格式长度,也不保证不扩容或更快。
多文件示例,文件 task-bench/Cargo.toml(完整;新建该目录及 benches 子目录;无 src/main.rs,显式 bench target 是此包的目标;所有基准命令在 task-bench 根执行):
1 | |
多文件示例,文件 task-bench/benches/render.rs(完整;依赖上述 manifest;Criterion 提供入口,不另加 main):
1 | |
black_box 包裹输入及返回值,降低优化把工作消除的风险,但不是任意硬件上绝对隔离优化的证明。语义断言在计时循环外,避免把测试开销混入被测函数。harness=false 让 Criterion 接管入口,throughput 声明元素单位,不是预设吞吐率。
多文件示例(基准命令;执行目录 task-bench,依赖完整 manifest 与 benches/render.rs;保存基线后,在保持环境一致的情况下比较):
1 | |
阅读实际报告的 time 区间、吞吐量与 change 区间,区分测量噪声、统计变化和业务价值;这里不填模拟微秒或固定提升比例。首次命令为 new/with_capacity 两组保存 before,第二次分别与各自保存的同名基线比较,不能把 --baseline 理解成自动比较两个不同函数。双实现之间的差异还需查看同一次报告并复测;更多选项见 Criterion 命令行基线。
深入阅读:证据的局限
覆盖率帮助发现未执行路径,不代替行为矩阵,更不能保证业务正确;不要把某个百分比当作充分质量标准。回归测试降低再次引入同类错误的风险,不保证任何 bug 永不回来。文档示例与测试应有独立状态,避免依赖共享临时目录或运行顺序。
并发测试需要可观察同步与合理超时,但超时本身不证明无死锁。第 18 章 ready+保留发送端协议证明取消任务不能先自然关闭,重复运行只是补充证据。更复杂并发模型可考虑 loom 等工具,它们有额外使用合同,不是本章项目依赖。
性能闭环没有固定耗时承诺;小步修改、记录报告、复测再决定保留或回退。局部微基准不代表整体吞吐量,整体 profile 也不能代替输出语义检查;两类证据应相互补充。
自测
- 为什么非法转移测试既检查 Err,又检查原状态?Worker.close 保证了什么、没有保证什么?
- 单元、集成与 doctest 的可见性和执行目标有什么不同?bin 文档加隐藏 main 会改变默认 doctest 行为吗?
- 为什么这里不测试“TaskId::new(0).unwrap 会 panic”?应该怎样检查该业务输入?
- 本章两种 render 实现证明了哪些输入等价?乘 4 能保证不扩容吗,–baseline 会自动比较两个函数吗?
- 如何让调试和性能结论可复查,同时不在日志中留下敏感值及其稳定指纹?
参考答案
- Err 不足以证明失败无副作用;还须检查状态。close 只拒绝新提交并保留已收任务,不保证处理完成或后台取消。
- 单元测试可访问模块私有项,集成测试通过公共库 API,doctest 编译文档示例;Cargo 默认不测 bin doctest,隐藏行不改变目标性质。
- 那只重复标准库 unwrap 语义;直接断言 InvalidTaskId{value:0} 才检查领域行为。真实 panic 边界用第 15 章索引例。
- 检查空输入、1…=1000 与 u64::MAX;估计不足仍会扩容。baseline 按同名基准比较保存记录,不自动把 new 当另一函数的基线。
- 记录非敏感输入条件、版本、命令、状态类别与真实报告,在一致 profile 下复测;不以原值哈希替代敏感值记录。






