教AI代理调试不稳定测试

TL;DR · AI 摘要
文章介绍了如何利用AI代理结合传统开发工具解决测试不稳定问题,通过比较不同运行路径找到根本原因。
核心要点
- AI代理可以用于确定性地定位不稳定测试的根本原因。
- 通过比较通过和失败的执行路径差异,可以识别出导致问题的代码分支。
- 结合AI技能、传统工具和创造性思维可有效解决复杂问题。
结构提纲
按章节快速跳转。
思维导图
用一张图看清主题之间的关系。
查看大纲文本(无障碍 / 无 JS 友好)
- AI调试不稳定测试
- 问题描述
- 测试不可靠
- 难以复现
- 解决方案
- AI代理
- 传统工具
- 创造性思维
金句 / Highlights
值得收藏与分享的关键句。
AI代理可以记录并比较通过和失败的执行路径,帮助定位问题根源。
不稳定测试可能导致测试结果不可靠,需要确定性的调试方法。
通过比较不同执行路径的差异,可以识别出导致问题的关键代码分支。
教AI代理调试不稳定测试 | IntelliJ IDEA博客
IntelliJ IDEA – Java和Kotlin专业开发的领先IDE
教AI代理调试不稳定测试

2026年5月4日
如果你已经上网一段时间了,你肯定听说过AI代理技能。它们教会你的代理做这做那。你可能甚至自己使用或编写过一些。
如果你还不熟悉这些技能,想法很简单:而不是每次为特定任务提示指令,你只需定义一次并以后重复使用。一个技能是AI等价于知识库文章:一个普通的文本文档,它存在于可发现的位置,并描述步骤、一组惯例或领域特定的知识。
你在网上看到的大多数技能都是用于简单的事情,比如强制执行代码风格或提交信息惯例。但它们可以比这更强大。在本文中,我们将结合AI技能、传统的开发工具和一点创造性思维,解决一个众所周知的挑战性任务:让AI确定地找到不稳定测试的根本原因。
**问题**
不稳定测试被定义为在没有对代码或测试本身进行更改的情况下返回通过和失败的测试。
“不稳定性”削弱了测试的全部意义:当测试失败时,你无法确定是否真的有错误。你不能完全依赖测试结果,同时又不能忽视它们。这浪费了人力和基础设施资源。
而且,除了底层的bug本身已经很难之外,不稳定测试往往具有这样的特性:在几千次运行中失败一次,使它们极其难以重现和调试。
**示例项目**
对于示例项目,让我们采用本文中的webshop演示:你的程序不是单线程的。这是一个Spring Boot项目,其中一项服务存在TOCTOU(检查时间到使用时间)问题:它检查一个条件,然后根据该条件采取行动,但另一个线程可以在中间改变状态。在这种情况下,它有时会导致重复的发票号码,并且会使相应的测试变得不稳定。
这是有问题的测试:
@SpringBootTest class InvoiceServiceTest {
@Autowired private OrderService orderService;
@Test void firstTwoOrdersGetInvoiceNumbersOneAndTwo() { CompletableFuture<Invoice> alice = CompletableFuture.supplyAsync( () -> orderService.checkout("Alice", BigDecimal.TEN)); CompletableFuture<Invoice> bob = CompletableFuture.supplyAsync( () -> orderService.checkout("Bob", BigDecimal.TEN));
String num1 = alice.join().getInvoiceNumber(); String num2 = bob.join().getInvoiceNumber();
assertEquals(Set.of("INV-00001", "INV-00002"), Set.of(num1, num2)); } }
测试创建两个订单并检查结果发票获得号码INV-00001和INV-00002。由于InvoiceService中的一个bug,它可能随机通过或失败。
注意:如果你使用IntelliJ IDEA,可以通过测试运行器中的“运行直到失败”选项测试测试是否真的不稳定。让可疑的测试运行一段时间,看看它是否会最终失败。
- * *
如果我们对底层的bug一无所知,只拥有这个测试,有没有工具可以帮助我们找到根本原因?或者我们可以自己制作一个吗?此外,能否将构建和使用工具的任务委托给AI?
**直觉**
让我们为这类问题提出一些直觉。
为了产生两种结果,执行必须遵循不同的代码路径。差异可能很小,可能只是多了一个额外的方法调用或一个if分支被选择而不是另一个。但必须存在这种差异;否则,结果将是一致的。因此,如果我们能记录一次通过运行和一次失败运行的代码路径,然后进行比较,差异至少会引导我们朝正确方向前进。理想情况下,通过跟踪调用树,我们可以找到执行分裂的地方。这条线必须就是不稳定性起源的地方。
这种推理合理吗?让我们来检验一下。
**构建工具**
我们可以使用什么工具来记录代码路径?虽然不是专门设计用于跟踪,但测试覆盖率工具可以给我们想要的信息。
有几个Java覆盖率工具可供选择,例如JaCoCo和IntelliJ IDEA的覆盖率工具。我们将使用IntelliJ IDEA的,因为它包含一个命中计数功能,非常有用。我们可能需要这种额外的粒度,因为不稳定性可能不仅源于_执行了什么_,还源于_执行了多少次_。
IntelliJ IDEA 的覆盖率工具具有熟悉的用户界面,但我们需要一种方式来程序化地启动它。幸运的是,也可以通过命令行收集覆盖率数据,方法是通过 Maven Surefire 将覆盖率代理附加到 JVM 上:
mvn surefire:test \
-Dtest=com.example.webshop.service.InvoiceServiceTest \
"-DargLine=-Didea.coverage.calculate.hits=true \
-javaagent:\$AGENT_JAR=\$IC_FILE,true,false,false,true,com.example.webshop.*"-Didea.coverage.calculate.hits=true 参数告诉代理按行记录调用次数,而不是仅仅记录布尔命中/未命中掩码。测试完成后,结果会写入一个二进制 .ic 文件。
到目前为止一切顺利,但我们需要以人类(和AI)可读的格式呈现报告。
**添加文本输出**
幸运的是,IntelliJ 覆盖率代理 是开源的。让我们克隆该项目,并让AI添加一个文本报告器,将二进制报告转换为纯文本。

