...
Back

只在 CI 里发生的那些失败

六十一个测试在构建容器里失败,在每台笔记本上都通过。原因是一类没人刻意写过的文件系统产物 —— 而通用的教训是:你的环境在替你隐藏什么。

只在 CI 里发生的那些失败

只在 CI 里发生的那些失败 🏗️

我们的发布门禁红了,六十一个失败。每一个在本地都通过,而且不止一台机器。测试本身没有改动。

原因是一类文件:macOS 在文件经过某些文件系统和归档时会写出的元数据附属文件,名字是原文件名加 ._ 前缀。我们的测试套件遍历目录寻找内容文件,匹配上了这些附属文件,试图把它们当内容解析,于是失败了六十一次。在开发机上它们在列表里是隐藏的,所以没人见过。在构建容器里,它们就只是文件。

一行 .gitignore 修好了。有意思的不是这个修法。


你的环境在替你隐藏东西

这个 bug 值得写,是因为它属于一个很大的家族,而这个家族由一条性质定义:你的本地环境静默地规范化了某个 CI 不会规范化的东西。

常见成员:

大小写不敏感的文件系统。 macOS 和 Windows 默认大小写不敏感。Linux 不是。import Button from "./button" 在本地能跑,在构建容器里失败,而那个报错 —— 模块找不到,而文件明明就在那儿 —— 第一次遇到时是真的令人费解。

隐藏文件和元数据文件。 那些 ._ 附属文件、.DS_Store、编辑器的交换文件。在你的文件浏览器里不可见,对任何读目录的代码都可见。

区域设置与排序规则。 排序依赖 locale。一个默认 C 的容器,和一台设了地区 locale 的工作站,排序结果不同 —— 于是一个断言顺序的测试在一边通过、在另一边失败。

时区。 容器通常跑 UTC,笔记本很少。任何涉及日期边界的测试,都有相当大的概率在作者毫无察觉的情况下依赖时区。

行尾。 一次采用了不同行尾规范化的检出,会改变文件哈希,弄坏精确匹配的断言。

可用核心数。 依赖并发的测试在八核和两核上行为不同,而 CI runner 常常比你以为的小。

每一个案例里,本地都是宽容的那一边,CI 是严格的那一边。正是这种不对称,让"在我机器上是好的"这句话既真实又无用。


第二个发现:一个测量了错误对象的基线

同一个门禁暴露了同一个问题的一个更微妙的版本。我们维护着一条 lint 基线 —— 记录下现存警告的数量,这样新增的会让构建失败,而不需要一次性大扫除。而它是在一个开发机检出上记录的。

容器产出了不同的数字。不是因为代码不同,而是因为环境不同:存在的文件集合不同、解析略有差异、多出一条警告。这条基线对一台不门禁任何东西的机器是正确的,对那台真正做门禁的机器是错的。

修法可以推广:一条阈值必须在执行它的那个环境里测量。 基线、性能预算、覆盖率下限 —— 任何会在 CI 里被比对的数字,都必须在 CI 里产生,否则它就是把一个环境的怪癖编码成了施加于另一个环境的规则。


让这一类 bug 变便宜

三条实践,按收益排序:

尽早让差异可见。 在每次 CI 运行的开头打印环境 —— locale、时区、核心数、文件系统大小写敏感性、工具版本。十行输出,能把一个令人费解的失败变成一个显而易见的失败。

locale; date; nproc; node --version
# 这个文件系统大小写敏感吗?
touch /tmp/Case && [ -e /tmp/case ] && echo "大小写不敏感" || echo "大小写敏感"

对任何环境相关的东西都显式声明。 在测试初始化里设置时区和 locale,而不是继承它们。用显式比较器排序,而不是默认排序。断言在解析后的值上,而不是格式化后的字符串上。

让文件发现精确。 我们这个具体的 bug 就是一个匹配范围超出预期的通配。喂给测试套件的目录遍历,应该匹配一个显式模式并默认忽略点号开头的文件 —— 一个宽松的模式迟早会匹配上某个文件系统自己创建的东西。


值得保留的视角

当某个东西只在 CI 里失败时,直觉是 CI 配错了。更有用的做法是把它反过来:CI 正在向你展示一个你的机器一直在隐藏的真相。 这段代码一直对大小写、或 locale、或意外文件很脆弱 —— 只是你有一个足够慷慨的环境在替它兜着。

这也意味着,修法很少是"让 CI 更像我的笔记本"。修法是消除对那份宽容的依赖 —— 因为生产容器同样严格,而它不会把失败报告成一次红色的构建。