几年前(最多三年),QA 工程师面临的最大挑战之一是:我们如何说服业务方和团队,应该投资于自动化测试?如今,有了 AI,这个问题基本已经解决。我们不再花大量时间编写自动化测试代码,因为 AI 会替我们完成,但一个新的挑战出现了:不稳定测试。在本文中,我将解释什么是不稳定测试、我如何构建一个工具来帮助识别它们,以及我如何使用 AI 进行初步分析。

什么是不稳定测试,以及它为何重要

在深入介绍解决方案之前,让我们先理解什么是不稳定测试,以及我们为什么要关注它。

简而言之,不稳定测试是一种自动化测试,在代码或测试本身没有任何改动的情况下,既会产生通过结果,也会产生失败结果。

Doubt

如果它们能通过,我们为什么还要监控它们?毕竟它们最终是通过的。

这可能是你的第一反应,但一旦你理解了什么是不稳定测试,你就会发现,在大型项目中,它会损害团队对自己产品和测试的信任,真正的失败开始被忽视,而且每个流水线的 CI 时间和成本都会上升。

项目快速概览

基于这些担忧,我开发了这个项目。代码可在 GitHub 上获取:flaky-test-detector。

在第一版草稿中,我原本只打算解析测试结果、按测试和提交进行评分,并手动进行分析:

First draft

完成这第一版草稿后,我想:为什么不集成一个 AI 来帮我做分析呢?带着这个想法,我添加了新功能来支持分析以及与 AI 模型的集成。如今,这个解决方案是这样的:

Last draft

项目的组织方式如下:

flaky-test-detector/
├── .github/workflows/
│   └── flaky-report.yml   # weekly analysis + result files
├── agents/
│   └── agent.py           # optional AI agent: root-cause analysis of reports
├── docs/
│   └── how-it-works.svg   # diagram used in this README
├── flaky_detector/
│   ├── __init__.py
│   ├── __main__.py        # CLI
│   ├── parsers.py         # JUnit XML parser
│   ├── github.py          # fetches JUnit artifacts from GitHub Actions
│   ├── scoring.py         # flakiness scoring and ranking
│   └── report.py          # result files (JSON / CSV / Markdown)
├── scripts/
│   └── generate_junit.py  # generates sample JUnit XML files
├── results/               # sample JUnit XML files (git-ignored)
├── reports/               # generated reports and AI analyses
├── tests/
├── pyproject.toml         # project metadata and dev dependencies (uv)
├── requirements.txt
└── README.md

对不稳定测试进行评分和排名

让我们理解这个项目如何对不稳定测试进行评分和排名。它首先接收 XML 格式的 JUnit 报告(大多数测试框架的标准输出,每个执行的测试对应一个<testcase>),然后parsers.py负责将该文件中的信息解析为合适的格式:

class Outcome(str, Enum):
    PASSED = "passed"
    FAILED = "failed"
    ERROR = "error"
    SKIPPED = "skipped"


@dataclass(frozen=True)
class TestResult:
    """单次 CI 运行中的单次测试执行。"""
    __test__ = False  # 防止 pytest 尝试收集这个类

    name: str  # "<classname>::<name>"
    outcome: Outcome
    commit_sha: str | None
    run_id: str | None
    time: float = 0.0
    message: str | None = None
    source: str | None = None  # 结果来自的文件

    @property
    def passed(self) -> bool:
        return self.outcome is Outcome.PASSED

如果该文件不是合法的 JUnit XML,则会抛出异常;如果合法,则继续解析。当调用scoring.py时,它会使用FlakinessScorer类,该类负责接收TestResult对象并将其转换为TestScore对象。每个结果先按测试名称分组,再按 commit 分组,对于每个 (test, commit) 组合,评分器会检查其 outcome。它会忽略:

  • skipped测试,因为它们无法说明不稳定性;
  • 来自缺少commit_sha属性(commit id)的 JUnit 文件的结果,因为它们无法相互比较。

两者failed和error都算作失败。

@dataclass(frozen=True)
class TestScore:
    """单个测试的不稳定性评分。"""
    __test__ = False  # 防止 pytest 尝试收集这个类

    name: str
    commits: int  # 该测试有可用数据的 commit 数
    flaky_commits: int  # 该测试结果不一致的 commit 数
    commits_where_flaky: tuple[str, ...] = field(default=())
    error_messages: tuple[str, ...] = field(default=())  # 去重后的失败消息,按首次出现顺序

    @property
    def score(self) -> float:
        return self.flaky_commits / self.commits if self.commits else 0.0


