mcpField
B
bug-reproduction

bug-reproduction

github.com/petrkindlmann测试与质量2221.4k 次安装6 天前更新
代码仓库

关于 bug-reproduction

bug-reproduction 是帮助工程与 QA 团队复现并验证缺陷的技能,将模糊报告转化为精简、确定性的复现步骤和回归测试。它还指导用 git bisect 定位引入缺陷的提交,并通过撤销修复验证测试有效。

主要功能

  1. 01从报告提取复现条件
  2. 02逐步缩减并隔离复现路径
  3. 03用 git bisect 定位引入提交
  4. 04固定时间、随机数和网络
  5. 05修复前编写失败的回归测试
  6. 06通过撤销修复验证测试

适用场景

  1. 01将模糊缺陷报告变为可执行复现
  2. 02定位过去正常、当前失败的变更
  3. 03处理时间或网络导致的不稳定复现
  4. 04确认修复确实解决缺陷

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/petrkindlmann/qa-skills --skill bug-reproduction

安装后在 Claude Code 中输入/bug-reproduction确认已加载。

包含的文件

除 SKILL.md 外,安装时一起下载的参考资料和脚本。

references/
bisect.md
determinism.md
intake.md
ticket-writeback.md

详细介绍

无法复现的 Bug,就无法修复,也无法证明已修复。这项技能会将一条含糊简短的报告(“订单总额有时不对”)逐步变成一个已验证的最小复现、一条在修复前编写的确定性失败测试、一次能指出引入该问题的提交的 `git bisect`,以及工单中的结构化证据块。它所贯彻的原则是:先复现,再提出假设;每次只做一步简化;固定时间、随机种子和网络环境,确保每次运行的复现结果都一致;先确认测试变红,再确认修复能让测试变绿——并且回退修复后,测试会再次变红。

快速导航

你现在有……前往
一条简短的报告,不知道如何触发问题步骤 1:提取隐含的复现步骤
一份有 14 步、需要精简的繁杂复现流程步骤 2:复现、精简、隔离、记录循环
“上个月还能用,现在坏了”步骤 3:二分定位引入问题的提交
复现结果时过时不过步骤 4:让复现具有确定性
复现流程清晰,但还没有修复步骤 5:先编写失败的回归测试(红)
“开发者说已经修复,测试也通过了”步骤 6:验证修复是否确实解决了问题
“我这里无法复现,但用户那里可以”步骤 7:区分不稳定、环境问题与无法复现
已完成复现和测试步骤 8:将证据写回工单

排查问题

首先,查看项目根目录中的 .agents/qa-project-context.md——其中记录了技术栈、测试运行器、已知的不稳定区域和环境矩阵。跳过其中已有答案的问题。如果该文件不存在,建议使用 qa-project-context 技能创建。

  • 报告的原文是什么? 报告越简短,在修改代码前就越需要提取更多信息——一行报告就决定了整个问题受理流程(步骤 1)。
  • 目前能否复现?复现结果有多稳定? “每次都能”还是“偶尔能”决定了你是直接开始精简,还是先处理确定性问题。
  • 之前是否正常工作过? 已知的历史正常版本有助于通过 git bisect 找出引入问题的提交;如果没有已知的正常版本,就需要向前调试。
  • 使用什么测试运行器和技术栈? Vitest/Jest 与 Playwright 会采用不同的确定性 API(vi.setSystemTime 与 page.clock),回归测试的编写位置也会不同。
  • 有哪些非确定性来源? 时间规则、随机性、第三方 API、本地化设置——都必须固定,才能确保复现可信。

核心原则

  1. 先复现,再推测。 最容易犯的错误是看到一个症状就跳到根本原因(“听起来像是浮点数舍入错误,我来修补总额计算”)。不要这样做。提取复现步骤,让问题能按需出现,然后再提出假设。没有复现的修复只是猜测,无法证伪。

  2. 复现是确定性的产物,不是一个故事。“随机代码在午夜前后会出问题”是一个故事。固定时钟、设置随机数生成器的种子,并对网络进行桩接,这样相同的输入就能在每次运行、每台机器上产生相同的故障。如果结果不确定,就无法对问题进行二分查找、测试,也无法证明问题已修复。

  3. 每次只减少一个变量,并在每次删减后重新确认。 缩小复现范围是一个搜索过程,不是重写。每次移除一个步骤、数据字段或依赖,然后重新运行并确认问题仍能复现。一次移除多个内容,你就无法知道究竟哪个因素起了作用。

  4. 先以失败状态编写回归测试,再修复。 断言真正的预期值,先观察测试失败(证明它能捕获这个错误),再观察修复使测试通过。修复后才添加的测试,或被禁用/标记为待处理的测试,都无法证明任何问题。

  5. 通过测试还不算完成——撤销修复进行验证才算。 测试通过的原因可能不对。暂时移除修复,确认测试再次失败。只有在没有修复时会失败的测试,才能真正防止这个错误再次出现。


