这不是配置教程,是三个我自己踩过、并且在本地复现出来的坑。每个坑的特征都一样:CI 显示 ✅,但那个检查实际上什么都没检查。
先说结论:CI 里最危险的状态不是红灯,是你以为在跑、其实早就空转的绿灯。
一、坑 1:管道吃掉了退出码
这是最经典、也最难发现的一个。
yaml
- name: Run tests
run: npm test | tee test-report.txt
看起来很好:测试结果既打到终端,又存了文件。
问题是:这条命令的退出码,是 tee 的,不是 npm test 的。
我本地实测过(git bash,非 GitHub Actions 环境):
bash
# 不带 pipefail
( false | true ); echo "exit=$?"
# => exit=0 ← 前一段失败了,但整体返回成功
# 带 pipefail
( set -o pipefail; false | true ); echo "exit=$?"
# => exit=1 ← 前一段失败,整体失败
前一段明明失败了,退出码却是 0。CI 看到 0 就判通过。
好消息 :GitHub Actions 的 bash shell 默认命令是
css
bash --noprofile --norc -eo pipefail {0}
已经带 -o pipefail 了 。这是官方文档 workflow-syntax 里写明的。
坏消息 :只有 shell: bash 才是这个。以下三种情况没有:
| 写法 | 实际命令 | 有 pipefail 吗 |
|---|---|---|
| 不写 shell(Linux) | bash -e {0} |
❌ 没有 |
shell: bash |
bash --noprofile --norc -eo pipefail {0} |
✅ 有 |
shell: sh |
sh -e {0} |
❌ 没有 |
| Windows 默认 | pwsh -command ... |
❌ 不是 bash 语义 |
注意第一行:Linux 上不写 shell,用的是 bash -e,不带 pipefail。 只有你显式写 shell: bash,才会拿到那个带 pipefail 的版本。
这非常反直觉------大多数人以为「默认就是 bash,应该一样」。
怎么修:显式声明。
yaml
- name: Run tests
shell: bash
run: npm test | tee test-report.txt
或者在 run 里自己加:
yaml
- name: Run tests
run: |
set -o pipefail
npm test | tee test-report.txt
二、坑 2:continue-on-error 之后,用错了判断字段
这个坑我见过不止一次。
yaml
- name: Mutation check
id: mutation
continue-on-error: true
run: npx ai-test-check ./src
- name: Fail if bad
if: steps.mutation.conclusion == 'failure'
run: exit 1
逻辑看着很顺:上一步失败 → 这一步报错。
但它永远不会触发。
原因是 GitHub 有两个字段,语义不同:
| 字段 | 含义 |
|---|---|
outcome |
continue-on-error 之前的结果 |
conclusion |
continue-on-error 之后的结果 |
官方文档原文:
When a continue-on-error step fails, the outcome is failure, but the final conclusion is success.
也就是说:上一步真的失败了,但因为你写了 continue-on-error: true,它的 conclusion 是 success。
你拿 conclusion 去判断 == 'failure',条件恒为假。这个检查永远不报错。
怎么修 :判断失败要用 outcome。
yaml
- name: Fail if bad
if: steps.mutation.outcome == 'failure'
run: exit 1
这条我建议所有写 continue-on-error 的人都回去 grep 一遍自己的 workflow。
三、坑 3:|| true 和「只警告不失败」
这个不算 bug,是设计选择,但它经常被当成 bug 用。
yaml
- name: Lint
run: npm run lint || true
写的人想的是「lint 先别卡,等稳定了再卡」。三个月后没人记得这件事,这个 lint 检查等于不存在。
同理还有:
yaml
- name: Test
continue-on-error: true # 没有下面那个 outcome 判断
run: npm test
continue-on-error 单独用,等于把这个 step 变成纯展示。
我不反对「先观察再卡」。但如果你这么做了,至少要在 workflow 里留个记号:
yaml
# TODO(2026-12-01): 观察期结束,去掉 continue-on-error
# 观察期内的数据看 job summary 里的 mutation-report.txt
- name: Mutation check (observe only)
id: mutation
continue-on-error: true
run: npx ai-test-check ./src
写个日期。不然「临时」会变成永久。
四、那怎么知道测试到底测住了没有?
上面三个坑都是流程层 的问题。还有一个更底层的:你的测试本身就可能是假的。
「测试全绿」只说明:你写的断言都通过了。 它不说明:你改坏代码,测试会不会红。
这两个是完全不同的问题。
验证方法是变异测试:故意把源码改坏(>= 改成 >、&& 改成 ||、数字边界 ±1),然后跑测试,看测试有没有变红。
- 变红 ⇒ 这个变异被「杀死」⇒ 测试确实覆盖到了这里
- 还是绿 ⇒ 变异「存活」⇒ 测试看不见这里
存活率就是测试有效性的另一面。
我用自己的工具 ai-test-check 在两个示例项目上跑了一遍(真实输出):
示例 A:calculator(16 个可变异点)
ini
[1/16] calc.js === → !== ✅ 被杀
[3/16] calc.js >= → > ❌ 存活
[6/16] calc.js && → || ❌ 存活
[8/16] calc.js 数字边界 ❌ 存活
...
变异总数: 16
被杀: 7
存活: 9
★ 测试有效性: 44%
示例 B:full(19 个可变异点,原始测试)
makefile
变异总数: 19
被杀: 8
存活: 11
★ 测试有效性: 42%
同一份源码,只补测试,不改一行业务代码:
makefile
变异总数: 19
被杀: 19
存活: 0
★ 测试有效性: 100%
这就是关键:42% → 100%,源码一个字没动。
那 11 个存活变异里,有 5 个是数字边界、3 个是比较运算符。补的都是「刚好等于」和「差一」的用例:
js
// 原来只测了
expect(discount(200)).toBe(180);
// 补上边界
expect(discount(99)).toBe(99); // 差一
expect(discount(100)).toBe(100); // 刚好等于
五、把这两件事接起来
现在回到 CI。
上面三个坑让「检查可能空转」,变异测试让「检查可能本来就无效」。
两者叠加,就是:CI 全绿,代码有 bug,而且没人知道。
我的建议是把变异测试作为一个 step 接进去,并且用前面讲对的方式判断:
yaml
jobs:
mutation:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- name: Mutation check
id: mutation
continue-on-error: true
shell: bash # ★ 坑 1:显式 bash,拿到 pipefail
run: |
npx ai-test-check ./src --max=20 | tee mutation-report.txt
- name: Show report
if: always()
run: cat mutation-report.txt
- name: Upload report
if: always()
uses: actions/upload-artifact@v4
with:
name: mutation-report
path: mutation-report.txt
- name: Fail if effectiveness < 50%
if: steps.mutation.outcome == 'failure' # ★ 坑 2:用 outcome 不是 conclusion
run: exit 1
三个关键点,正好对应前面三个坑:
shell: bash------ 拿到带 pipefail 的 bashsteps.mutation.outcome------ 判断真实结果,不是被 continue-on-error 抹平的结果--max=20------ 限制变异数量,别让 CI 跑太久
六、诚实边界
我得说清楚这个工具和这套方法不能做什么:
| 限制 | 说明 |
|---|---|
| 只支持 JS/CJS | 基于 acorn AST,不支持 TS、Python |
| 变异类型有限 | 运算符 / 布尔 / 数字边界 / 状态字符串 / 条件取反,就这 5 类 |
| 可能高估有效性 | 变异造出语法错误时会被当成「被杀」,方向是保守的(对你不利) |
| 只做单文件 | 没有跨文件、模块间契约检测 |
| 慢 | 每个变异都要跑一次完整测试,20 个变异 = 20 次测试 |
| 100% 不等于完美 | 只说明「这 19 个变异都被抓到了」,不说明没别的 bug |
最重要的一条:变异测试不能替代任何测试,它只是补上一个盲区------「覆盖率说测了没,变异测试说测得住不」。
七、一个可以直接抄的检查清单
如果你只想花 5 分钟自查,看这四条:
- 我的 workflow 里,有
|管道的 step,写了shell: bash吗? - 用了
continue-on-error的 step,判断时用的是outcome还是conclusion? - 有没有
|| true在吃掉本该失败的步骤? - 我的测试,改坏源码后会红吗?(不知道就测一下)
前三条是流程问题,五分钟能改完。第四条要跑一次才知道。
八、工具
bash
npx ai-test-check ./src --max=20
它很小,只做一件事:让你在 30 秒内知道,你的测试是不是真的。
如果你跑完发现自己项目的有效性很低,欢迎在评论区告诉我数字。
我更好奇的是:你觉得这个数有用吗? 如果没用,为什么?
------ 因为我不想做一个「看起来很酷但没人需要」的东西。