代理创建了一个名为 TextCoverageStatistics 的新类。在我们构建项目并针对我们的 .ic 文件运行报告器后,我们会得到类似以下内容:
=== 覆盖率摘要 ===
指令:236/618 38,2% 分支:0/20 0,0% 行:56/150 37,3% ...
=== 按类覆盖率 ===
类 行 行% 方法 方法%
... com.example.webshop.service.InvoiceNumberGenerator 4/4 100,0% 2/2 100,0% com.example.webshop.service.InvoiceService 10/10 100,0% 3/3 100,0% com.example.webshop.service.OrderService 6/6 100,0% 2/2 100,0% ...
报告的第一部分提供了总体概览:整个项目中覆盖了多少行、分支和方法。下面则是按类分解的详细信息,显示每个类的相同指标。
然后是每个类的每行命中次数:
--- com.example.webshop.service.InvoiceService --- 行 命中 分支 19 2 20 1 22 2 23 2 24 2 ... 对于覆盖率代理所仪器化的每一行,我们看到该行被执行了多少次,以及是否有任何分支被取到。实际报告更长,但你已经明白意思了。现在我们有了一个文本表示,显示哪些行被执行了,以及确切执行了多少次。
这是我们为差异所需的原始材料。到目前为止,一切顺利!
**比较报告**
据说,获得的报告包含必要的信息,一个非常努力的开发人员可以查阅它们并找到错误。但我们现在不是为了做这种琐碎的任务而来的,对吧?
让我们升级工具,使其能够获取多个报告变体并展示差异。最可控的方式是一次“砖块”一次地处理,但我认为我们现在可以将整个过程交给AI,包括自动化:

