实战:我用AI给老旧项目补单元测试,覆盖率从20%飙到80%

3 阅读8分钟

上个月,领导丢给我一个“祖传”项目——代码堆了五年,文档约等于零,单元测试覆盖率常年趴在20%不动。每次上线前,全靠几个人手动回归,点得手指抽筋。领导说:“给你两周,把覆盖率提到60%以上,不然下个版本我们不敢发。”

我盯着屏幕上的20%,又看看那几十个屎山一样的模块,陷入了沉思。手工补?一个函数一个函数地写断言?两周怕是连一个核心模块都啃不完。

这时候,我决定赌一把:让AI来给我打工

一、 开局:直接扔代码给AI,它给我吐了个寂寞

我的第一个想法很简单:把代码贴给 ChatGPT,让它写单元测试。

于是我从一个工具类开始,贴了大概 200 行代码,附上指令:“请为这个类编写完整的 JUnit 单元测试,使用 Mockito。”

AI 反应很快,10秒就给我生成了一个测试类,洋洋洒洒上百行。我满心欢喜地复制到 IDEA 里,一运行——全红

报错信息乱七八糟:NoSuchMethodErrorNullPointerExceptionMockito cannot mock this class……我仔细一看,AI 生成的测试里,mock 了一些私有方法,还 mock 了 final 类(那个项目用的 Java 8,Mockito 默认不支持 mock final)。更要命的是,它假设了一堆根本不存在的 when(...).thenReturn(...),而那些依赖的真实行为,AI 根本不知道。

第一个教训:AI 不知道你的项目上下文,它只会“看着代码写测试”,但看不懂你的依赖环境、框架限制、以及那些隐藏在配置文件里的 Bean。

二、 改变策略:我当“翻译”,AI 当“码农”

既然 AI 对全局一窍不通,那我就把全局拆碎了喂给它。我开始尝试 “逐方法 + 带上下文” 的策略。

2.1 先给 AI 讲业务,再给代码

对于每一个核心方法,我不再只贴代码,而是先用自然语言描述:

  • 这个方法在什么场景下调用?
  • 输入参数有哪些约束?比如 userId 必须是正数,否则抛异常。
  • 它会调用哪些外部服务?比如会调用 userService.getUserById(),那个服务可能返回 null 或抛异常。
  • 它内部有哪些分支逻辑?if-else 的条件是什么?

比如有一个 calculateDiscount 方法,里面调用了会员等级接口,根据等级返回折扣率。我这样描述:

“这是一个电商订单的折扣计算函数。它接收一个 userId 和一个 orderAmount。它会先调用 membershipClient.getLevel(userId) 获取会员等级(可能的返回值:'BRONZE', 'SILVER', 'GOLD')。如果是 GOLD,折扣率为 0.8;SILVER 为 0.9;BRONZE 或 null 为 1.0。然后计算 orderAmount * discountRate 并返回,结果保留两位小数。如果 orderAmount 为负数,抛出 IllegalArgumentException。”

然后我把方法代码贴给它,让它生成测试。这次生成的测试,终于能把几个分支覆盖到了,但是——它只写了 happy path

比如它只会测试 GOLD 返回正常折扣,但不会测试会员接口抛异常的情况,也不会测试 orderAmount 为 0 或者负数的情况。覆盖率数字上去了,但质量堪忧。

2.2 用“缺陷驱动”让 AI 补全边界

这时候我换了个指令:“请针对这个方法,设计包含以下场景的测试用例:1. 正常流程;2. 会员接口超时抛异常;3. 会员接口返回 null;4. orderAmount 为 0;5. orderAmount 为负数;6. orderAmount 为极大值(防止溢出)。”

AI 这才像模像样地生成了覆盖异常和边界的测试。运行一下,居然有几个挂了!仔细一看,是代码里确实没处理 orderAmount 为负数的情况——AI 帮我找到了一个隐藏的 bug。我赶紧修了代码,再跑测试,绿了。

第二个教训:AI 需要你告诉它“要测什么”,你不说的,它默认不测。把测试场景列清楚,它才能生成高质量的用例。

三、 批量化:用脚本让 AI 批量打工

一个方法一个方法地跟 AI 聊,虽然比手写快,但还是太慢。我算了一下,两周时间,就算不吃不喝也聊不完几百个方法。

