课程概览 · 第 21 章

上一篇:Cargo 与依赖:建立可重复的 Rust 开发闭环

类型系统能提前发现一类内存与所有权错误,但业务转移、输入边界、关闭顺序和性能仍需要可复查的证据。本章先给可观察行为矩阵,再用独立任务库展示测试边界,最后建立复现、剖析、对照基准与复测的工作流,不宣称测试通过就代表全部逻辑正确。

行为测试、公共 API 集成边界、可复现调试与性能对照基准的证据闭环
图:测试守住可观察行为,调试收窄失败条件,性能结论来自相同环境中的测量与复测。

前置知识与学习目标

需要第 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
2
3
4
5
cargo +stable new --lib --edition 2024 task_domain
cd task_domain
# 用下文库内容替换 src/lib.rs,并追加测试模块
cargo +stable test
cargo +stable test --doc

new 创建的 Cargo.toml 提供 package.name = “task_domain”、edition = “2024”,无需第三方依赖。以下源码顶部的波浪线外层 fence 可以包裹 Rustdoc 的内部反引号。

多文件示例,文件 task_domain/src/lib.rs(库实现完整内容;仅标准库,无 main;随后把下一块测试模块追加到此文件):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
//! 任务状态转移库。
//!
//! ```
//! use task_domain::{Task, TaskId, TaskState};
//!
//! let mut task = Task::new(TaskId::new(1).unwrap(), "对账".into())
//! .expect("valid task");
//! task.start(100).expect("Todo can start");
//! assert_eq!(task.state(), Some(&TaskState::InProgress { started_at: 100 }));
//! ```

use std::fmt;

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct TaskId(u64);

impl TaskId {
pub fn new(value: u64) -> Result<Self, InvalidTaskId> {
if value == 0 {
Err(InvalidTaskId { value })
} else {
Ok(Self(value))
}
}

pub fn as_u64(self) -> u64 {
self.0
}
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct InvalidTaskId {
pub value: u64,
}

impl fmt::Display for InvalidTaskId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "TaskId 不能为 0,收到 {}", self.value)
}
}

impl std::error::Error for InvalidTaskId {}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TaskState {
Todo,
InProgress { started_at: u64 },
Done { finished_at: u64 },
Cancelled { reason: String },
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TransitionError {
NotTodo,
NotInProgress,
AlreadyFinished,
}

impl fmt::Display for TransitionError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::NotTodo => write!(f, "只有 Todo 任务可以开始"),
Self::NotInProgress => write!(f, "只有 InProgress 任务可以完成"),
Self::AlreadyFinished => write!(f, "终态任务不能再转移"),
}
}
}

impl std::error::Error for TransitionError {}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum NewTaskError {
BlankTitle,
}

impl fmt::Display for NewTaskError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "标题不能为空白")
}
}

impl std::error::Error for NewTaskError {}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Task {
id: TaskId,
title: String,
state: TaskState,
}

impl Task {
pub fn new(id: TaskId, title: String) -> Result<Self, NewTaskError> {
if title.trim().is_empty() {
return Err(NewTaskError::BlankTitle);
}
Ok(Self { id, title, state: TaskState::Todo })
}

pub fn id(&self) -> TaskId {
self.id
}

pub fn title(&self) -> &str {
&self.title
}

pub fn state(&self) -> Option<&TaskState> {
Some(&self.state)
}

pub fn start(&mut self, started_at: u64) -> Result<(), TransitionError> {
match self.state {
TaskState::Todo => {
self.state = TaskState::InProgress { started_at };
Ok(())
}
TaskState::InProgress { .. } => Err(TransitionError::NotTodo),
TaskState::Done { .. } | TaskState::Cancelled { .. } => {
Err(TransitionError::AlreadyFinished)
}
}
}

pub fn finish(&mut self, finished_at: u64) -> Result<(), TransitionError> {
match self.state {
TaskState::InProgress { .. } => {
self.state = TaskState::Done { finished_at };
Ok(())
}
TaskState::Todo => Err(TransitionError::NotInProgress),
TaskState::Done { .. } | TaskState::Cancelled { .. } => {
Err(TransitionError::AlreadyFinished)
}
}
}

pub fn cancel(&mut self, reason: String) -> Result<(), TransitionError> {
match self.state {
TaskState::Todo | TaskState::InProgress { .. } => {
self.state = TaskState::Cancelled { reason };
Ok(())
}
TaskState::Done { .. } | TaskState::Cancelled { .. } => {
Err(TransitionError::AlreadyFinished)
}
}
}
}