步骤 1:提取隐含的复现步骤

一条简单的报告(“结账坏了,订单总额有时不对”)描述的是症状,而不是操作路径。在编写任何代码之前,提取或询问所有可复现性要素。切勿凭空编造复现步骤,也不要推测根本原因——只有在问题复现之后,才能做这两件事。

对于“总额错误”/数据正确性错误,简单报告最常遗漏、但至关重要的要素包括:

  • 精确的复现步骤 —— 逐步点击的操作路径,而不是“结账坏了”。
  • 构建版本/版本号/提交(git SHA) —— 他们使用的构建版本可能已经修复了这个问题。
  • 环境 —— 浏览器及其版本、操作系统、设备。
  • 输入数据 —— 购物车内容、数量、账户/用户、优惠券、精确的测试数据。总额是输入的纯函数;没有这些信息,你只能靠猜。
  • 预期值与实际值 —— 他们预期的数字,以及实际看到的数字。
  • 发生频率 —— 每次都会发生,还是间歇性发生?“有时”往往意味着存在非确定性。
  • 区域设置/时区/货币 —— 舍入、税费和格式因区域设置而异;在 de-DE 中“错误”的总额,在 en-US 中可能是正确的。
  • 发生时间戳,以及任何日志/屏幕截图/网络跟踪记录。

在接触代码之前,先将这些信息写入一份复现规范。如果某一项为空,那就是你接下来要向报告者提出的问题——而不是开始编写修复方案或推测原因的许可。

参阅 references/intake.md,了解完整的提取清单、每个关键要素对“总额错误”问题的重要性,以及复现规范模板。


步骤 2:复现→精简→隔离→记录循环

对于已确认但杂乱的复现流程(例如跨 3 个页面的 14 个手动 UI 步骤),不要把 14 步版本交给开发者,也不要彻底重写。请运行以下循环:

  1. 复现 / 确认。 首先建立基线:运行完整的复现流程,并确认它确实会失败。只有当前能够复现的问题,才可以精简。
  2. 精简。 移除一个步骤、字段或依赖项,然后重新运行。如果仍能复现,就保留这项删减;如果不再失败,则该元素是必要条件——将它恢复。每次只改变一个变量,重复此过程,直到每个剩余部分都是必要的。这是核心顺序规则:未确认问题能够复现前,绝不进行精简;每次删减后,也绝不省略验证问题是否仍会失败。
  3. 隔离。 将故障范围缩小到仍能体现问题的最小层级——从跨 3 个页面的 UI 流程缩小到单个页面,再缩小到针对出问题函数的单元测试 / API 调用(如果 Bug 出在 UI 以下的层级)。
  4. 记录。 将现在这个最小化复现流程作为证据记录下来:最少的步骤或单条命令,以及日志 / 跟踪记录 / 截图。开发者和回归测试都会使用这些内容。

最终产物是仍能复现问题的最短步骤序列,而不是原始操作流程。


步骤 3:二分查找引入问题的提交

Bug 出现在 HEAD,但过去的某个版本发布时没有问题,并且你有一条在 Bug 存在时会以非零状态退出的命令。使用 git bisect run 自动对历史记录进行二分查找——不要手动逐个检出提交,也不要用 git revert 来搜寻。

git bisect start
git bisect bad HEAD          # current commit has the bug   (alias: git bisect new)
git bisect good v2.4.0       # last clean release            (alias: git bisect old)
git bisect run npm test -- checkout-total.spec.ts   # ONE targeted test, never the full suite
# bisect prints "<sha> is the first bad commit"
git bisect reset             # ALWAYS clean up — restores the original HEAD

git bisect run 使用的退出码约定:退出码 0 = 正常(不存在 Bug),非零(1–124)= 异常(存在 Bug),退出码 125 = 跳过(无法测试)。因此,你的命令必须在功能正常时返回 0,在 Bug 可复现时返回非零——大多数测试运行器已经符合此约定。运行一个有针对性的测试,不要运行 npm test:all / 整个测试套件:较早提交中的无关失败会将该提交标记为异常,并把搜索带到错误的一半。

