课程概览 · 第 19 章

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

Rust 的类型系统在编译期消灭了一大类 bug,但业务逻辑错误、边界输入、资源关闭顺序这些仍要靠测试兜底。本章用一套任务状态转移的测试矩阵收尾整个课程:先明确测什么(可观测行为),再给单元/集成/doctest/should_panic 的确切分工,然后把调试和性能优化放进同一套"证据先行"的流程。

测试分层、稳定复现、最小根因、性能测量和回归基准组成的 Rust 工程证据闭环
图:测试、调试和优化都必须留下可复查的证据,避免用一次偶然成功或直觉替代结论。

学习目标与默认选择

学完本章你应当能够:

  • 用任务状态转移矩阵规划测试:正常转移、非法转移、空白输入、缺失数据、资源/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
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
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
//! 任务状态转移库:第 19 章测试矩阵的被测对象。
//!
//! ```
//! use task_domain::{Task, TaskId, TaskState};
//!
//! let mut task = Task::new(TaskId::new(1).unwrap(), "对账".to_string())
//! .expect("合法任务");
//! task.start(100).expect("Todo 可开始");
//! assert_eq!(task.state(), Some(&TaskState::InProgress { started_at: 100 }));
//! ```

use std::fmt;

/// 任务 id,newtype 封装,拒绝 0。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct TaskId(u64);

impl TaskId {
/// 创建 TaskId;0 被拒绝,因为持久层用 0 表示"无任务"。
pub fn new(value: u64) -> Result<Self, InvalidTaskId> {
if value == 0 {
Err(InvalidTaskId { value })
} else {
Ok(TaskId(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 {
/// 只能从 Todo 开始
NotTodo,
/// 只能从 InProgress 完成
NotInProgress,
/// 已终态(Done 或 Cancelled)的任务不能再转移
AlreadyFinished,
}

impl fmt::Display for TransitionError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
TransitionError::NotTodo => write!(f, "只有 Todo 任务可以开始"),
TransitionError::NotInProgress => write!(f, "只有 InProgress 任务可以完成"),
TransitionError::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 {
match self {
NewTaskError::BlankTitle => write!(f, "标题不能为空白"),
}
}
}

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

/// 任务聚合:标题非空不变量在构造时检查。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Task {
id: TaskId,
title: String,
state: TaskState,
labels: Vec<String>,
}

impl Task {
/// 构造 Todo 任务;标题 trim 后非空才接受。
pub fn new(id: TaskId, title: String) -> Result<Self, NewTaskError> {
if title.trim().is_empty() {
return Err(NewTaskError::BlankTitle);
}
Ok(Task {
id,
title,
state: TaskState::Todo,
labels: Vec::new(),
})
}

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

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

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

/// Todo -> InProgress
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)
}
}
}

/// InProgress -> Done
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)
}
}
}

/// 任意非终态 -> Cancelled
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_part, title_part) = match line.split_once(':') {
Some(parts) => parts,
None => return Err(LoadError::MissingFields { line: line_no }),
};
let id: u64 = id_part
.trim()
.parse()
.map_err(|_| LoadError::BadId { line: line_no })?;
let id = TaskId::new(id).map_err(|_| LoadError::ZeroId { line: line_no })?;
let title = title_part.trim();
if title.is_empty() {
return Err(LoadError::BlankTitle { line: line_no });
}
tasks.push(Task::new(id, title.to_string()).expect("title 已验证非空"));
}
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 {
LoadError::BlankTitle { line } => write!(f, "第 {line} 行标题为空"),
LoadError::MissingFields { line } => write!(f, "第 {line} 行缺少 id:标题 分隔符"),
LoadError::BadId { line } => write!(f, "第 {line} 行 id 不是数字"),
LoadError::ZeroId { line } => write!(f, "第 {line} 行 id 为 0"),
}
}
}

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

/// 处理任务的 worker:关闭后拒绝新任务(资源/worker 关闭场景)。
#[derive(Debug)]
pub struct Worker {
tasks: Vec<Task>,
closed: bool,
}