pub fn load_tasks(lines: &[String]) -> Result<Vec<Task>, LoadError> {
let mut tasks = Vec::new();
for (index, line) in lines.iter().enumerate() {
let line_no = index + 1;
let (id_text, title) = line
.split_once(':')
.ok_or(LoadError::MissingFields { line: line_no })?;
let value: u64 = id_text.trim().parse()
.map_err(|_| LoadError::BadId { line: line_no })?;
let id = TaskId::new(value)
.map_err(|_| LoadError::ZeroId { line: line_no })?;
let title = title.trim();
if title.is_empty() {
return Err(LoadError::BlankTitle { line: line_no });
}
tasks.push(Task::new(id, title.to_string()).expect("title checked"));
}
Ok(tasks)
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LoadError {
BlankTitle { line: usize },
MissingFields { line: usize },
BadId { line: usize },
ZeroId { line: usize },
}

impl fmt::Display for LoadError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::BlankTitle { line } => write!(f, "第 {line} 行标题为空"),
Self::MissingFields { line } => write!(f, "第 {line} 行缺少分隔符"),
Self::BadId { line } => write!(f, "第 {line} 行 id 不是数字"),
Self::ZeroId { line } => write!(f, "第 {line} 行 id 为 0"),
}
}
}

impl std::error::Error for LoadError {}

#[derive(Debug, Default)]
pub struct Worker {
tasks: Vec<Task>,
closed: bool,
}

impl Worker {
pub fn new() -> Self {
Self::default()
}

pub fn submit(&mut self, task: Task) -> Result<(), WorkerClosed> {
if self.closed {
return Err(WorkerClosed);
}
self.tasks.push(task);
Ok(())
}

pub fn close(&mut self) {
self.closed = true;
}

pub fn len(&self) -> usize {
self.tasks.len()
}

pub fn is_empty(&self) -> bool {
self.tasks.is_empty()
}
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct WorkerClosed;

impl fmt::Display for WorkerClosed {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "worker 已关闭")
}
}

impl std::error::Error for WorkerClosed {}

state() 保留本例既有的 Option 形状,但此实现始终返回 Some;这不是第 08 章状态枚举 API 的别名。时间值仅作为转移记录,本章测试不宣称验证了时间先后业务规则。若实际业务有额外约束,应单独建模并添加对应证据,而不是从已有断言推断。

单元测试:按行为断言

代码片段(仅标准库;把整个测试模块追加到上块 task_domain/src/lib.rs,依赖其全部类型与 load_tasks;在项目根执行 cargo +stable test):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
#[cfg(test)]
mod tests {
use super::*;

fn sample_task(title: &str) -> Task {
Task::new(TaskId::new(1).unwrap(), title.to_string()).unwrap()
}

#[test]
fn todo_to_in_progress_to_done() {
let mut task = sample_task("对账");
assert_eq!(task.state(), Some(&TaskState::Todo));
task.start(100).unwrap();
assert_eq!(
task.state(),
Some(&TaskState::InProgress { started_at: 100 }),
);
task.finish(200).unwrap();
assert_eq!(task.state(), Some(&TaskState::Done { finished_at: 200 }));
}

#[test]
fn done_task_rejects_further_transitions() {
let mut task = sample_task("对账");
task.start(10).unwrap();
task.finish(20).unwrap();
assert_eq!(task.finish(30), Err(TransitionError::AlreadyFinished));
assert_eq!(task.start(30), Err(TransitionError::AlreadyFinished));
assert_eq!(
task.cancel("不需要".into()),
Err(TransitionError::AlreadyFinished),
);
assert_eq!(task.state(), Some(&TaskState::Done { finished_at: 20 }));
}

#[test]
fn todo_cannot_finish_directly() {
let mut task = sample_task("对账");
assert_eq!(task.finish(10), Err(TransitionError::NotInProgress));
assert_eq!(task.state(), Some(&TaskState::Todo));
}

#[test]
fn blank_title_is_rejected() {
assert_eq!(
Task::new(TaskId::new(1).unwrap(), " ".into()),
Err(NewTaskError::BlankTitle),
);
assert_eq!(sample_task(" 对账 ").title(), " 对账 ");
}

#[test]
fn load_reports_blank_title_with_line_number() {
let lines = vec!["1:索引重建".into(), "2: ".into()];
assert_eq!(load_tasks(&lines), Err(LoadError::BlankTitle { line: 2 }));
}

#[test]
fn load_reports_missing_separator() {
let lines = vec!["没有分隔符".into()];
assert_eq!(load_tasks(&lines), Err(LoadError::MissingFields { line: 1 }));
}

#[test]
fn load_rejects_zero_id_with_line_number() {
let lines = vec!["1:正常".into(), "0:零号".into()];
assert_eq!(load_tasks(&lines), Err(LoadError::ZeroId { line: 2 }));
}

#[test]
fn taskid_new_rejects_zero() {
assert_eq!(TaskId::new(0), Err(InvalidTaskId { value: 0 }));
assert_eq!(TaskId::new(1).unwrap().as_u64(), 1);
}

#[test]
fn closed_worker_rejects_submission() {
let mut worker = Worker::new();
worker.submit(sample_task("对账")).unwrap();
worker.close();
assert_eq!(worker.submit(sample_task("第二笔")), Err(WorkerClosed));
assert_eq!(worker.len(), 1);
}
}

