关于 bug-reproduction
bug-reproduction 是帮助工程与 QA 团队复现并验证缺陷的技能,将模糊报告转化为精简、确定性的复现步骤和回归测试。它还指导用 git bisect 定位引入缺陷的提交,并通过撤销修复验证测试有效。
主要功能
- 01从报告提取复现条件
- 02逐步缩减并隔离复现路径
- 03用 git bisect 定位引入提交
- 04固定时间、随机数和网络
- 05修复前编写失败的回归测试
- 06通过撤销修复验证测试
适用场景
- 01将模糊缺陷报告变为可执行复现
- 02定位过去正常、当前失败的变更
- 03处理时间或网络导致的不稳定复现
- 04确认修复确实解决缺陷
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ petrkindlmann/ qa-skills --skill bug-reproduction安装后在 Claude Code 中输入/bug-reproduction确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
references/bisect.mddeterminism.mdintake.mdticket-writeback.md详细介绍
快速导航
| 你现在有…… | 前往 |
|---|---|
| 一条简短的报告,不知道如何触发问题 | 步骤 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:提取隐含的复现步骤
一条简单的报告(“结账坏了,订单总额有时不对”)描述的是症状,而不是操作路径。在编写任何代码之前,提取或询问所有可复现性要素。切勿凭空编造复现步骤,也不要推测根本原因——只有在问题复现之后,才能做这两件事。
对于“总额错误”/数据正确性错误,简单报告最常遗漏、但至关重要的要素包括:
- 精确的复现步骤 —— 逐步点击的操作路径,而不是“结账坏了”。
- 构建版本/版本号/提交(git SHA) —— 他们使用的构建版本可能已经修复了这个问题。
- 环境 —— 浏览器及其版本、操作系统、设备。
- 输入数据 —— 购物车内容、数量、账户/用户、优惠券、精确的测试数据。总额是输入的纯函数;没有这些信息,你只能靠猜。
- 预期值与实际值 —— 他们预期的数字,以及实际看到的数字。
- 发生频率 —— 每次都会发生,还是间歇性发生?“有时”往往意味着存在非确定性。
- 区域设置/时区/货币 —— 舍入、税费和格式因区域设置而异;在
de-DE中“错误”的总额,在en-US中可能是正确的。 - 发生时间戳,以及任何日志/屏幕截图/网络跟踪记录。
在接触代码之前,先将这些信息写入一份复现规范。如果某一项为空,那就是你接下来要向报告者提出的问题——而不是开始编写修复方案或推测原因的许可。
参阅 references/intake.md,了解完整的提取清单、每个关键要素对“总额错误”问题的重要性,以及复现规范模板。
步骤 2:复现→精简→隔离→记录循环
对于已确认但杂乱的复现流程(例如跨 3 个页面的 14 个手动 UI 步骤),不要把 14 步版本交给开发者,也不要彻底重写。请运行以下循环:
- 复现 / 确认。 首先建立基线:运行完整的复现流程,并确认它确实会失败。只有当前能够复现的问题,才可以精简。
- 精简。 移除一个步骤、字段或依赖项,然后重新运行。如果仍能复现,就保留这项删减;如果不再失败,则该元素是必要条件——将它恢复。每次只改变一个变量,重复此过程,直到每个剩余部分都是必要的。这是核心顺序规则:未确认问题能够复现前,绝不进行精简;每次删减后,也绝不省略验证问题是否仍会失败。
- 隔离。 将故障范围缩小到仍能体现问题的最小层级——从跨 3 个页面的 UI 流程缩小到单个页面,再缩小到针对出问题函数的单元测试 / API 调用(如果 Bug 出在 UI 以下的层级)。
- 记录。 将现在这个最小化复现流程作为证据记录下来:最少的步骤或单条命令,以及日志 / 跟踪记录 / 截图。开发者和回归测试都会使用这些内容。
最终产物是仍能复现问题的最短步骤序列,而不是原始操作流程。
步骤 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 来掩盖时序问题。
| 来源 | Vitest | Playwright |
|---|---|---|
| 时间 | vi.useFakeTimers() + vi.setSystemTime(new Date('…')) | page.clock.install({ time }) + page.clock.setFixedTime(…) |
| 随机性 | faker.seed(1337)(或模拟 Math.random) | 通过初始化钩子为应用的 RNG 设置种子 |
| 网络 | MSW setupServer + http.get → HttpResponse.json | page.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+ MSWsetupServer)和 Playwright(page.clock+page.route)配方, 以及 10 次运行的确定性检查。
步骤 5:先编写失败的回归测试(红灯)
你已经获得了干净且确定的复现过程,而开发者还没有修复它。现在就以针对 bug 的 TDD 方式,在修复之前编写回归测试:
- 将最小复现过程编成测试,并断言真正的预期值——
expect(total).toBe(2754)。永远通过的同义反复断言毫无证明作用。 - 运行测试并确认它在修复前失败——必须先是红灯。无法变红的测试并没有触发这个 bug。
- 提交测试(或将其暂存到修复分支),让测试在 CI 中保护这项修复。
- 开发者提交修复后,重新运行测试:它应该转为绿灯。同一个测试,不做任何修改。
预期状态:修复前红灯,修复后绿灯。
会违背目的的错误做法——针对确实要等到这个 bug 修复后才会通过的测试:等修复已经合并后才编写测试;为了让 CI 保持绿色而禁用失败的测试(添加待处理标记、使用永远为真的同义反复,或移除断言);或者先修复,再事后补上测试。保持断言生效,让测试变红。
参见 references/determinism.md,了解承载断言的确定性测试主体。
步骤 6:验证修复确实有效
开发者提交了修复,现在回归测试通过了。通过是必要条件,但还不够——测试可能因为无关原因而通过。不要只看到通过就关闭,也不要轻信开发者的说法。请运行有效性检查:
- 暂时撤销修复(将其暂存,或使用
git stash/注释掉修复行),然后重新运行测试。确认没有修复时测试仍然失败。这能证明测试确实覆盖了这个 Bug(测试能捕获这个 Bug)——测试是因为修复才通过,而不是因为其他与缺陷无关的原因。 - 恢复修复,确认测试重新通过。
- 多次确定性地重新运行(使用
--repeat-each/循环),确认通过结果稳定,而不是侥幸通过。
只有在没有修复时测试失败、有修复时测试通过,且多次重复结果一致,才能确认修复有效。撤销后验证这一步正是回归测试的核心目的,也最容易被遗漏。
步骤 7:区分不稳定、环境相关和无法复现
你花了两个小时,但自己无法复现,而用户遇到的问题显然确实存在。不要立刻以“无法复现”为由关闭,不要认定是偶发问题就将其隔离,也不要断定“我的机器上无法复现,所以问题不是真的”。这是三种不同的诊断,各自需要不同的证据:
| 诊断 | 区分性证据 | 应采取的措施 |
|---|---|---|
| 不稳定 | 代码和环境相同,同一提交上既通过也失败——在同一环境中运行 --repeat-each 50(或多次重复运行相同命令),观察结果是否反复变化 | 找出非确定性因素(时间/RNG/网络/竞态),让结果变得确定(步骤 4) |
| 环境相关 | 仅在不同配置下复现——时区、区域设置、视口、操作系统、浏览器版本、CI 与本地环境——且在该配置下结果稳定 | 匹配用户的环境:复现用户的时区/区域设置/操作系统/浏览器环境,然后逐步缩小范围 |
| 数据相关 | 仅使用用户的特定账号/输入时才会复现 | 获取并复现用户的数据/测试夹具;Bug 与输入相关,而非平台 |
| 确实无法复现 | 匹配环境和数据并多次运行后,以上情况均无法复现 | 记录你尝试过的内容(环境、运行次数、数据)和未复现的结果——不要悄悄关闭 |
容易被遗漏的一步是:先匹配报告中的用户环境和数据再下结论——复现用户的配置(时区、区域设置、操作系统、浏览器版本)和环境,然后重新运行。在相同环境中使用 --repeat-each 可以隔离出真正的不稳定问题;跨环境结果不一致则指向环境相关问题;对输入的依赖则指向数据相关问题。
步骤 8:将证据写回工单
复现和已提交的回归测试都已完成。将含糊的原始报告替换为结构化内容——不要粘贴原始的 14 步 UI 操作流程,也不要只写“已复现,关闭”。请包含以下七项:
- 最简步骤/复现命令——最短复现路径,而非完整操作流程。
- 环境与构建版本/提交——准确的 SHA 和平台。
- 预期结果与实际结果——具体数值。
- 引入问题的提交——通过
git bisect找出的相关提交。 - 回归测试——已提交的测试文件及路径链接。
- 证据——日志、截图、跟踪记录或构件。
- 确定性说明——种子、冻结时间和存根,以便任何人都能以相同方式重新运行。
请参阅 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)— 获得确定性复现后进行根因调试的通用循环;本技能负责产出该复现以及用于守护最终修复的失败测试。