good / bad 组合用于假设这是一次回归(过去正常,现在异常)。old / new 别名代表相同的搜索方式,在排查任意状态转换时更易读。

完整的顺利流程以及跳过 / 无法测试的包装脚本,请参阅 references/bisect.md。

跳过二分查找以及确保确定性(无法测试或结果不稳定的提交)

两件事会破坏简单的二分查找,而默认的异常处理方式——“任何失败都退出 1”——会同时踩中这两个问题:

  • 较早的提交无法构建。 编译错误会以 1 退出,二分查找会将其解读为“存在 Bug”,并把一个正常的提交标记为异常。这是错误的:无法构建的提交属于无法测试——包装脚本必须在构建失败时执行 exit 125(跳过),以区别于真正异常的提交。
  • 网络 / 时间问题导致的偶发失败。 临时发生的、未 stub 的第三方调用会以 1 退出,并被归咎于该提交。在二分查找期间强制确保确定性——stub 网络、固定 TZ、为随机数生成器设置种子——这样只有真正的 Bug 才会导致步骤失败。如果某个提交的结果在多次运行之间发生变化,就将其视为无法测试(exit 125),而不是异常。

请用脚本包装该步骤,使其返回 0 = 正常、1 = 异常、125 = 跳过(有效的异常范围是 1–127,但不包括 125),并在构建前进行检查、stub 网络,再重试一次以捕获不稳定问题。然后运行 git bisect run ./bisect-step.sh。完整的包装脚本见 references/bisect.md。


步骤 4:让复现过程具有确定性

这个 bug「只会在午夜前后出现,使用随机折扣码,并通过第三方定价 API」 有三个不确定性来源。将这三者全部固定,让它每次运行都以相同方式失败——不用等到午夜,不要调用真实的定价 API,也绝不要用 sleep/setTimeout/waitForTimeout 来掩盖时序问题。

来源VitestPlaywright
时间vi.useFakeTimers() + vi.setSystemTime(new Date('…'))page.clock.install({ time }) + page.clock.setFixedTime(…)
随机性faker.seed(1337)(或模拟 Math.random)通过初始化钩子为应用的 RNG 设置种子
网络MSW setupServer + http.get → HttpResponse.jsonpage.route(...) → route.fulfill(...)

要点:

  • Vitest: 只有在调用 vi.useFakeTimers() 后,vi.setSystemTime 才会生效。在 beforeEach 中为 faker 设置种子。设置 MSW 的 onUnhandledRequest: 'error',这样遗漏的模拟请求就会明确报错。
  • Playwright: 必须在 page.goto 之前调用 page.clock.install/setFixedTime。 page.clock 是受支持的 API——它确实存在;不要退而使用手写的 Date 覆盖,也不要通过增加超时时间来「让测试通过」。
  • 如果 bug 与区域设置有关,请固定区域设置/时区/货币(TZ=UTC、LANG)。

避免: jest.useFakeTimers('legacy')(以及 timers: 'legacy')——旧版假定时器已弃用,且不会模拟 Date/Date.now,因此时钟仍会继续走动,你的「冻结」复现仍会漂移。从 Jest 27 开始,现代定时器就是默认设置——只需调用 jest.useFakeTimers() + jest.setSystemTime(),或在 Vitest 中调用 vi.useFakeTimers()。(Jest 30, 2025)

参见 references/determinism.md,了解完整的 Vitest(vi.useFakeTimers + vi.setSystemTime

  • faker.seed + MSW setupServer)和 Playwright(page.clock + page.route)配方, 以及 10 次运行的确定性检查。

步骤 5:先编写失败的回归测试(红灯)

你已经获得了干净且确定的复现过程,而开发者还没有修复它。现在就以针对 bug 的 TDD 方式,在修复之前编写回归测试:

  1. 将最小复现过程编成测试,并断言真正的预期值—— expect(total).toBe(2754)。永远通过的同义反复断言毫无证明作用。
  2. 运行测试并确认它在修复前失败——必须先是红灯。无法变红的测试并没有触发这个 bug。
  3. 提交测试(或将其暂存到修复分支),让测试在 CI 中保护这项修复。
  4. 开发者提交修复后,重新运行测试:它应该转为绿灯。同一个测试,不做任何修改。

预期状态:修复前红灯,修复后绿灯。

