给AI喂了100个历史Bug,它现在能帮我写断言了
上个月,我被拉进了一个“历史遗留项目攻坚群”。这个项目有多老?老到它的单元测试覆盖率常年趴在**21%,每次上线前,测试同学都要靠手工回归点得手指抽筋。组长拍着我的肩膀说:“给你两周,把覆盖率干到60%**以上。”
我当时的表情,大概就和看到自己写的代码在线上炸了一样——生无可恋。
硬着头皮写了两天单元测试,那叫一个痛苦。一个复杂的工具类,我要先 Mock 一堆 Service,再想各种边界条件,最后还得手写断言。我盯着屏幕想:这种重复劳动,能不能让AI替我干了?
于是我开始了为期两周的“AI调教计划”。过程有点曲折,但结果还真成了——覆盖率最终飙到了**82%**。今天就来聊聊,我是怎么把AI从“只会复制代码的实习生”,培养成“懂业务逻辑的测试老手”的。
第一阶段:理想很丰满,现实很骨感
我的想法很简单:直接把那些“祖传代码”扔给 ChatGPT,让它帮我写测试。
我从一个核心的业务 Util 类开始,复制了 200 多行代码,附上指令:“请为这个类编写完整的 JUnit 测试,使用 Mockito,要覆盖所有分支。”
AI 反应很快,10 秒钟就给我吐了一个上百行的测试类。我满怀期待地粘贴到 IDEA 里,然后点击运行——满屏飘红。
报错信息五花八门:NoSuchMethodError、NullPointerException,甚至还有 Mockito cannot mock this class。我仔细一看,发现 AI 干了几件蠢事:
-
它 Mock 了一个 final类,但项目是老旧的 Java 8,Mockito 默认不支持 Mock final 。 -
它 Mock 了一个私有方法,这显然不符合常规的单测规范。 -
它生成的断言全是 Assert.assertNotNull(result)。这种断言有什么用?只要方法不返回 null,就算是把代码逻辑全删了,测试都能过。
第一个教训很深刻:AI 不知道你的项目上下文,它只是个无情的代码生成器,不是测试架构师。 它不知道你项目里有哪些框架限制,更不知道那些隐藏在 Spring 配置文件里的 Bean 该怎么注入。
第二阶段:把 AI 当实习生带,给业务上下文
既然 AI 不懂业务,那我就教它。
我开始换策略,不再一股脑地扔代码,而是当一个“翻译官”,把技术语言翻译成AI能理解的自然语言。我把过去一年这个模块修过的 100 多个 Bug 记录(那些让团队加过班的血泪史)整理出来,按类型打上标签。
这100个Bug就像一本错题集,涵盖了各种容易出错的场景:边界值遗漏、并发问题、Null 指针、异常处理不充分等等。
然后,我设计了一套“提示词模板”,每次让 AI 生成测试前,我都会告诉它:
“请为以下方法生成测试用例。该方法属于订单模块,调用方是营销中台。需要覆盖的场景: 1. 正常流程(参考历史Bug #45,注意金额为0的情况);2. 外部接口超时(参考Bug #12,需模拟TimeoutException);3. 会员等级为空(参考Bug #78,此场景应返回默认折扣,而非抛异常)。代码片段如下:
public BigDecimal calculateDiscount(String userId, BigDecimal amount) { ... }断言要求: 对于金额比较,必须使用
compareTo而非equals,避免精度问题。"
结果出乎意料的好。
AI 生成的测试,终于不再是那种“跑个过场”的代码了。它会自动生成类似这样的测试,精准地覆盖我指定的边界:
@Test
void testCalculateDiscount_WhenMembershipApiTimeout_ShouldReturnDefaultDiscount() {
// 模拟历史Bug #12 的场景
when(membershipClient.getLevel(anyString())).thenThrow(new TimeoutException());
BigDecimal result = orderService.calculateDiscount("user123", new BigDecimal("100.00"));
// 验证是否返回了兜底的默认折扣,而不是报错
assertEquals(0, new BigDecimal("1.00").compareTo(result));
}
第二个教训(或者说心得):AI 的能力上限,取决于你喂给它的上下文下限。 你把历史 Bug 喂给它,它才知道哪里最容易出 Bug;你告诉它业务逻辑,它才能写出贴合场景的断言 。
第三阶段:批量流水线,让 AI 打工,我做监工
一个方法一个方法地调教,虽然比手写快,但还不够。我要的是批量化生产。
我写了一个简单的 Python 脚本,做了个简陋的“AI 单测生成流水线”:
-
扫描:扫描项目里那些覆盖率低于 50% 的类。 -
提取:解析该类的方法签名和依赖。 -
检索:从我们整理的历史 Bug 库中,检索与该类相关的历史缺陷场景。 -
生成:调用大模型 API,并把我设计好的“提示词模板”(包含业务描述、历史 Bug 场景、代码片段)传给它。 -
写入:将生成的代码写入对应的测试文件。
当然,第一次跑批量,翻车率高达 60%。主要问题集中在断言太弱上。
比如,AI 生成了一段代码,测试一个修改用户信息的方法。它的断言是:
Assertions.assertThat(updatedUser).isNotNull();
这个方法执行后,到底有没有修改成功?isNotNull 根本说明不了问题。这就是所谓的“覆盖率高,但质量低”,行覆盖率上去了,但代码里的逻辑错误,它一个也抓不出来。
这让我想到了一个更严格的检验标准:变异测试。
我在一个核心模块上跑了一下 PITest,结果让人崩溃——**变异测试覆盖率只有 20%**。这意味着,即使我改了代码的逻辑(比如把 if(level == "GOLD") 改成 if(level != "GOLD")),AI 生成的测试也不会失败,因为它的断言太弱了,根本没有验证核心结果 。
第四阶段:锻造“火眼金睛”,教 AI 写有效断言
既然问题出在断言上,那我就专门针对“断言”进行特训。
我收集了项目组里公认写得最好的20个手写测试用例,把它们作为“范文”喂给 AI,并告诉它:“学习这些断言的写法,不要只判断空,要判断结果是否符合预期。”
同时,我在提示词里增加了“断言模式”:
-
对于查询类方法:必须验证返回值的关键字段。 -
对于更新类方法:必须通过 Verify 模式验证依赖服务的方法被正确调用,且参数正确。 -
对于异常类测试:必须验证异常类型和异常消息(因为很多时候不同的异常由不同的错误码映射,消息也很重要)。 -
对于集合类:必须验证集合的大小和包含元素,而不是只断言不为空。
经过几轮优化,AI 生成的断言终于像那么回事了。比如针对上面的更新方法,它现在会写成这样:
@Test
void updateUser_ShouldModifyUserName_WhenInputIsValid() {
// ...
userService.updateUser(request);
// 有效的断言:验证数据库里的实体真的被改了
User actualUser = userRepository.findById(userId);
assertThat(actualUser.getName()).isEqualTo("新名字");
// 并且发送了领域事件
verify(eventPublisher).publishEvent(any(UserUpdatedEvent.class));
}
最后,我再次运行 PITest,这次变异测试覆盖率从 20% 涨到了 **67%**。虽然离 100% 还有距离,但至少现在 AI 写的测试,是真的能“杀死”变异体、保护代码逻辑的了。
写在最后
两周时间,我几乎没怎么写“硬代码”,但通过“人指挥 AI + 历史数据喂养 + 变异测试验证”这套组合拳,硬是把那个“祖传项目”的单元测试覆盖率从 21% 拉到了 82%。更重要的是,在这个过程中,AI 帮我发现了 7 个隐藏的 Bug,都是那种只有在极端边界条件下才会触发的“幽灵 Bug”。
现在的我,每天的工作不再是埋头写枯燥的 assertEquals,而是琢磨怎么优化提示词,怎么整理更好的历史数据喂给 AI,以及怎么通过变异测试的报告来反向要求 AI 写出更高质量的断言。
如果你也想让 AI 帮你写测试,我的建议是:别把它当神仙,把它当徒弟。给它看错题集,它才能考高分。
- 点赞
- 收藏
- 关注作者
评论(0)