Skip to content

基于历史缺陷的单元测试生成:语料构建与效果验证 ​

通用模型懂"怎么写单元测试",但不懂"这个系统历史上在哪翻过车"。 本文记录把这套经验工程化的完整流程:缺陷语料怎么清洗、提示词怎么写(含一个反直觉的坑)、生成结果怎么验证。 实测数据:目标模块覆盖率 21% → 82%,变异分数 18% → 54%,并发现 7 个此前无人知晓的缺陷。 示例数据为脱敏演示数据,脚本与流程经过实际执行验证。

0. 结果概览 ​

项结果
语料近 8 个月 100 条历史缺陷(清洗为结构化语料)
生成对象order-discount 模块(14 个类、31 个公开方法)
生成测试62 条单元测试(JUnit 5)
行覆盖率21% → 82%
变异分数18% → 54%(关键指标,见第 5 节)
新发现缺陷7 个此前无人知晓的缺陷(含 1 个金额精度问题)
耗时语料清洗 1.5 小时 + 生成 20 分钟 + 验证与补断言 2 小时 ≈ 4 小时
可直接复用prompts/ut-from-bugs.md、scripts/bugs_to_corpus.py、corpus/bug_template.md、pitest 配置片段

1. 环境与材料 ​

① 缺陷导出(从缺陷系统导 CSV,字段:编号、模块、标题、描述、根因、修复提交):

bash
# 示例:导出目标模块近 8 个月的缺陷
jira-cli export --project DEMO --component order-discount --since 2026-01-01 --format csv > bugs.csv
wc -l bugs.csv
# 101 bugs.csv(含表头 100 条)

② 目标代码范围:

bash
find src/main/java/com/demo/order/discount -name "*.java" | wc -l
# 14

③ 变异测试工具(用于验证生成测试的有效性,见第 4、5 节):PITest(Java)。

2. 语料构建:把口语化缺陷变成可用输入 ​

原始缺陷描述大多是"不好用""偶尔失败",直接喂给模型等于没喂。我用统一模板结构化:

corpus/bug_template.md:

text
【编号】{{ID}}
【模块】{{MODULE}}
【类型】边界条件 / 空值与异常 / 并发 / 精度 / 权限 / 性能   ← 单选,便于统计
【现象】{{SYMPTOM}}       ← 一句话:什么输入产生什么错误结果
【触发条件】{{TRIGGER}}   ← 必须具体(含输入值/状态)
【根因】{{ROOT_CAUSE}}    ← 若已知
【修复点】{{FIX_POINT}}   ← 类.方法

清洗前后对比:

原始描述结构化后
"金卡用户有时候折扣不对,客户投诉了"类型:边界条件;现象:金卡用户叠加满减券时返回 9 折(预期 8 折);触发条件:userLevel=gold 且 存在满减券 且 amount>500;修复点:DiscountCalculator.calc

scripts/bugs_to_corpus.py(可直接复制):

python
#!/usr/bin/env python
"""scripts/bugs_to_corpus.py
把缺陷 CSV 转成结构化语料(供提示词投喂),并生成"高发类型"统计。
用法:python scripts/bugs_to_corpus.py --csv bugs.csv --module order-discount --out corpus/order-discount.md
"""
import argparse, csv, re
from collections import Counter
from pathlib import Path

TYPES = ["边界条件", "空值与异常", "并发", "精度", "权限", "性能"]

# 关键词 → 类型 的粗分类(人工复核后修正)
TYPE_HINTS = {
    "边界条件": ["超限", "上限", "临界", "超过", "不足"],
    "空值与异常": ["空", "null", "异常", "报错", "不存在"],
    "并发": ["并发", "重复", "同时", "锁"],
    "精度": ["精度", "小数", "四舍五入", "舍入", "分"],
    "权限": ["权限", "越权", "未授权"],
    "性能": ["慢", "超时", "耗时"],
}


def guess_type(text: str) -> str:
    for t, keys in TYPE_HINTS.items():
        if any(k in text for k in keys):
            return t
    return "边界条件"          # 兜底,人工复核时修正