class FlakinessScorer:
    def score(self, results: Iterable[TestResult]) -> list[TestScore]:
        outcomes: dict[str, dict[str, set[bool]]] = defaultdict(lambda: defaultdict(set))
        messages: dict[str, dict[str, None]] = defaultdict(dict)  # 有序、去重
        for r in results:
            if not r.commit_sha or r.outcome.value == "skipped":
                continue
            outcomes[r.name][r.commit_sha].add(r.passed)
            if not r.passed and r.message:
                messages[r.name][r.message] = None
        scores = []
        for name, by_commit in outcomes.items():
            flaky = tuple(sorted(sha for sha, seen in by_commit.items() if len(seen) > 1))
            scores.append(
                TestScore(
                    name=name,
                    commits=len(by_commit),
                    flaky_commits=len(flaky),
                    commits_where_flaky=flaky,
                    error_messages=tuple(messages[name]),
                )
            )
        return scores

当同一个 commit 的结果同时为{True, False}时,脚本会将测试标记为不稳定:即同一份代码库下该测试既失败又通过。随后它会生成 Ranker 所需的数据,格式如下:

{
  "name": "tests.checkout.test_cart::test_9",
  "commits": 10,
  "flaky_commits": 3,
  "commits_where_flaky": [
    "gen0007d154385",
    "gen05893e77ff9",
    "gen059095256f8"
  ],
  "error_messages": [
    "AssertionError: timeout waiting for gateway",
    "ConnectionError: connection reset by peer"
  ]
}

当调用FlakinessRanker类时,它会使用这些数据对测试进行排序。它只考虑拥有至少 5 次提交数据的测试(min_commits),因此分数是可信的;当测试的分数达到 0.1 或更高时(threshold),它就会将该测试标记为不稳定。这两个值都可以修改:

def __init__(self, threshold: float = 0.1, min_commits: int = 5) -> None:
    if not 0.0 <= threshold <= 1.0:
        raise ValueError("threshold must be between 0 and 1")
    if min_commits < 1:
        raise ValueError("min_commits must be >= 1")
    self.threshold = threshold
    self.min_commits = min_commits

该分数等于flaky_commits(同一测试既通过又失败的提交数)除以该测试在流水线中可用的commits总数。

有一个测试在 10 次提交上运行过,其中 3 次提交上它在同一次提交中既通过又失败。

在这个例子中,commits是 10,flaky_commits是 3,因此分数为 0.3。

用这第一个实现,我们已经能看到初步结果。我创建了一些 JUnit 报告,运行下面的命令后得到了这样的输出:

python -m flaky_detector analyze ./results/*.xml --threshold 0.1

Frist results

每一行显示分数、测试名称,以及该测试有数据的提交中不稳定提交的数量。

报告与智能体创建

正如我之前提到的,在完成基础部分后我想:为什么不改进一下呢?第一步是创建结果报告。由于我已经在考虑集成 AI 智能体,报告需要一种适合它的格式。报告由report.py生成,支持 JSON、CSV 和 Markdown 格式(使用--output-dir选项)。在本文中,我们将重点关注 JSON 格式,因为它是 AI 智能体所使用的格式:

{
  "generated_at": "2026-11-10T19:32:23+00:00",
  "source": "local",
  "threshold": 0.1,
  "min_commits": 5,
  "results_parsed": 19644,
  "tests_analyzed": 109,
  "flaky_count": 12,
  "flaky_rate": 0.1101,
  "tests": [
    {
      "name": "tests.checkout.test_cart::test_9",
      "score": 0.7,
      "commits": 60,
      "flaky_commits": 42,
      "is_flaky": true,
      "commits_where_flaky": [
        "gen0007d154385",
        "gen059095256f8"
      ],
      "error_messages": [
        "AssertionError: timeout waiting for gateway",
        "ConnectionError: connection reset by peer",
        "AssertionError: race condition on shared state",
        AssertionError: expected 200, got 503,
        TimeoutError: operation timed out after 30s
      ]
    }
  ]
}

为了构建这个 AI 智能体,我使用了Strands Agents SDK。我第一次接触这个 SDK 是在 Train the Trainers 工作坊上(感谢 AWS EMEA 的邀请),由于配置智能体的方式非常简单,我决定在这个项目中使用它。

在这个项目中,我使用的是GeminiModel(API key 从GOOGLE_API_KEY变量中读取,位于.env),但你可以切换到其他模型;参见Model Providers。

from strands import Agent
from strands.models.gemini import GeminiModel

model = GeminiModel(
    client_args={"api_key": api_key},
    model_id="gemini-2.5-flash",
)

agent = Agent(
    model=model,
    tools=[fetch_json_report, export_analysis],
    name="Flaky Test Analysis Agent",
    description="这个智能体分析不稳定测试报告,并提供关于不稳定测试、其分数、趋势的洞察,同时分析错误消息以提供关于不稳定根因的洞察。当被要求总结不稳定测试报告时,它会使用 fetch_json_report 工具获取报告数据,然后提供总结,说明有多少测试不稳定、哪些最严重,以及基于错误消息的可能根因。",
    system_prompt="你是一个有用的助手,负责分析不稳定测试报告。在需要时使用 fetch_json_report 工具获取报告数据。始终阅读每个不稳定测试的 error_messages,并推断可能的根因(超时、竞态条件、网络/网关不稳定、共享状态等),说明每个原因影响哪些测试以及你的置信度。分析完成后,调用一次 export_analysis,传入 summary、root_causes 和 recommendations 以保存 Markdown 报告,然后告诉用户文件路径。",
)

构建智能体时,我们需要为它提供工具,以便它完成工作。对于这个应用,我创建了两个工具:

  • fetch_json_report:获取生成的 JSON 报告。它负责根据时间戳或时间段查找文件,并在单个响应中返回它们,按从最旧到最新排序。
  • export_analysis:将 AI 所做的分析保存到 Markdown 文件中。

有了这些,就可以调用智能体来运行分析:

def main() -> None:
    parser = argparse.ArgumentParser(description=用 AI 智能体分析不稳定的测试报告。)
    parser。add_argument("period", nargs="?", default="last_24_hours", choices=list(PERIODS),
                        help="要分析的报告周期(默认:%(default)s)")
    args = parser。parse_args()
    label = args。period。replace("_", " ")
    agent(f"总结来自 {label} 的不稳定测试报告(period={args.period}):有多少测试不稳定,哪些最严重?完成后,分析错误信息以推断不稳定的可能根本原因,并给出修复建议。然后用 export_analysis 工具创建报告文件,再告诉我写入的 Markdown 文件路径。"
          "how many tests are flaky and which are the worst? After finishing, analyze the error messages to infer the possible root causes of flakiness and give recommendations to fix them. And create the report file with export_analysis tool, then tell me the path of the Markdown file that was written.")


if __name__ == "__main__":
    main()

周期作为参数传入(例如,python agents/agent.py last_7_days),智能体会提供类似这样的报告:不稳定测试分析 - 最近 24 小时。

What now?

可以用它做什么

好,现在可以识别不稳定测试,甚至分析它们了。我能用它做什么?首先,你可以在团队中开始实施。监控你的测试并标记不稳定的测试,可以帮助提升团队对正在构建内容的信任、降低 CI 成本,并避免真正的 bug 因为测试在第二次或第三次执行时通过而被忽略。

应该什么时候运行它?这取决于你的团队如何工作:

  • 如果你的团队采用持续部署,每天或每周部署,那么每周运行一次会很有意义。如果你在较低环境中合并 PR 时遇到问题,它也能帮助理解发生了什么。
  • 如果你没有持续部署,每月部署一次,我的建议是在每次部署时运行它,始终比较最近的执行情况,看看数量是否增加。
  • AI 分析是可选的。你可以只运行评分和排名步骤,然后在一个周期、一个月甚至一个季度结束时,用智能体给你反馈。

限制与集成

  • 目前,JUnit XML 文件必须包含属性<property name="commit_sha" value=""/>(提交 id)和属性<property name="run_id" value=""/>。由于该方案是围绕 GitHub Actions 构建的,其他框架和流水线可能需要进行修改。当结果从 GitHub Actions 获取时(--repo),这两个值都取自 GitHub 运行本身。
  • 已经有一个与 GitHub Actions 集成的工作流(.github/workflows/flaky-report.yml)。它每周一运行,生成报告并将其作为 artifact 上传。如果你打算把它加入自己的工作流,可能需要做一些调整,而且被分析的仓库必须将其 JUnit XML 文件作为工作流 artifact 发布。
  • 对于 AI 模型,你可以设置自己喜欢的那个。记得检查模型提供商以使其适配你的上下文。
  • 另一个建议是与 Grafana 之类的报告工具集成,这样你就能看到不稳定测试在不同时间点的表现,以及它们是否在减少。

结论

不稳定测试不只是令人烦恼:它们会侵蚀团队对测试的信任,并掩盖真正的失败。度量它们是处理它们的第一步,而每个测试和每次提交一个简单的分数就足以起步。AI 分析让你初步了解可能的根本原因,但它不能替代人工审查结果,因为由基础设施引起的失败看起来也可能像是不稳定。试一试,看看你自己的流水线表现如何!