这些测试使用已知合法数据中的 unwrap 来建立测试前提;生产输入仍走 Result。没有只检查错误 trait 接线的测试,也不重复测试标准库“Err.unwrap 会 panic”这一已知行为。具体执行数量由选定目标、过滤条件和之后的测试变动决定,不写固定通过数量。

集成测试:文件到公共 API

集成测试在库外编译,只能走公开接口。下面验证真实文件读取、正常任务字段和未在上述单元测试重复的非数字 ID 分支,临时目录只由本次测试创建和删除。

多文件示例,文件 task_domain/tests/public_api.rs(完整;依赖上面的 task_domain 库,仅标准库;在项目根执行 cargo +stable test --test public_api):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
use std::fs;
use std::time::{SystemTime, UNIX_EPOCH};
use task_domain::{LoadError, load_tasks};

#[test]
fn file_input_reaches_public_loader() -> Result<(), Box<dyn std::error::Error>> {
let stamp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos();
let name = format!("rust-course-ch21-{}-{stamp}", std::process::id());
let dir = std::env::temp_dir().join(name);
fs::create_dir(&dir)?;
let result = (|| -> Result<(), Box<dyn std::error::Error>> {
let path = dir.join("tasks.txt");
fs::write(&path, "1:索引重建\n2:缓存预热\n")?;
let text = fs::read_to_string(&path)?;
let lines: Vec<String> = text.lines().map(str::to_owned).collect();
let tasks = load_tasks(&lines)?;
assert_eq!(tasks.len(), 2);
assert_eq!(tasks[0].id().as_u64(), 1);
assert_eq!(tasks[1].title(), "缓存预热");
assert_eq!(
load_tasks(&["abc:标题".into()]),
Err(LoadError::BadId { line: 1 }),
);
Ok(())
})();
fs::remove_dir_all(&dir)?;
result
}

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、相关环境和执行命令。无法稳定复现的并发错误还应记录关闭顺序和同步事件,不要用一次成功就认定已修复。

  1. 最小化失败输入与调用路径,保留真正触发错误的条件。
  2. 编译错误从第一处相关诊断开始,后续错误可能只是连锁结果;不要机械按错误编号套修复。
  3. 对非敏感结构使用 Debug 或临时 dbg,确认状态和分支;观察结束后移除插桩。
  4. panic 时按需要启用 RUST_BACKTRACE=1,结合调用路径定位;Result 业务错误不会自动产生 panic 回溯。
  5. 修复根因后让最小失败场景成为回归测试,并记录它覆盖的边界。

代码片段(调试命令;在上述 task_domain 项目根执行,只定位选定测试;依赖库与单元测试模块):

1
RUST_BACKTRACE=1 cargo +stable test todo_cannot_finish_directly -- --nocapture

日志优先记录必要的非敏感结构信息,例如错误类别、行号、消息数量和阶段,不记录 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
2
3
4
5
6
7
8
9
10
11
[package]
name = "task-bench"
version = "0.1.0"
edition = "2024"

[dev-dependencies]
criterion = { version = "0.5", features = ["html_reports"] }

[[bench]]
name = "render"
harness = false

多文件示例,文件 task-bench/benches/render.rs(完整;依赖上述 manifest;Criterion 提供入口,不另加 main):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
use criterion::{Criterion, criterion_group, criterion_main};
use std::fmt::Write;
use std::hint::black_box;