会违背目的的错误做法——针对确实要等到这个 bug 修复后才会通过的测试:等修复已经合并后才编写测试;为了让 CI 保持绿色而禁用失败的测试(添加待处理标记、使用永远为真的同义反复,或移除断言);或者先修复,再事后补上测试。保持断言生效,让测试变红。

参见 references/determinism.md,了解承载断言的确定性测试主体。


步骤 6:验证修复确实有效

开发者提交了修复,现在回归测试通过了。通过是必要条件,但还不够——测试可能因为无关原因而通过。不要只看到通过就关闭,也不要轻信开发者的说法。请运行有效性检查:

  1. 暂时撤销修复(将其暂存,或使用 git stash/注释掉修复行),然后重新运行测试。确认没有修复时测试仍然失败。这能证明测试确实覆盖了这个 Bug(测试能捕获这个 Bug)——测试是因为修复才通过,而不是因为其他与缺陷无关的原因。
  2. 恢复修复,确认测试重新通过。
  3. 多次确定性地重新运行(使用 --repeat-each/循环),确认通过结果稳定,而不是侥幸通过。

只有在没有修复时测试失败、有修复时测试通过,且多次重复结果一致,才能确认修复有效。撤销后验证这一步正是回归测试的核心目的,也最容易被遗漏。


步骤 7:区分不稳定、环境相关和无法复现

你花了两个小时,但自己无法复现,而用户遇到的问题显然确实存在。不要立刻以“无法复现”为由关闭,不要认定是偶发问题就将其隔离,也不要断定“我的机器上无法复现,所以问题不是真的”。这是三种不同的诊断,各自需要不同的证据:

诊断区分性证据应采取的措施
不稳定代码和环境相同,同一提交上既通过也失败——在同一环境中运行 --repeat-each 50(或多次重复运行相同命令),观察结果是否反复变化找出非确定性因素(时间/RNG/网络/竞态),让结果变得确定(步骤 4)
环境相关仅在不同配置下复现——时区、区域设置、视口、操作系统、浏览器版本、CI 与本地环境——且在该配置下结果稳定匹配用户的环境:复现用户的时区/区域设置/操作系统/浏览器环境,然后逐步缩小范围
数据相关仅使用用户的特定账号/输入时才会复现获取并复现用户的数据/测试夹具;Bug 与输入相关,而非平台
确实无法复现匹配环境和数据并多次运行后,以上情况均无法复现记录你尝试过的内容(环境、运行次数、数据)和未复现的结果——不要悄悄关闭

容易被遗漏的一步是:先匹配报告中的用户环境和数据再下结论——复现用户的配置(时区、区域设置、操作系统、浏览器版本)和环境,然后重新运行。在相同环境中使用 --repeat-each 可以隔离出真正的不稳定问题;跨环境结果不一致则指向环境相关问题;对输入的依赖则指向数据相关问题。


步骤 8:将证据写回工单

复现和已提交的回归测试都已完成。将含糊的原始报告替换为结构化内容——不要粘贴原始的 14 步 UI 操作流程,也不要只写“已复现,关闭”。请包含以下七项:

  1. 最简步骤/复现命令——最短复现路径,而非完整操作流程。
  2. 环境与构建版本/提交——准确的 SHA 和平台。
  3. 预期结果与实际结果——具体数值。
  4. 引入问题的提交——通过 git bisect 找出的相关提交。
  5. 回归测试——已提交的测试文件及路径链接。
  6. 证据——日志、截图、跟踪记录或构件。
  7. 确定性说明——种子、冻结时间和存根,以便任何人都能以相同方式重新运行。

请参阅 references/ticket-writeback.md,查看可直接复制粘贴的 Markdown 结构。


反模式

1. 还没复现就着手修复

看到“总数不对”就修改总数计算,或在尚未能按需触发问题前就猜测“看起来像舍入错误”。无法证明一个未复现问题的修复确实有效。先提取复现步骤(步骤 1)。

2. 未确认复现就开始精简

精简一段从未确认确实会失败的复现步骤。最终你“精简”的可能是一个原本就没坏的东西。先确认基线会失败,然后再删减。

3. 一次删除多个变量

一次删掉三个步骤,结果停止复现时,你不知道是哪一步起了作用。每次只删除一个变量,并在每次删除后重新运行。

4. 对整个测试套件或手动进行二分查找

git bisect run npm test:all 会让无关失败将提交标记为坏;手动切换提交并测试则既慢又容易出错。在 git bisect run 下运行一个有针对性的测试。

5. 二分查找期间构建失败时返回 1

