关于 fixing-flaky-tests
面向 PostHog 工程师的测试排查技能,用于调查和修复 CI 中的不稳定测试。它强调依据运行数据测量失败率、复现问题、解决根因,并通过多次运行验证修复。
主要功能
- 01根据 GitHub 运行数据测量失败率
- 02区分不稳定测试与确定性回归
- 03在类似 CI 的条件下复现故障
- 04从症状追查根本原因
- 05通过多次运行验证修复
适用场景
- 01调查 CI 中间歇性测试失败
- 02区分不稳定问题与确定性回归
- 03复现顺序或资源争用问题
- 04通过多次运行检查修复效果
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ posthog/ posthog --skill fixing-flaky-tests安装后在 Claude Code 中输入/fixing-flaky-tests确认已加载。
详细介绍
修复不稳定测试
在你提议更改 CI 中测试套件的运行方式之前,先查看已经尝试过的事项。其中记录了关于测试并行、分片和基于覆盖率的选取的实测结论,这样已被否决的方案就不会被重新构建。
三条不可妥协的原则,按顺序排列:
- 先复现,再修复。 为你从未观察到的失败所做的修复只是猜测。 只有在下面的升级阶梯用尽时,才退回到分析性修复。
- 修复根本原因。 休眠、提高超时、重试和弱化断言只是掩盖不稳定;它们并不能修复问题。
- 用 N 次运行循环进行验证。 一次绿色运行无法证明间歇性失败已被解决。 根据观察到的失败率来确定 N 的大小。
稳定测试并不是唯一有效的结局。 一旦你知道它为什么不稳定,第 5 步就会问它是否应该存在。 一个捕捉不到任何真实问题的测试值得删除,而一个因为运行层级而不稳定的测试则值得下移一级。
在做任何这些之前:先测量,不要假设。 不稳定还是确定性,以及失败率,都是要从可验证的 GitHub 运行数据中确立的事实(第 1 步)——绝不能继承自 Slack 警报、队友的猜测或 ci:insights 标签。
对于分诊红色 CI 运行(发现并分类失败),先使用 debugging-ci-failures 技能——一旦失败被分类为不稳定测试,本技能就会接手。
对于编写不会不稳定的新 Playwright 测试,请使用 playwright-test 技能。
investigating-ci-failures(绿/红边界)和 diagnosing-ci-and-merge-bottlenecks(engineering-analytics-flaky-tests 工具的注意事项)是位于 products/engineering_analytics/skills/ 下的产品技能,无法在此处调用:请阅读该路径下的 SKILL.md。
1. 从 GitHub 衡量失败率,而不是从摘要
GitHub Actions API(或 GitHub MCP)才是事实来源。hogli ci:insights 只是摘要,不是神谕——它可能把 flaky 和确定性问题标错,它会滞后于 API,直到 GitHub 的 webhook 稳定下来,而且它根本无法给出失败率:它报告的是绝对次数,因为 CI 只发出失败,却省略了普通的通过运行,所以没有分母。用它来 验证 假设或拉取历史上下文,绝不要把它当作第一步或分类权威。
自己从原始运行数据中建立时间线:
# Pass/fail history for the workflow on the branch under suspicion (master shown):
gh run list --repo PostHog/posthog --workflow=ci-backend.yml --branch master \
--status completed --limit 60 --json conclusion,headSha,createdAt,databaseId
仅看运行级别的结论还不够——一次红色运行可能失败在 不同的 job/测试上。要读取失败分片的日志,确认每次都是同一个测试;对于绿色运行,要确认该测试确实在那个分片中运行并通过(它可能被分到别处,而不是被修复了):
gh api repos/PostHog/posthog/actions/jobs/{job_id}/logs \
| grep -E "<exact::test::id>|short test summary"
在分类之前先读时间线:
- 相邻提交上通过/失败交错出现 —— 同一个未改动的测试在某些运行中验证通过,在另一些运行中失败 → 真正是 flaky;继续往下看。
- 一长串不间断的失败(比如连续 30+ 次)在统计上与 flake 不相容——在任何低于约 95% 的单次运行失败率下,
p^30 ≈ 0。那是 确定性回归:转到debugging-ci-failures并找到引入问题的提交(第 4 步)。 - 每次重跑都在同一个 runner 上失败,而
master上同样的代码却通过 → 这是 依赖环境的竞态,不是回归:内核时钟粒度、文件系统、runner 速度。在同一个 runner 上重试无济于事,所以“3/3 次尝试都失败”并不能说明 PR 是否导致了它。合并队列失败会免费给你控制权:trunk-merge/**测试 PR 的正文写明了它基于的 master SHA,所以读取该 SHA 上 master 的运行(gh api repos/PostHog/posthog/commits/<sha>/check-runs),并确认同一个测试在那里运行并通过。 - 两者同时出现很常见:一个潜伏的 flake,其失败率飙升到约 100%。找到转变点(最后一次绿色 → 第一次红色);那个边界,而不是摘要的一行结论,才能告诉你是什么触发了它。如果失败到达了 master,
products/engineering_analytics/skills/investigating-ci-failures/里有边界查询。
如果某个失败被报告为(或你怀疑它是)持续性的,不要串行处理——衡量失败率并并行尝试复现。
记录实测失败率(失败次数 / 总运行次数,来自运行数据)。你需要它来确定第 7 步验证循环的规模。
然后确认它是否已经被处理:
git log --oneline -10 -- <test_file_path> # recently fixed already?
hogli ci:insights search "<test name or error>" # cross-run history — corroborate against the run data, do not trust blindly
search 报告两个界面;阅读每个界面实际声称的内容:
- broken tests(最近的失败指纹,过去 2 天)。
potentially_resolved状态意味着该 job 在默认分支上的最新运行又变绿了——这是修复已落地的弱证据,不是证明。在报告“已修复”而不是重新修复之前,要_对照运行数据_确认它覆盖了这次失败。 - test health(按影响范围排序;
engineering-analytics-flaky-testsMCP 工具读取的是同一范围)。confirmed_flake是唯一有证据支持的分类:同一个提交在同一个矩阵 job 中既失败又通过了该测试,通过重跑尝试变绿或 job 内重试实现。在不同矩阵分支中通过不算恢复。suspected_regression意味着没有记录到恢复——这是缺乏证据,而不是回归的证据,所以在自己的运行数据另有说明之前,要把它当作真实回归对待。
用 Trunk Flaky Tests 进行佐证
CI 会把测试结果上传到 Trunk Flaky Tests,它会在 master 和 PR 运行中跟踪每个测试的失败历史。
在 PR 上,trunk-io 机器人的 "Trunk Test Analytics" 评论会链接到该 PR 的切片(https://app.trunk.io/posthog-inc/flaky-tests/pr/<number>?repo=PostHog/posthog)。
.mcp.json 中的 trunk MCP 服务会查询它(这些工具被 Trunk 标记为实验性):
search-test(repoName: "PostHog/posthog",testNameSearch: "<test name, no filepath>")→ 测试用例 ID。fix-flaky-test(repoName、testCaseId)→ 失败历史、首次出现的提交、git blame 以及 Trunk 的根因调查;createNewInvestigation: true会触发一次新的分析(最多需要一分钟)。
通过 /mcp → trunk 完成一次身份验证(浏览器 OAuth);无头环境则改为在服务条目中添加带 TRUNK_API_TOKEN 组织令牌的 Authorization: Bearer 头。
在开始之前,有两个限制值得了解。
该仓库未启用 AI 调查,因此 fix-flaky-test 只会返回历史或什么都不返回,绝不返回根因。
而且查找只能从名称到 ID:仅凭一个仪表盘链接无法确定测试的名称,所以要询问测试名称,而不是猜测 ID。
Trunk 通过 CODEOWNERS 将每个测试归属到某个团队,而 CODEOWNERS 无法表达 owners.yaml 的映射。
.github/scripts/trunk-codeowners.sh 会在每次上传前把该映射投影为一个生成的 CODEOWNERS(hogli owners:codeowners 会在本地构建同一个文件),因此测试在 Trunk 中的所有者应与 hogli owners:who 一致。
若不一致,说明投影丢弃了某个两个团队都会主张的拼写。
与 ci:insights 一样,这只是佐证和历史,而不是分类权威——不稳定还是确定性、以及比率,仍然来自上文的运行数据。
2. 从 CI 中提取失败信息
gh run view <run-id> --log-failed
# If the job was re-run and the latest attempt passed, the flaky failure
# lives in a previous attempt:
gh api "repos/PostHog/posthog/actions/runs/{run_id}/attempts/1/jobs"
gh api "repos/PostHog/posthog/actions/jobs/{job_id}/logs"
在继续之前先记录:
- 确切的测试 ID(文件路径 + 测试名称)以及失败的断言或错误。
- 周围的警告——
[MSW] Unhandled、异步泄漏警告、teardown 错误——这些往往才是真正的原因,会打印在症状之前。 - 在同一 worker/分片中、它之前还运行了哪些其他测试(顺序可疑项)。
3. 在本地复现——先不要改动任何东西
沿着以下条件逐级升级,直到失败出现。 在第一个能复现的层级停下;该层级就是你第 7 步的验证环境。
-
单次运行:
hogli test <path>::<test>——确认该测试至少能跑起来。 -
重复循环(默认 N=20):捕获概率性 flake。
-
类 CI 条件:CI 在资源紧张的 runner 上以低 worker 数分片运行 Jest——在运行前先从
frontend/package.json的test脚本和.github/workflows/ci-frontend.yml中读取当前的 flags。 示例(使用撰写本文时的 flags),将测试与其分片邻居一起运行:pnpm --filter=@posthog/frontend jest <test_file> <neighbor_file> --maxWorkers=2 --forceExit对于 pytest,要运行整个文件或类,而不是单个测试,这样模块级 fixture 和顺序才能与 CI 一致。
-
顺序:先运行疑似污染源的测试,再运行受害测试;并在文件内反转顺序。 在隔离运行时消失的 flake 是顺序 bug。
-
资源争用:在另一个 shell 中运行 CPU 密集型任务(例如并行运行整个文件的 jest)的同时,重跑该循环。 超时类 flake 往往只在这种情况下出现。
循环 harness——依据退出码判断,而不是通过 grep 输出:
N=20; PASS=0; FAIL=0
for i in $(seq 1 $N); do
if <test command> >/tmp/flake-run.log 2>&1; then
PASS=$((PASS+1))
else
FAIL=$((FAIL+1)); cp /tmp/flake-run.log /tmp/flake-fail-$i.log; echo "run $i: FAIL"
fi
done
echo "$PASS passed, $FAIL failed out of $N"
关于循环的两点成本说明:
- 复现期间,在第一次失败后即
break——一份捕获到的失败日志就够了。 只有在测量失败率或在第 7 步验证时,才完整跑完所有 N 次。 pnpm --filter=@posthog/frontend jest脚本在每次调用前都会运行pnpm build:products。 在循环内部,先构建一次,然后用pnpm --filter=@posthog/frontend exec jest ...迭代,这样会跳过重新构建。
如果走完整个阶梯后仍无法复现,那么这个 flake 是 CI 环境特有的。
在就此定论之前,先问一问 runner 有哪些你机器上没有的东西:Linux 来自粗粒度时钟的 mtime,而 macOS 提供的是纳秒级;大小写敏感的文件系统;不同的时区;/tmp 位于不同的文件系统。
通常你可以强制制造出 CI 条件,而不是等它出现——用 os.utime 把文件调整到粗粒度时钟所产生的顺序,在 TZ=UTC 下运行——然后再凭经验验证。
否则,就基于 CI 证据和根因分析继续修复,并在报告中明确说明——此时第 7 步的验证是分析性的,而非经验性的。
4. 对 flake 做根因分析
将症状与原因类别匹配;绝不要只修补症状。
| 症状 | 可能的原因类别 |
|---|---|
| 等待 promise/listener/元素时超时 | 未 await 的异步工作、缺失的 mock、隐藏的未决请求 |
| 单独运行通过,与邻居一起失败(或反之) | 共享状态:模块缓存、DB 行、全局配置、顺序 |
| 在临近午夜/UTC 边界时失败,或在慢 runner 上失败 | 使用了真实时钟——缺少 time_machine.travel / fake timers |
| 对列表顺序或生成的 ID 做断言 | 将非确定性的顺序/ID 断言为确定性 |
| 查询看不到刚写入的数据 | 最终一致性(ClickHouse)、缺少 flush/commit |
仅在 --maxWorkers=2 / 争用下失败 | 由调度暴露的竞态条件、过于紧张的超时 |
| 每次尝试都在某个 runner 上失败,在另一个上通过 | 时钟粒度竞态:从更早的写入读取截止时间 |
当原因不明显时,用二分法定位
如果症状表没有指向明确的原因,且测试文件本身没有变化(git log -- <test_file> 没有新提交),那么触发点就在别处——邻近的测试、依赖升级或产品变更。与其猜测,不如找出它_何时_开始:
-
先对 CI 运行历史做二分(成本低,无需本地构建):根据第 1 步的时间线,找到最后一次通过 → 第一次失败的边界,并对该窗口内的提交做 diff(
git log <good>..<bad>)。这份简短的列表往往能直接指出罪魁祸首。 -
在本地能复现且失败是(近乎)确定性的时候,对代码做
git bisect:git bisect start <bad-sha> <good-sha> git bisect run bash -c '<repro command>' # exit 0 = good, non-zero = bad注意:对于间歇性 flaky 测试,某一步的_侥幸通过_会把
git bisect引向错误的方向。只有在失败是确定性的时候才信任代码二分;否则每一步都要运行复现 N 次(只要任意一次迭代失败就算失败),或者干脆使用 CI 运行历史的边界。
PostHog 特有的模式:
前端(Jest + kea)
- 缺少 MSW mock:带有
afterMountloader 的 kea logic 会通过connect()链触发 API 调用——三层之深的 logic 也可能触发一个未被 mock 的 fetch。 未处理的请求目前会以无害的空分页 200 返回,因此症状是 loader 成功但数据为空或错误,而不是网络错误。 日志中的[MSW] Unhandled GET ...警告会指出缺失的 mock——补上useMocks条目即可。 未处理请求的行为此前发生过变化(以前会挂起);如果症状对不上,请阅读frontend/src/mocks/jest.ts了解当前未 mock 请求的行为。 toFinishAllListeners()超时:会等待所有已挂载 logic 上的全部 kea listener promise(默认 3 秒——在kea-test-utils中的LISTENER_FINISH_WAIT_TIMEOUT)。 任何已连接且带有未完成 loader 的 logic 都会阻塞它。 修复未完成的工作;不要调高超时时间。- Mock URL 不匹配:
mocksToHandlers会去除末尾斜杠,但查询参数、@current风格的片段以及:param模式必须与真实请求 URL 匹配。 与[MSW] Unhandled那一行做对比。 - 每个测试的 handler 重置:
frontend/src/mocks/jest.ts注册了一个全局的afterEach(() => mswServer.resetHandlers())。 每个it.each用例都是独立的测试,因此运行时 mock 必须在beforeEach中(重新)注册。 - 挂载泄漏:在
beforeEach中挂载却从未卸载的 logic 会把异步工作泄漏到后续测试中。
后端(pytest)
- 数据库状态泄漏:测试之间共享行而没有隔离——检查 fixture 作用域,以及测试是否需要
@pytest.mark.django_db(transaction=True)。 - 真实时间:使用
time_machine.travel(..., tick=False);永远不要对由now()派生的值做断言。 - 由时间戳派生的时间界限:以先前写入记录的时间为基准来固定或过滤 "as of"——应改为显式记录时间(
/writing-tests,"Two writes in a row are not ordered in time")。 - ClickHouse 最终一致性:查询可能看不到刚刚插入的数据——在测试 setup 中显式 flush,而不是 sleep。
5. 决定结果——修复只是三种选择之一
一旦你知道了它为什么会偶发失败,就要在花力气去稳定它之前,先问这个测试是否应该存在。
偶发失败的测试是唯一一种成本已被证明、而价值未被证明的情况:它已经确凿地消耗了重跑次数、实际时间和注意力。
因此,要追溯性地套用 /writing-tests 的关卡,而且力度要比对待新测试更大:
这个测试能捕获到哪个现实中的回归,而现有测试都捕获不到?
有三种结果都是有效的。要有意识地选择;不要默认选第一个。
| 结果 | 何时适用 | 下一步 |
|---|---|---|
| 修复它 | 以大致合适的成本守护一个真实的回归。 | 第 6 步 |
| 调整它的层级 | 值得守护,但偶发失败是它运行所在层级固有的。 | 见下文 |
| 删除它 | 你说不出它捕获的是什么回归,或者另一个测试已经捕获了它。 | 见下文 |
删除它
以下几类反复出现的形态无法通过关卡:
- 同义反复的冒烟测试。 断言的是同一文件中许多同类测试本来就需要成立才能运行的前提条件——图表已渲染、列表非空。 后面每个测试都是它更强的版本。
- 重复覆盖。 对现有测试已经覆盖的路径再写一个更单薄的测试。 把它合并为参数化用例,或者删掉。
- 第三方断言。 偶发失败是因为它测试的是供应商的最终一致性、调度器或 API,而不是我们的逻辑。 那是供应商该写的测试,而基于 mock 的同类测试通常已经覆盖了我们这一侧。
- 永久被门控。 除 CI 外处处被跳过(缺少凭据、需主动开启的标记),因此没人在开发时依赖它,只有 CI 为它付出代价。
删除不可逆,所以它的门槛比修复更高:
- 取得用户明确批准,与隔离所要求的关卡相同。 提出删除方案并交回;不要自己拍板。
- 交代覆盖情况:指出还有什么在 file:line 处捕获了这一点,或者明确说明失去了什么以及为什么这可以接受。 “大概在别处覆盖了”不是答案——去读那个同类测试。
- 绝不要为了在时间压力下把红色构建变绿而删除。 那不过是多几步的隔离,真实的回归就是这样被发布出去的。
然后跳到第 8 步:这里没有可循环的东西。
调整它的层级
把测试沿着 /writing-tests 的成本阶梯往下移,而不是在原地加固它。
为了验证一次直接调用就能验证的逻辑,而绕经真实 broker、浏览器或供应商 API,这是在测试传输层,而传输层正是不确定性的所在。
调整层级从构造上消除了偶发失败,所以优先选择它,而不是越来越复杂的等待。
替代者是一个新测试:对它套用 /writing-tests 的关卡,并在它新的层级上验证它,而不是对照已不复存在的旧复现条件。
6. 修复根因——绝不掩盖它
| 诱人的掩盖做法 | 应改为 |
|---|---|
在断言前加 sleep(2) / setTimeout | 等待特定条件(waitFor、expectLogic、显式 flush) |
| 调高测试/监听器超时 | 找出卡住的地方;超时只是信使 |
添加重试(pytest-rerunfailures 的 --reruns、jest.retryTimes) | 仅用于真正不确定的外部基础设施,并附上注释和关联 issue——绝不用于被测的产品代码 |
| 跳过 / 隔离测试 | 仅在用户明确批准时,并附上关联 issue |
| 放宽断言 | 让数据确定化(排序、冻结、设定种子),保持断言严格 |
| 强化一个本就不该存在的测试 | 回到第 5 步——删除它或重新定级是更省事的修复 |
尽可能让修复保持最小,并且限定在测试或其 fixture 内。 如果竞态出在产品代码里,这个 flake 就发现了一个真实 bug——修复产品代码,并在报告中说明。
7. 用 N 次运行循环验证
在修复后的代码上,在与复现失败相同的条件下(相同的邻近测试、worker 数量、争用情况)运行第 3 步的测试装置。
根据修复前观察到的失败率来确定 N:如果大约每 k 次运行失败 1 次,你需要大约 N ≥ 3k 次连续通过,才有约 95% 的置信度认为该 flake 已消除——(1 - 1/k)^(3k) ≈ 5%。
因此使用 N = max(3k, 20)。
如果没有可用的失败率估计,运行 50 次,并在报告中注明置信度有所降低。
如果该 flake 在本地从未能复现,运行 N = 20 作为回归检查,并将该验证标注为分析性验证。
循环中任何一次失败 → 回到第 4 步;根因找错了或不完整。 最后正常运行一次周边文件/测试套件,确认修复没有破坏同级测试。
改为重新定级了?旧的复现条件不再适用。 在其自身层级上循环运行替代测试,并确认 flake 消失是因为旧测试被移除,而不是因为它变快了。
改为删除了?没有可循环运行的对象。 运行一次周边文件/测试套件,确认没有东西依赖它,并把覆盖率的论证写进报告。
8. 报告
Test: <file path>::<test name>
Observed in CI: <measured rate from run data, e.g. 8/45 runs over 3h (gh run list); ci:insights state corroborates>
Local repro: <command + conditions, e.g. 3/20 failures with neighbor X, maxWorkers=2 | not reproducible locally>
Root cause: <one or two sentences>
Outcome: fixed | re-leveled (<from> → <to>) | deleted
Change: <what changed and why it removes the cause; for a deletion, what still covers the behavior (file:line) and what coverage is genuinely lost>
Validation: <N>/<N> passes under repro conditions | <N>/<N> at the new level | analytical only (CI-specific) | n/a, deleted
Follow-ups: <product bug found, related tests with the same pattern, or none>
边界
- 不要为了“测试”修复而重跑 CI 任务、推送或向 GitHub 发帖——在本地验证。
- 不要在修复 flake 的过程中编辑
.github/workflows/。 - 不要为了让 flake 通过而接受/更新快照。
- 如果同一根因模式明显影响同级测试,只有在修复是机械性的情况下才在同一次变更中一并修复;否则将其列为后续事项。
- 未经用户明确批准,不要删除测试,并且要么为覆盖率指定一个替代测试,要么明确说明会失去什么。 删除是一个有效的结果,绝不是通往绿灯的捷径。