AI 辅助 Rust 单元测试:让模型先生成边界用例,再写测试函数

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=0size=0,但不一定会测试page=0, size=0的组合。

4.2 自检清单:人工验证的三个维度

在信任 AI 输出的边界列表之前,建议用以下三个问题过一遍:

验证维度检查方法
类型覆盖每个参数的类型取值范围是否穷尽?(Option 的 None/Some、Result 的 Ok/Err、数值的 0/负/MAX)
业务语义用例是否覆盖了注释里描述的所有行为?("空行返回 None"、"去掉双引号")
跨参数组合是否存在两个以上参数交互的边界?(即使当前没有,也要记一笔留待集成测试)

这套自检不是要推翻 AI 的输出,而是补上它"看不到全局"的短板。实际工作中,我把这个三问清单贴在终端旁边,每次筛选边界用例时扫一遍,平均能多抓到 1~2 个 AI 遗漏的 case。

五、总结

这套"AI 先生成边界、再写测试"的流程,本质是把测试设计中最消耗心力的"穷举"工作交给模型,把人留在更擅长的"判断"和"决策"位置上。

回归到工程实践,有三点值得带走:

  1. Prompt 的质量决定边界清单的质量。给 AI 的上下文要包含函数签名、业务语义和预期行为三要素,敷衍的 Prompt 只会得到敷衍的边界。
  2. 人工筛选是必要的安全网。平台差异、并发场景、参数组合这三类边界,AI 目前的覆盖率都不够,必须靠人补。
  3. 编译通过 ≠ 思路正确。AI 生成的测试代码语法没问题,但断言的预期值可能编错——比如把None写成Some(("", ""))。跑测试前先扫一眼每个 assert 的预期值是否合理。

测试是代码的第一道防线,而一份扎实的边界用例清单,就是这道防线上最密的那张网。