fn render_ids(ids: &[u64]) -> String {
let mut output = String::new();
for id in ids {
write!(output, "{id},").expect("String write cannot fail");
}
output
}

fn render_ids_with_capacity(ids: &[u64]) -> String {
let mut output = String::with_capacity(ids.len() * 4);
for id in ids {
write!(output, "{id},").expect("String write cannot fail");
}
output
}

fn bench_render(c: &mut Criterion) {
let ids: Vec<u64> = (1..=1000).collect();
for input in [&[][..], ids.as_slice(), &[u64::MAX][..]] {
assert_eq!(render_ids(input), render_ids_with_capacity(input));
}
assert_eq!(render_ids(&[]), "");
assert_eq!(render_ids(&ids).len(), 3893);
assert_eq!(render_ids(&[u64::MAX]), "18446744073709551615,");

let mut group = c.benchmark_group("render");
group.throughput(criterion::Throughput::Elements(ids.len() as u64));
group.bench_function("new", |b| {
b.iter(|| black_box(render_ids(black_box(&ids))));
});
group.bench_function("with_capacity", |b| {
b.iter(|| black_box(render_ids_with_capacity(black_box(&ids))));
});
group.finish();
}

criterion_group!(benches, bench_render);
criterion_main!(benches);

black_box 包裹输入及返回值,降低优化把工作消除的风险,但不是任意硬件上绝对隔离优化的证明。语义断言在计时循环外,避免把测试开销混入被测函数。harness=false 让 Criterion 接管入口,throughput 声明元素单位,不是预设吞吐率。

多文件示例(基准命令;执行目录 task-bench,依赖完整 manifest 与 benches/render.rs;保存基线后,在保持环境一致的情况下比较):

1
2
cargo +stable bench --bench render -- --save-baseline before
cargo +stable bench --bench render -- --baseline before

阅读实际报告的 time 区间、吞吐量与 change 区间,区分测量噪声、统计变化和业务价值;这里不填模拟微秒或固定提升比例。首次命令为 new/with_capacity 两组保存 before,第二次分别与各自保存的同名基线比较,不能把 --baseline 理解成自动比较两个不同函数。双实现之间的差异还需查看同一次报告并复测;更多选项见 Criterion 命令行基线。

深入阅读:证据的局限

覆盖率帮助发现未执行路径,不代替行为矩阵,更不能保证业务正确;不要把某个百分比当作充分质量标准。回归测试降低再次引入同类错误的风险,不保证任何 bug 永不回来。文档示例与测试应有独立状态,避免依赖共享临时目录或运行顺序。

并发测试需要可观察同步与合理超时,但超时本身不证明无死锁。第 18 章 ready+保留发送端协议证明取消任务不能先自然关闭,重复运行只是补充证据。更复杂并发模型可考虑 loom 等工具,它们有额外使用合同,不是本章项目依赖。

性能闭环没有固定耗时承诺;小步修改、记录报告、复测再决定保留或回退。局部微基准不代表整体吞吐量,整体 profile 也不能代替输出语义检查;两类证据应相互补充。

自测

  1. 为什么非法转移测试既检查 Err,又检查原状态?Worker.close 保证了什么、没有保证什么?
  2. 单元、集成与 doctest 的可见性和执行目标有什么不同?bin 文档加隐藏 main 会改变默认 doctest 行为吗?
  3. 为什么这里不测试“TaskId::new(0).unwrap 会 panic”?应该怎样检查该业务输入?
  4. 本章两种 render 实现证明了哪些输入等价?乘 4 能保证不扩容吗,–baseline 会自动比较两个函数吗?
  5. 如何让调试和性能结论可复查,同时不在日志中留下敏感值及其稳定指纹?

参考答案

  1. Err 不足以证明失败无副作用;还须检查状态。close 只拒绝新提交并保留已收任务,不保证处理完成或后台取消。
  2. 单元测试可访问模块私有项,集成测试通过公共库 API,doctest 编译文档示例;Cargo 默认不测 bin doctest,隐藏行不改变目标性质。
  3. 那只重复标准库 unwrap 语义;直接断言 InvalidTaskId{value:0} 才检查领域行为。真实 panic 边界用第 15 章索引例。
  4. 检查空输入、1…=1000 与 u64::MAX;估计不足仍会扩容。baseline 按同名基准比较保存记录,不自动把 new 当另一函数的基线。
  5. 记录非敏感输入条件、版本、命令、状态类别与真实报告,在一致 profile 下复测;不以原值哈希替代敏感值记录。