impl Worker {
pub fn new() -> Self {
Worker {
tasks: Vec::new(),
closed: false,
}
}

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 {}

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

(本例只用标准库。)

文件顶部的 //! 文档注释带一个可执行示例–那就是 doctestcargo test 会编译并运行它,文档示例永远不会悄悄失效。doctest 属于库的公共契约示例,放在 /////! 注释里,用 text fence 展示时记得它在源码中位于注释内。

测试模块:#[cfg(test)]

把五类场景写进同一个测试模块(追加到 src/lib.rs 末尾,cargo test 直接可跑)。注意每个测试都在** Arrange-Act-Assert**结构里断言可观测结果:

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
#[cfg(test)]
mod tests {
use super::*;

fn sample_task(title: &str) -> Task {
Task::new(TaskId::new(1).unwrap(), title.to_string()).expect("合法任务")
}

// ---- 1. 正常转移:Todo -> InProgress -> Done ----
#[test]
fn todo_to_in_progress_to_done() {
let mut task = sample_task("对账");
assert_eq!(task.state(), Some(&TaskState::Todo));

task.start(100).expect("Todo 可开始");
assert_eq!(task.state(), Some(&TaskState::InProgress { started_at: 100 }));

task.finish(200).expect("InProgress 可完成");
assert_eq!(task.state(), Some(&TaskState::Done { finished_at: 200 }));
}

// ---- 2. 非法转移被拒绝,且状态不被污染 ----
#[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("不需要".to_string()),
Err(TransitionError::AlreadyFinished)
);
// 状态保持 Done:非法转移不产生副作用
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));
}

// ---- 3. 空白输入:标题构造拒绝 ----
#[test]
fn blank_title_is_rejected() {
assert_eq!(
Task::new(TaskId::new(1).unwrap(), " ".to_string()),
Err(NewTaskError::BlankTitle)
);
// 边界:非空白标题(含空格)仍接受
let task = Task::new(TaskId::new(1).unwrap(), " 对账 ".to_string()).unwrap();
assert_eq!(task.title(), " 对账 ");
}

// ---- 4. 缺失数据:加载器报行号 ----
#[test]
fn load_reports_blank_title_with_line_number() {
let lines = vec![
"1:索引重建".to_string(),
"2: ".to_string(), // 第 2 行标题空白
];
assert_eq!(load_tasks(&lines), Err(LoadError::BlankTitle { line: 2 }));
}

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

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

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

// ---- 5. 资源/worker 关闭:关闭后提交被拒 ----
#[test]
fn closed_worker_rejects_submission() {
let mut worker = Worker::new();
worker.submit(sample_task("对账")).expect("开启时可提交");

worker.close();
// 关闭后 submit 返回错误而不是 panic 或静默丢弃
assert_eq!(worker.submit(sample_task("第二笔")), Err(WorkerClosed));
// 已接收的任务仍然在
assert_eq!(worker.len(), 1);
}

#[test]
fn error_types_implement_display_and_error() {
fn assert_error<E: std::error::Error + Send + Sync + 'static>(err: &E) -> String {
err.to_string()
}
// 所有错误类型实现 Display + std::error::Error(可进日志/错误链)
assert!(assert_error(&TaskId::new(0).unwrap_err()).contains("不能为 0"));
let mut task = sample_task("对账");
task.finish(1).unwrap_err(); // NotInProgress
assert!(task.state().is_some());
}

// ---- should_panic 边界:守护内部不变量 ----
// unwrap 在 Err 上 panic 是被测的行为边界:TaskId::new(0) 一定返回 Err。
#[test]
#[should_panic(expected = "InvalidTaskId")]
fn invalid_task_id_unwrap_panics() {
let _ = TaskId::new(0).unwrap();
}
}

运行 cargo test 的结果:11 个单元测试 + 1 个 doctest 全部通过