生成的脚本会在以下两个条件都满足时循环运行测试:
- 我们至少获得一次通过和一次失败的运行。
- 指定的运行次数已通过。
这两个条件都很重要,因为测试失败可能非常罕见,指定的运行次数可能不够。同时,在通过和失败运行中可能存在更细致的变化,因此我们可能希望捕捉到这些变化。
在收集报告后,脚本会汇总运行之间变化的行。以下是其外观:
收集了20次运行:12次通过,8次失败
在运行之间变化的行:
Invoice:29 Hits(1,2) Invoice:31 Hits(1,2) Invoice:32 Hits(1,2) InvoiceNumberGenerator:15 Hits(1,2) InvoiceService:19 Hits(1,2) Branch(1/2) InvoiceService:20 Hits(1,2) InvoiceService:22 Hits(1,2) InvoiceService:24 Hits(1,2) 所有变化都有相同的模式:差异不在于哪些行被执行,而在于_执行了多少次_。正如我们预期的那样,IntelliJ IDEA 的覆盖率代理的命中计数功能证明是有用的!
变化的行指向了 InvoiceService 中的一个懒初始化块及其下游影响在 InvoiceNumberGenerator 和 Invoice 中。命中计数的变化意味着初始化有时会运行多次,这不应该发生。这就是问题的根源所在。
如果你错过了描述问题的文章,这里说明为什么双重初始化会导致这个错误。createGenerator() 方法会查询数据库获取最后使用的发票号,并从该值开始创建计数器。当两个线程都在 if (generator == null) 块中进入之前,任何一个都没有完成时,每个线程都会从数据库读取相同的号码,并创建自己的生成器,从相同的值开始。结果是重复的发票号码。
覆盖率差异已经将我们引向了在前一篇文章中更详细讨论的 TOCTOU 竞争。但,我们当前方法的新颖之处在于它不仅仅依赖于人类专业知识,而且很容易让AI访问。
**将其转化为技能**
我认为,AI辅助修改开源工具,帮助你解决手头的任务,只需几分钟,本身就是令人惊叹的。但让我们把目光放得更长远一些。
我们已经做了以下工作:我们从一个直觉出发:不稳定的测试会走不同的代码路径,而覆盖率分析可以揭示它们的分歧点。然后我们将这个直觉转化为一个具体、可重复的流程。这是否值得写一篇知识库文章,或者是一个AI代理技能?是的!
在同一代理会话中,让我们让代理执行以下任务:
- 确保所有脚本都是自包含且可运行的。
- 将整个流程详细记录在一个
SKILL.md文件中,逐步说明,以便另一个代理可以在没有上下文的情况下跟随该流程。

代理将一切打包,并编写一份指南,描述何时应用该技能、需要哪些工具以及应遵循哪些步骤。

审查期间唯一的后续操作是将技能与规范对齐。由代理最初撰写的技能缺少前言中的元数据。代理擅长整理那些遗漏了细节的技能,但元数据对于可发现性至关重要。如果没有它,一个技能可能根本不会被其他代理选中。

**测试技能**
为了验证该技能实际上是否有效,让我们启动一个新的代理会话。没有预热,没有提示。相反,让我们以非常一般的方式表达问题,例如“找出并修复 InvoiceServiceTest 中不稳定的根源”。

代理将 SKILL.md 中的技能描述与问题描述匹配,发现指令并执行它们:它运行覆盖率脚本,阅读差异,识别出竞态条件。而不是猜测,它遵循已建立的步骤,每次都能得出相同的结论。这可以说是生成式AI所能达到的最确定性的结果!
**总结**
我们对覆盖率代理所做的更改已经发布在新版本 1.0.774 中。该技能也已在此 链接 处提供。
在这篇文章中,我们从对不稳定测试的直觉出发,围绕开源覆盖率代理构建了定制工具,利用它找到了一个竞态条件,并将整个流程打包成一个可重用的AI技能。您可以使用此技能在自己的项目中查找不稳定测试,但我希望这篇文章传达了更大的理念。
AI技能允许您教代理解决几乎所有的问题,只要您能将文本接口堆叠在一起。许多复杂的编程问题可以分解为更简单的问题,并使用熟悉的工具解决。借助AI协调这一切,我们甚至可以让这个过程变得愉快。正如在AI之前一样,好奇心是唯一真正的前提。
你是否受到启发,在自己的工作中解决了难题?你是否想分享你编写的技能或找到最有用的技能?欢迎在评论区告诉我们!
快乐调试!
[](https://blog.jetbrains.com/idea/2026/05/teaching-an-ai-agent-to-debug-flaky-tests#)