AI 辅助 Rust 单元测试:让模型先生成边界用例,再写测试函数
一、写测试最怕的不是写不出来,而是根本想不到
刚开始给 Rust 函数写测试时,我的做法很"朴素"——把函数签名扫一眼,脑子里蹦出三五个正常输入,assert_eq! 一跑就提交了。后来被编译器毒打多了才意识到:让测试有价值的从来不是"正常情况",而是那些你没考虑到的边界。
空字符串传入会怎样?Option::None 有没有覆盖?溢出边界在哪个值触发?这些问题的答案,靠直觉根本想不全。作为自学编程的人,没有系统学过测试方法论,每次写边界用例都像是在碰运气——运气好想到七八个,运气差漏掉一半。
如果有一个"边界用例检查清单"该多好——不是从脑子里搜刮,而是让 AI 模型根据函数签名和语义,系统性地生成一份候选清单。人来做筛选和最终决策,机器来做穷举。
二、核心方法:先筛边界再写代码的六步工作流
这套方法的核心思想很简单:把"想边界"和"写测试"拆成两个独立步骤。先让 AI 帮你列出所有可能的边界维度,人工筛选后再让 AI 生成测试函数骨架。
flowchart TD
A[📋 输入函数签名与语义说明] –> B[🤖 AI 分析函数行为与参数空间]
B –> C[📝 生成边界用例候选列表<br/>按维度分类:空值/极值/类型边界/组合爆炸]
C –> D{🧠 人工筛选<br/>是否合理?是否遗漏?}
D — 补充 –> E[✏️ 手动追加边界用例]
D — 确认 –> F[🤖 AI 生成测试函数代码<br/>每个边界对应一个 #[test]]
E –> F
F –> G[✅ 编译并运行 cargo test]
G — 失败 –> H[🔧 分析失败原因<br/>是测试写错了还是函数有 bug?]
H –> F
G — 全部通过 –> I[🎯 提交代码]
流程中有三个关键环节值得展开:
第一,给 AI 的上下文要足够具体。 不能只丢一个函数签名过去,要附上函数的业务语义和预期行为。比如"这个函数解析一行配置,格式为 key = value,支持双引号包裹的值和 # 注释",比光给一个 fn parse(line: &str) -> Option<(String, String)> 效果好得多。
第二,人工筛选是质量闸门。 AI 生成的边界列表一定会有"过度防御"的情况——比如对一个只处理 ASCII 的函数,它可能生成 Unicode 组合字符的测试用例。这些在实际场景下是噪音,需要果断删掉。
第三,生成的测试代码必须编译通过才算数。 有些 AI 会编造不存在的 API 或用错 trait 约束。所以"生成 → 编译 → 修正"这个循环必不可少。
三、实战演示:用 parse_config_line 走一遍完整流程
下面用一个 Rust 配置解析函数来完整走一遍这套流程。
3.1 待测函数
/// 解析单行配置,格式为 `key = value` 或 `key = "value"`,支持 # 注释。
/// – 空行或纯注释行返回 None
/// – key 和 value 会被 trim
/// – 双引号包裹的 value 会去掉引号
/// – 返回 (key, value) 的元组
fn parse_config_line(line: &str) -> Option<(String, String)> {
// 去除 # 注释
let content = line.split('#').next().unwrap_or("").trim();
if content.is_empty() {
return None;
}
// 按第一个 = 分割
let mut parts = content.splitn(2, '=');
let key = parts.next()?.trim().to_string();
let value_raw = parts.next()?.trim();
if key.is_empty() {
return None;
}
// 去掉引号
let value = value_raw
.strip_prefix('"')
.and_then(|s| s.strip_suffix('"'))
.unwrap_or(value_raw)
.to_string();
Some((key, value))
}
3.2 Prompt 模板:让 AI 生成边界用例
以下是我实际使用的 Prompt:
你是一个 Rust 测试专家。请分析以下函数,按维度生成边界用例清单。
【函数】
```rust
/// 解析单行配置,格式为 `key = value` 或 `key = "value"`,支持 # 注释。
/// – 空行或纯注释行返回 None
/// – key 和 value 会被 trim
/// – 双引号包裹的 value 会去掉引号
/// – 返回 (key, value) 的元组
fn parse_config_line(line: &str) -> Option<(String, String)> {
// 去除 # 注释
let content = line.split('#').next().unwrap_or("").trim();
if content.is_empty() {
return None;
}
// 按第一个 = 分割
let mut parts = content.splitn(2, '=');
let key = parts.next()?.trim().to_string();
let value_raw = parts.next()?.trim();
if key.is_empty() {
return None;
}
// 去掉引号
let value = value_raw
.strip_prefix('"')
.and_then(|s| s.strip_suffix('"'))
.unwrap_or(value_raw)
.to_string();
Some((key, value))
}
```
请按以下维度输出边界用例,每类给出具体输入和预期输出:
1. 空值与缺失(空字符串、纯空白、缺 =、缺 value)
2. 边界字符(key/value 为单个字符、= 前后有空格)
3. 注释相关(行首 #、行中 #、value 内含 #)
4. 引号处理(带双引号、只有左引号、引号内部为空)
5. 特殊输入(key 含中文、value 含 =、整行只有 =)
输出格式:
– 输入: `"…"`
– 预期输出: `Some(("key", "value"))` 或 `None`
– 边界维度: 简短说明
3.3 AI 输出的边界用例清单
AI 按五个维度输出了以下 16 个边界用例(节选核心条目):
| 1 | "" | None | 空字符串 |
| 2 | " " | None | 纯空白 |
| 3 | "# 注释" | None | 纯注释行 |
| 4 | "key" | None | 缺少等号 |
| 5 | "= value" | None | 缺少 key |
| 6 | "key=" | Some(("key", "")) | 缺少 value |
| 7 | "a=b" | Some(("a", "b")) | 单字符 key/value |
| 8 | " key = value " | Some(("key", "value")) | 前后空白 |
| 9 | "key=\\"val\\"ue\\"" | Some(("key", "val\\"ue")) | value 内含引号 |
| 10 | "key=val#注释" | Some(("key", "val")) | 行中注释 |
| 11 | "key=\\"\\"" | Some(("key", "")) | 引号内为空 |
| 12 | "key=\\"only_left" | Some(("key", "\\"only_left")) | 只有左引号 |
| 13 | "k=v1=v2" | Some(("k", "v1=v2")) | value 含等号 |
| 14 | "配置项=值" | Some(("配置项", "值")) | key/value 中文 |
| 15 | "=" | None | 整行只有等号 |
| 16 | "key= " | Some(("key", "")) | value 为空白 |
3.4 人工筛选:删掉什么、补上什么
拿到这份清单后,我做了两件事:
删掉了第 9 条(value 内含引号)和第 12 条(只有左引号),因为当前函数注释明确写了格式为 key = "value",value 内部不支持转义引号,这两个 case 属于"当前版本不覆盖"的边界。
补充了一条:连续多行解析时的状态污染——不过这是集成测试的范畴,单元测试阶段先跳过。
最终保留 14 个用例。
3.5 Prompt 模板:让 AI 生成测试函数
筛选完成后,再用以下 Prompt 生成测试代码:
请为以下 Rust 函数生成 #[test] 测试函数,覆盖我筛选后的边界用例清单。
【边界用例清单】(粘贴上面筛选后的表格)
要求:
1. 每个测试函数用 #[test] 标注
2. 函数名使用 `test_parse_config_line_<场景英文描述>` 格式
3. 使用 assert_eq! 比较实际输出和预期
4. 所有测试放在 #[cfg(test)] mod tests 中
5. 代码可以直接编译通过
AI 生成的测试代码节选:
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_empty_string() {
assert_eq!(parse_config_line(""), None);
}
#[test]
fn test_whitespace_only() {
assert_eq!(parse_config_line(" "), None);
}
#[test]
fn test_comment_line() {
assert_eq!(parse_config_line("# 这是注释"), None);
}
#[test]
fn test_missing_equals() {
assert_eq!(parse_config_line("key"), None);
}
#[test]
fn test_missing_key() {
// = 左边没有 key,应当返回 None
assert_eq!(parse_config_line("= value"), None);
}
#[test]
fn test_missing_value() {
// key 后面有 = 但没有 value,value 应当是空字符串
assert_eq!(
parse_config_line("key="),
Some(("key".to_string(), "".to_string()))
);
}
#[test]
fn test_trim_whitespace() {
assert_eq!(
parse_config_line(" key = value "),
Some(("key".to_string(), "value".to_string()))
);
}
#[test]
fn test_inline_comment_removed() {
// # 及其后面的内容应该被忽略
assert_eq!(
parse_config_line("key=val#这是注释"),
Some(("key".to_string(), "val".to_string()))
);
}
#[test]
fn test_chinese_chars() {
assert_eq!(
parse_config_line("配置项=值"),
Some(("配置项".to_string(), "值".to_string()))
);
}
}
运行 cargo test,14 个测试全部通过,0 个失败。这说明两件事:一是函数的实现基本正确,二是 AI 对 Rust 标准库 API 的掌握足以生成可直接编译的测试代码。
四、AI 生成的边界列表,你该信多少?
坦率地说,AI 在边界分析上也并非全能。以下是实际使用中发现的几类典型遗漏和评估方法。
4.1 AI 容易遗漏的三类边界
平台相关边界。比如 usize::MAX 在 64 位和 32 位平台上值不同,但 AI 通常只按 64 位给出用例。如果你的库需要支持 WASM(32 位),这个差异可能变成 bug。
并发状态边界。对于涉及共享状态的函数(如 &mut self 方法),AI 很少主动生成"两个线程同时调用"的边界场景。这是因为它拿到的是单个函数的签名,看不到调用上下文。
组合爆炸边界。当函数有 3 个以上参数时,AI 倾向于独立分析每个参数,很少生成"参数 A 取极值 + 参数 B 取极值"的交叉用例。比如一个分页函数 fn list(page: u32, size: u32),AI 可能分别测试 page=0 和 size=0,但不一定会测试 page=0, size=0 的组合。
4.2 自检清单:人工验证的三个维度
在信任 AI 输出的边界列表之前,建议用以下三个问题过一遍:
| 类型覆盖 | 每个参数的类型取值范围是否穷尽?(Option 的 None/Some、Result 的 Ok/Err、数值的 0/负/MAX) |
| 业务语义 | 用例是否覆盖了注释里描述的所有行为?("空行返回 None"、"去掉双引号") |
| 跨参数组合 | 是否存在两个以上参数交互的边界?(即使当前没有,也要记一笔留待集成测试) |
这套自检不是要推翻 AI 的输出,而是补上它"看不到全局"的短板。实际工作中,我把这个三问清单贴在终端旁边,每次筛选边界用例时扫一遍,平均能多抓到 1~2 个 AI 遗漏的 case。
五、总结
这套"AI 先生成边界、再写测试"的流程,本质是把测试设计中最消耗心力的"穷举"工作交给模型,把人留在更擅长的"判断"和"决策"位置上。
回归到工程实践,有三点值得带走:
测试是代码的第一道防线,而一份扎实的边界用例清单,就是这道防线上最密的那张网。