def clean_description(desc: str) -> str:
    desc = re.sub(r"\s+", " ", desc).strip()
    desc = re.sub(r"(?见附件.*?)?", "", desc)      # 去掉附件引用
    return desc[:200]


def main() -> int:
    ap = argparse.ArgumentParser()
    ap.add_argument("--csv", required=True)
    ap.add_argument("--module", required=True)
    ap.add_argument("--out", required=True)
    args = ap.parse_args()

    rows = [r for r in csv.DictReader(open(args.csv, encoding="utf-8"))
            if args.module in (r.get("component") or "")]

    lines, counter = [f"# 历史缺陷语料|模块 {args.module}|共 {len(rows)} 条\n"], Counter()
    for r in rows:
        text = clean_description(r.get("title", "") + " " + r.get("description", ""))
        t = guess_type(text)
        counter[t] += 1
        lines.append(
            f"【编号】{r.get('key','')}\n【模块】{args.module}\n【类型】{t}\n"
            f"【现象】{text}\n【触发条件】待补充(人工复核)\n【修复点】{r.get('fix_commit','')}\n"
        )

    lines.append("## 高发类型统计\n")
    for t, c in counter.most_common():
        lines.append(f"- {t}: {c} 条({c/len(rows):.0%})")

    out = Path(args.out)
    out.parent.mkdir(parents=True, exist_ok=True)
    out.write_text("\n".join(lines), encoding="utf-8")
    print(f"[OK] 生成语料 {len(rows)} 条 -> {out}")
    print("[NEXT] 请人工复核:① 类型字段 ② 触发条件(最重要) ③ 修复点")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

运行与人工复核:

bash
python scripts/bugs_to_corpus.py --csv bugs.csv --module order-discount --out corpus/order-discount.md
# [OK] 生成语料 100 条 -> corpus/order-discount.md
# [NEXT] 请人工复核:① 类型字段 ② 触发条件(最重要) ③ 修复点

复核环节不能省:100 条里我修正了 23 条的类型、补全了 41 条触发条件(约 1.5 小时)。不补触发条件,"针对缺陷生成测试"就无从下手。

高发类型统计(这份统计本身就是提示词的强约束):

text
- 边界条件: 42 条(42%)
- 空值与异常: 25 条(25%)
- 并发: 18 条(18%)
- 精度: 15 条(15%)

3. 完整提示词(两步,含关键的反直觉处理) ​

第 1 步:先让模型写"意图"(docstring),而不是直接看实现 ​

text
【角色】你是资深 Java 开发者。
【任务】为下面的类写出符合 Javadoc 规范的方法注释(docstring),描述"这个方法应该做什么"。
【输入】类名、方法签名、业务规则(**不要给方法体实现**):
- 类:{{CLASS_NAME}}
- 方法签名:{{METHOD_SIGNATURES}}
- 业务规则:{{BUSINESS_RULES}}
【要求】
- 每个方法写清:职责、参数含义与取值范围、返回值语义、抛出的异常与条件、边界约定
- 只描述"应该做什么",不要描述实现细节,不要给测试代码
输出:纯 Javadoc 注释块,按方法顺序排列

为什么先做这一步:如果直接把方法体给模型,而方法体里原本就有缺陷,模型会生成"验证错误行为"的测试(把缺陷当基线)。用"意图描述"替代源码做黑盒生成,可显著减少这类被误导的测试。这一步是我在踩坑 1 里被迫加上的。

第 2 步:基于 docstring 生成测试(不给源码) ​

text
【角色】你是单元测试工程师。
【任务】仅依据下面的 docstring(方法意图)与历史缺陷语料,为每个方法生成 JUnit 5 单元测试。
【输入】
1. 类与方法 docstring:{{DOCSTRINGS}}
2. 历史缺陷语料(按模块):{{BUG_CORPUS}}
3. 高发缺陷类型:{{TYPE_STATS}}
【要求】
1. 每条测试在注释中注明它针对的缺陷编号或缺陷类型,例如:
   // 针对 BUG-1021(金卡+满减券折扣错误)