将无法构建的提交视为“存在 bug”。这会把正常提交标记为坏,破坏搜索结果。对无法测试的提交返回 exit 125(跳过);仅对确实存在 bug 的情况返回非零值。

6. 复现时使用实时系统时间、随机数或网络

不模拟 new Date() 和 Math.random,调用真实的定价 API,或通过休眠“等到午夜”。这样一来,复现结果就像抛硬币一样不确定。冻结时间、设定 RNG 种子,并为网络请求提供桩(步骤 4)。

7. 修复后才编写测试,或禁用测试

等 bug 已经消失后才添加回归测试,或者弱化失败的测试(添加待处理标记、使用恒真断言、删除断言)来保持 CI 通过。这样的测试永远无法证明自己能捕获 bug。要在修复前先编写一个会失败的测试。

8. 测试通过就结束,没有通过还原进行验证

“测试通过了,关闭吧。”测试可能因为错误的原因而通过。还原修复,确认测试再次失败;然后恢复修复,确认测试通过。

9. 第一次无法复现就以“无法复现”结案

把间歇性失败、环境特定和无法复现一概当作同一种情况处理。匹配用户的环境和数据,并在下结论前使用 --repeat-each(步骤 7)。


失败模式

症状可能原因修复 / 检查
二分查找落在明显无关的提交上整个测试套件或间歇性失败将提交标记为坏改用一个有针对性的测试;使用返回 exit-125 跳过的包装命令,并为网络请求提供桩
复现在本地通过,在 CI 失败(或反之)环境特定(TZ、区域设置、操作系统、浏览器)固定 TZ/LANG;匹配发生失败的环境(步骤 7)
测试通过了,但不确定它能否捕获 bug从未运行过会失败的测试还原修复并确认测试失败(步骤 6)
vi.setSystemTime 没有作用在 vi.useFakeTimers() 之前调用先调用 useFakeTimers()
page.clock 的时间未应用于应用启动在 page.goto 之后运行了 install/setFixedTime在导航前设置时钟
复现有时通过、有时失败未固定实时系统时间、RNG 或网络执行步骤 4;使用 --repeat-each 10 确认

验证

  • 复现命令/测试可按需稳定失败:运行 10 次(使用 --repeat-each 10 或循环),并确认在修复前每次都会失败。
  • git bisect run … 以“<sha> is the first bad commit”结束,且 git bisect reset 会让你回到原始的 HEAD。
  • 还原修复后,回归测试返回非零值;应用修复后,返回 0。
  • 工单内容包含全部七项回写内容(步骤 8)——用 grep 检查其中是否有提交 SHA、测试路径和确定性说明。

完成标准

  • 已记录最小化复现步骤——最少步骤或一条命令——并验证它能按需触发失败,且在多次运行中均会失败。
  • 如果这是回归问题,git bisect 已定位引入问题的提交 SHA,并将其记录在工单中。
  • 复现具有确定性:冻结时间、固定 RNG 种子、对网络调用进行存根处理——已通过连续 10 次运行结果完全一致加以验证。
  • 已提交回归测试,该测试在修复前失败、修复后通过,并已确认撤销修复后测试会失败。
  • 工单包含结构化证据块,涵盖全部七项内容(最小步骤、环境/构建、预期结果与实际结果、引入问题的提交、回归测试链接、证据产物、确定性说明);原先含糊的报告已被替换,而非保留原样。
  • 如果问题未能复现,已根据导致该结论的证据将其归类(间歇性 / 特定环境 / 数据依赖 / 无法复现),绝不悄无声息地关闭工单。

相关技能

  • ai-bug-triage — 对已有故障进行分类、去重和严重性分流。分流决定故障是否重要以及应归属何处;使用本技能实际复现故障并编写失败测试。分流完成后移交,本技能接手。
  • ai-test-generation — 根据规格 / PRD / 用户故事生成测试。来源是需求时使用此技能;来源是缺陷,且测试必须先针对该缺陷失败时,使用本技能。
  • test-reliability — 对间歇性测试进行运行时自愈和隔离。当第 7 步确认是真正的间歇性问题时,转到该技能进行稳定处理或隔离;本技能只负责诊断。
  • qa-project-context — 技术栈、测试运行器、环境矩阵以及已知的间歇性问题区域,这些都会影响上述每个步骤。请先查看。
  • systematic-debugging(superpowers:systematic-debugging)— 获得确定性复现后进行根因调试的通用循环;本技能负责产出该复现以及用于守护最终修复的失败测试。

评论

支持 Markdown