于是我写了个小脚本(Python),做了个简陋的“AI 测试生成流水线”:

  1. 扫描项目中的所有 Java 文件,提取出 public 方法(用正则匹配,粗糙但够用)。
  2. 对每个方法,读取它所在的类全名,以及方法签名。
  3. 如果这个类还没有对应的测试类(或者覆盖率报表显示它低于某个阈值),就把它加入待生成队列。
  4. 调用 OpenAI API,传入我精心设计的 Prompt(包含业务描述模板、代码片段、测试框架要求、以及常见的边界场景列表)。
  5. 将生成的测试代码写入到对应的 *Test.java 文件中,并自动格式化。

当然,第一次批量生成的几百个测试,一大半都跑不过。主要问题:

  • import 缺失:AI 生成的 mock 对象没有导入对应的类,或者导错了包名。
  • 静态方法 mock 失败:有些工具类用的是静态方法,AI 直接 mock,但 PowerMock 没配。
  • 依赖未初始化:Spring 环境下的 @Autowired 依赖,AI 直接 new 出来,导致 NPE。

3.1 引入 Mock 框架和父类

针对这些问题,我做了两件事:

  1. 为每个测试类自动加上 @RunWith(MockitoJUnitRunner.class) 和必要的 import。
  2. 写了一个基础的测试父类 BaseUnitTest,里面初始化了 Mockito,并提供了通用的 mock 工厂方法。
  3. 在 Prompt 中明确要求:“使用 Mockito 进行 mock,不要 mock 静态方法,除非你确信项目已经集成了 PowerMock。对于 Spring 注入的依赖,统一使用 @Mock 注解。”

经过几轮调整,生成的测试通过率从 20% 提升到了 70% 左右。剩下的 30%,大多是 AI 理解错了业务逻辑,或者代码实在太复杂(比如多线程、异步回调),需要我手动干预。

四、 覆盖率虚高?还得靠变异测试来检验

覆盖率数字蹭蹭涨,很快到了 75%。但我心里没底:这些 AI 生成的测试,真的能测出代码的问题吗?

我在一个核心类上跑了一下 PITest(变异测试) ,看看这些测试能不能杀死变异体。结果惨不忍睹:变异测试覆盖率只有 30% 左右。也就是说,AI 生成的测试大多是“表面覆盖”,只调用了代码,但没有对返回值做充分的断言,所以即使把代码的逻辑改掉,测试也不会失败。

第三个教训:AI 生成的测试经常缺少强有力的断言。它可能只是调用了方法,然后 assertNotNull(result),根本不检查结果是否符合预期。这样的测试对提升代码质量几乎没有帮助。

于是我修改了 Prompt,加入了一段“优秀断言示例”,比如:

对于返回 BigDecimal 的方法,请使用 assertEquals(0, expected.compareTo(actual)) 而不是 assertEquals(expected, actual),因为浮点数精度问题。 对于集合,请验证大小和关键元素,不要只 assertNotNull。 对于抛异常的场景,请用 assertThrows 验证异常类型和消息。

并且,在生成测试后,我会运行变异测试,如果某个方法的变异体没被杀掉,就把变异体信息反馈给 AI,让它重新生成针对性的断言。这样迭代几轮,变异覆盖率也慢慢爬到了 60% 以上。

五、 结果与反思

两周时间,我几乎没怎么写代码,但通过“人指挥 AI”的方式,把项目的单元测试覆盖率从 20% 拉到了 82% (行覆盖)和 67% (分支覆盖)。更重要的是,在这个过程中,AI 帮我发现了 7 个隐藏的 bug,都是之前手工测试没覆盖到的边界情况。

现在每次 CI 都会自动运行这些测试,团队终于敢对老项目做重构了。而我,也从“手工点点点”的测试,变成了“AI 训练师+测试架构师”的混合体。


关于我们

霍格沃兹测试开发学社,隶属于 测吧(北京)科技有限公司,是一个面向软件测试爱好者的技术交流社区。

学社围绕现代软件测试工程体系展开,内容涵盖软件测试入门、自动化测试、性能测试、接口测试、测试开发、全栈测试,以及人工智能测试与 AI 在测试工程中的应用实践。

我们关注测试工程能力的系统化建设,包括 Python 自动化测试、Java 自动化测试、Web 与 App 自动化、持续集成与质量体系建设,同时探索 AI 驱动的测试设计、用例生成、自动化执行与质量分析方法,沉淀可复用、可落地的测试开发工程经验。

在技术社区与工程实践之外,学社还参与测试工程人才培养体系建设,面向高校提供测试实训平台与实践支持,组织开展 “火焰杯” 软件测试相关技术赛事,并探索以能力为导向的人才培养模式,包括高校学员先学习、就业后付款的实践路径。

同时,学社结合真实行业需求,为在职测试工程师与高潜学员提供名企大厂 1v1 私教服务,用于个性化能力提升与工程实践指导。