一切都始于一条 Slack 消息。构建失败了(显示为红色)。你翻阅失败记录,皱起眉头,然后在自己的笔记本电脑上重新运行同一个测试。通过了(显示为绿色)。你再次尝试 CI 任务。也许只是个偶然。失败再次出现,在服务器上顽固且可重复,但在你本地却无法复现。
一个在 CI 中失败但在本地通过的浏览器测试,不仅仅是令人烦恼。它会滋生不信任感。团队开始归咎于时序问题。他们推送一些永远不会被移除的临时修复方案。这里加一个 setTimeout,那里加一个 .wait(5000)。测试套件变慢了。失败不断回归。那些不稳定的测试(flaky tests)逐渐变成了常态,最终每个人都开始把红色的流水线当作背景噪音。
这是很危险的。你并不想要一个只会“狼来了”的测试套件。
CI 并没有坏,它只是不同而已
CI 环境并非随机。它们是确定性的。问题在于,它们是针对一个并非你的 MacBook 或 Linux 工作站的系统进行确定性运行的。你的本地设置隐藏了差异,而干净的 CI 运行器会立即暴露这些差异。
想想有多少环节会产生分歧。你的本地机器可能运行着带有热模块替换(hot module reloading)的开发服务器,而 CI 构建的是经过 tree shaking 和压缩(minification)的生产制品。仅此一项就可能剥离代码路径或改变执行顺序。依赖树会发生偏移。如果包管理器版本仅相差一个次要版本,看起来完全相同的 lockfile 可能会解析出不同的结果。网络序列也会改变。你的办公室 Wi-Fi 可能只需一跳就能解析 staging API;而 CI 运行器可能会通过负载均衡器访问不同的集群,从而引入你从未见过的延迟。
浏览器本身在不同环境下表现也不同。你本地的 Chrome 带有扩展程序、缓存的凭据、持久化的本地存储,以及带有硬件加速的 GPU。而 CI 每次运行时都从空白配置开始。浏览器的生命周期会分歧。渲染路径会分歧。你系统上存在的字体在 CI 中可能会被替换。视口大小(viewport sizing)和设备像素比(device pixel ratios)也会有所不同,这可能会触发响应式断点或改变懒加载(lazy-loading)行为。
这些差距是真实存在的。它们是机械性的差异。假装它们是随机的并不能让它们消失。
预览环境会骗人
预览环境加剧了这个问题。它们对于人工审查很有用,但它们不是生产环境。它们通常指向 api-staging 而不是真实的 API 主机。功能开关(feature flags)对每个实验都评估为 true,从而隐藏了生产环境会执行的条件逻辑。身份验证可能会跳过某个步骤或注入模拟(mock)令牌。Cookie 可能会使用宽松的策略。数据集可能只是一个很小的切片,只有十行而不是一万行,这意味着分页、搜索排名或虚拟化逻辑永远不会被执行。
如果你的测试在预览 URL 上通过但在生产环境中失败,或者反之亦然,那么问题不在于测试,而在于环境。
在猜测之前先记录日志
当失败首次出现时,抵制住那种想要修改测试并寄希望于好运的冲动。停止猜测。你需要冻结上下文,以便将通过的运行与失败的运行进行对比。
记录那些显而易见的嫌疑对象。记录失败瞬间的页面 URL、构建 ID 和 commit SHA。记录当前激活的功能开关。捕获 API 主机、确切的浏览器版本和视口大小。这些细节能将一个神秘的失败转化为一个可复现的条件。
不要仅仅依赖截图。两个页面在运行完全不同的 JavaScript 时,看起来可能像素级一致。截图无法告诉你 CI 包是否包含了一个额外的 polyfill,或者本地包是否因为已经在你的浏览器缓存中而跳过了一个 chunk。
还要记住,打开 DevTools 会改变时序。DevTools 可能会延迟垃圾回收(garbage collection)、改变网络优先级并禁用某些渲染优化。一个在你检查 DOM 时能通过的测试,在你关闭面板并以无头(headless)模式运行时可能会失败。调试器是一个有用的工具,但它不是一个中立的观察者。
重现犯罪现场
如果你想准确地复现失败,你不能只是简单地运行你的本地开发服务器并祈祷。你需要复制 CI 的确切条件。
构建 CI 生成的完全相同的产