四种测试的分工边界:

  • 单元测试#[cfg(test)] mod tests):与实现同文件,能看到私有项;测规则与不变量。测试代码不进发布二进制。
  • 集成测试tests/xxx.rs):把库当外部用户用,只能走 pub API;适合文件加载、多模块协作这类跨边界行为。本例的 load_tasks 若从真实文件读,就应在 tests/ 里写。
  • doctest(文档注释里的 rust ``` 代码块):守护"文档示例可运行";它编译的是文档里的字面代码,不是库的内部。
  • #[should_panic(expected = "...")]:只用于"panic 本身是契约"的场合(如便捷构造器对不变量的 expect)。expected 子串让测试不会因错误的原因 panic 而误通过。

doctest 的放置位置

doctest 在源码中位于 /////! 注释内(上文 src/lib.rs 顶部已包含一个)。若想在单文件里快速体验同样效果,可运行版如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/// 计算任务的显示序号。
///
/// ```
/// # fn main() {
/// assert_eq!(display_index(0), 1);
/// # }
/// ```
fn display_index(zero_based: usize) -> usize {
zero_based + 1
}

fn main() {
assert_eq!(display_index(3), 4);
}

(库形态时 # fn main() 包裹行会被隐藏;单文件运行时它保证 main 存在。cargo test 对两种形态都会执行文档示例。)

概念讲解:可复现的调试路径

bug 出现时按固定顺序走,避免随机改动:

  1. 最小失败输入:把失败场景压缩到最小数据(一条空白行、一个 0 id、一次双重 close)。上文的测试矩阵就是现成的最小化框架。
  2. 第一个编译器错误:从头读第一处错误,后面的错误常是连锁噪声。Rust 的错误带行号和帮助文本(如 E0505 会画出借用生命周期),照着改,不要盲试。
  3. Debugdbg!#[derive(Debug)]{:?} 可打印;dbg!(&task) 在打印的同时返回值,适合临时插桩。提交前删掉–它走 stderr 且每次调用都格式化。
  4. RUST_BACKTRACE=1:panic 时打印栈回溯,定位到出错行而不是只看到消息。
  5. 回归测试:修完立刻把最小失败输入写成 #[test],名字说明场景(load_reports_blank_title_with_line_number)。没有回归测试的修复等于没修。

日志红线:不记录 secret/PII。token、密码、身份证号、手机号不进 dbg!/println!/日志;调试时打印长度、哈希或脱敏样本。

概念讲解:证据主导的性能工作

性能问题的默认状态是"不知道"。顺序:

  1. release 基线:debug 构建的耗时没有参考价值(无优化 + 溢出检查策略不同)。cargo build --release 后测。
  2. profile:用数据定位热点,而不是猜。cargo bench/Criterion 找函数级差异;系统级用 Instruments(macOS)/perf(Linux)采样。
  3. 按层次改:算法(复杂度)> 分配(预分配/复用)> I/O(批量/缓冲)> 锁(缩小临界区)> 微优化(仅在基准数据支持时)。
  4. 复测:改动后跑同一基准对比;没有改善就回滚。

容量复用的判据:已知容量时才预分配(如 ids.len() * 4 这类可估算上限);未知容量时 with_capacity 只是浪费内存。

Criterion:完整基准项目

可选基准工具(课程里第 19 章的第二个依赖示例)。完整 manifest 与基准对:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
文件:Cargo.toml(完整内容)

[package]
name = "task-bench"
version = "0.1.0"
edition = "2024"

[dependencies]

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

[[bench]]
name = "render"
harness = false
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
文件:benches/render.rs(完整内容)

use criterion::{criterion_group, criterion_main, Criterion};
use std::fmt::Write;

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

fn bench_render(c: &mut Criterion) {
let ids: Vec<u64> = (1..=1000).collect();
let mut group = c.benchmark_group("render");
group.throughput(criterion::Throughput::Elements(1000));
group.bench_function("with_capacity", |b| b.iter(|| render_ids(&ids)));
group.finish();
}

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

要点:harness = false 让 Criterion 接管 main(默认 libtest 不适用于基准);throughput 声明单位产出让报告显示吞吐量;基准函数是普通函数,可直接单元测试。运行 cargo bench,输出形如:

1
2
render/with_capacity    time:   [9.8997 µs 9.9829 µs 10.070 µs]
thrpt: [99.309 Melem/s 100.17 Melem/s 101.01 Melem/s]

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 "只是临时"也会进终端历史与日志文件;打印长度/哈希替代。

自测

  1. 测试矩阵的五类场景分别守护什么可观测行为?哪类最适合集成测试?
    答的方向:正常/非法转移、空白输入、缺失数据、worker 关闭各自对应的断言对象;缺失数据若走真实文件(I/O 边界)适合 tests/ 集成测试。
  2. #[should_panic(expected = "...")]expected 子串为什么必须写?它防止哪种假阳性?
    答的方向:不写时任何 panic 都算通过,包括错误原因导致的 panic;子串确保 panic 消息匹配预期边界。
  3. doctest 与单元测试的执行物有什么区别?
    答的方向:doctest 编译运行的是文档注释里的字面代码(对外契约示例);单测运行的是库内部测试模块(可访问私有项)。
  4. 为什么性能基线必须在 release 下建立?Criterion 相比手写 Instant::now() 计时强在哪?
    答的方向:debug 无优化且检查策略不同,数字不可比;Criterion 提供统计采样、离群值处理、基线对比与变化判定。
  5. 调试五步中"最小失败输入"为什么排在读错误之前?
    答的方向:先把问题空间压缩到最小,后续每一步(读错误、插桩、回溯)的输出才可解读;大输入的错误信息淹没在噪声里。