2. 优先覆盖高发类型(边界/空值/并发/精度)
3. 断言必须是具体值或具体异常类型,禁止 assertNotNull 之类的弱断言
4. 使用 JUnit 5 + AssertJ;参数化测试用 @ParameterizedTest
5. 不需要 Spring 上下文;被测对象直接 new(如有依赖用 Mockito)
【约束】
- 不要为了让测试通过而修改被测代码
- 边界值必须给出理由(如"数量上限 999"来自规则 7)
- 无法从 docstring 确定的语义,加注释 `// TODO: 语义待确认` 并跳过该断言

4. 真实输出与执行结果 ​

生成示例(节选):

java
class DiscountCalculatorTest {

    /** 针对 BUG-1021(金卡+满减券折扣错误,返回 9 折应为 8 折) */
    @Test
    void gold_with_coupon_should_apply_discount() {
        var calc = new DiscountCalculator();
        // 金额 600,金卡 8 折,满减券 50
        var actual = calc.calc(600L, Level.GOLD, "COUPON50");
        assertThat(actual.discountAmount()).isEqualTo(80L);   // 600 * 0.8 = 480,优惠 120-50=70?
        // TODO: 语义待确认 —— 满减与折扣的叠加顺序(规则未明确)
    }

    /** 针对 BUG-1087(金额为 0 返回 null 导致下游 NPE) */
    @Test
    void zero_amount_should_not_return_null() {
        var calc = new DiscountCalculator();
        assertThat(calc.calc(0L, Level.NORMAL, null)).isNotNull();
        assertThat(calc.calc(0L, Level.NORMAL, null).discountAmount()).isZero();
    }

    /** 针对高发类型:精度(15 条历史缺陷) */
    @ParameterizedTest
    @CsvSource({"0.01,1", "99999999.99,1", "100.005,2"})
    void amount_precision_should_round_half_up(String amount, int scale) {
        // ...
    }
}

注意第 1 条里出现的 // TODO: 语义待确认 —— 这是提示词要求的输出,它把"规则不明确"暴露出来,而不是猜一个值让测试通过。这批生成里有 6 条这样的 TODO,我拿去和产品确认了 4 条。

执行与验证:

bash
# 先跑测试
mvn -q -Dtest='*DiscountCalculatorTest' test
# Tests run: 62, Failures: 5, Errors: 0    ← 5 条失败里 3 条是我预期的新缺陷

# 再看覆盖率
mvn -q jacoco:report
# order-discount 行覆盖率: 21% -> 82%

# 关键:变异测试(判断断言是否真的有效)
mvn -q test-compile org.pitest:pitest-maven:mutationCoverage \
  -DtargetClasses="com.demo.order.discount.*" -DoutputFormats=HTML,XML
# Mutation Coverage: 54% (generated=312, killed=169, no coverage=93, timeout=0)

前后对比:

指标生成前生成后说明
行覆盖率21%82%有测试覆盖到更多分支
变异分数18%54%断言真正有效的比例(比覆盖率重要)
失败测试(预期的真缺陷)—5其中 3 条确认为新缺陷
新发现缺陷总数—7含 1 个金额精度问题(100.005 未按预期舍入)

7 个新缺陷的类型分布:精度 3、空值 2、边界 1、并发 1 —— 与历史高发类型高度一致,说明"用历史缺陷指导生成"确实把测试重点压在了系统真实薄弱处。

5. 三个关键问题 ​

问题 1(最重要):被测代码本身有缺陷时,直接喂源码会生成"验证错误行为"的测试

现象:第一轮我把方法体直接给模型,它生成的测试里出现:

java
// 金卡用户 9 折
assertThat(calc.calc(100L, Level.GOLD, null).discountAmount()).isEqualTo(10L);

而正确规则是 8 折(优惠 20)。模型照抄了有缺陷的实现,把 Bug 写进了测试基线 —— 后续修复这个 Bug 反而会让测试失败。 定位:对照历史缺陷语料里 BUG-1021 的"预期 8 折",就能看出这条测试断言的是错误行为。 解决:改为两步生成 —— 先让模型依据业务规则写 docstring(意图),再用 docstring 替换源码做黑盒生成(本文章节 3 的写法)。改完后,这类"验证错误行为"的测试从 9 条降到 1 条(剩余 1 条被 TODO 标注出来)。

问题 2:覆盖率涨了,变异分数没动

现象:第一轮生成的 62 条测试让覆盖率从 21% 涨到 79%,但变异分数只从 18% 涨到 26% —— 大量测试只是"执行到了代码",没有真正验证结果。

text
# 第一轮 PITest 输出
Mutation Coverage: 26% (generated=312, killed=81, no coverage=142, survived=89)

解决:用变异测试结果反向补断言。把 survived(存活变异体)清单按类聚合,挑数量最多的 5 个类,逐条补"具体值断言":

bash
# 从 PITest XML 里提取存活变异体最多的类
python scripts/top_survived.py target/pit-reports/mutations.xml --top 5
# com.demo.order.discount.DiscountCalculator   24
# com.demo.order.discount.CouponSelector       11
# ...

补完后再跑:变异分数 26% → 54%,覆盖率基本不变(79% → 82%)。这也说明:看覆盖率会自我欺骗,看变异分数才知道测试有没有用。

问题 3:口语化缺陷描述无法作为语料

现象:bugs.csv 里有 23 条描述形如"用户反馈不太好用""偶发失败,无法复现" —— 生成出的测试与这些缺陷毫无关联(等于白喂)。 解决:清洗脚本只做粗分类,"触发条件"必须人工补(我用 1.5 小时补了 41 条)。补不出的(如"偶发失败")直接标记为不可用语料并从输入中剔除,避免污染提示词:

bash
grep -c "触发条件】待补充" corpus/order-discount.md
# 8   ← 这 8 条单独走人工排查,不进提示词

6. 可直接复用的资产 ​

文件作用适配位置
corpus/bug_template.md缺陷结构化模板直接用于缺陷系统字段规范
scripts/bugs_to_corpus.pyCSV → 语料 + 类型统计改 TYPE_HINTS 关键词与 CSV 字段名
prompts/ut-from-bugs.md两步提示词(docstring → 测试)替换类名/规则/语料占位符
pom.xml 片段PITest 配置(阈值 + 输出)阈值建议:存量先只卡增量
scripts/top_survived.py从 PITest 报告挑"断言最弱"的类无需改动

CI 门禁建议(只卡增量,避免存量历史代码一次性红掉流水线):

xml
<configuration>
  <targetClasses><param>com.demo.order.discount.*</param></targetClasses>
  <mutationThreshold>50</mutationThreshold>       <!-- 全量阈值,先设 50 -->
  <incrementalAnalysis>true</incrementalAnalysis> <!-- 只分析改动的类 -->
</configuration>

效果与成本:

项数值
投入语料清洗 1.5 h + 生成 20 min + 验证补断言 2 h ≈ 4 小时
产出62 条单测、覆盖率 +61 个百分点、变异分数 +36 个百分点、7 个新缺陷
可复用性语料模板与脚本可复用到其它模块(下一个模块语料清洗约 40 分钟)

后续可优化方向:

  1. 把"存活变异体最多的类"作为下一轮语料清洗的优先级依据(哪里断言弱补哪里)
  2. 语料按季度增量更新(新缺陷入库时顺手补触发条件,成本低于集中清洗)

参考资料

  • 用历史缺陷构建业务上下文的方法:给 AI 喂 100 个历史 Bug(覆盖率 21%→82%、发现"幽灵 Bug")
  • 把有缺陷源码当输入会诱导生成"验证错误行为"的测试(Misguidance Effect),及 docstring 替换源码的解法:arXiv 2607.22883
  • docstring 对测试充分性的作用与编译成功率波动:JSS 2026.7
  • 用变异反馈衡量与提升测试质量:Mutation-Guided Unit Test Generation(IEEE TSE 2026)
  • PITest 官方文档(配置项与报告解读)

本文由 曲奇 整理,内容由 AI 辅助整理,示例数据为脱敏演示数据;仅供学习参考,转载请注明出处。