CI 全绿了,测试却可能是假的:GitHub Actions 三个静默失效的坑

3 阅读6分钟

这不是配置教程,是三个我自己踩过、并且在本地复现出来的坑。每个坑的特征都一样:CI 显示 ✅,但那个检查实际上什么都没检查。

先说结论:CI 里最危险的状态不是红灯,是你以为在跑、其实早就空转的绿灯。


一、坑 1:管道吃掉了退出码

这是最经典、也最难发现的一个。

- name: Run tests
  run: npm test | tee test-report.txt

看起来很好:测试结果既打到终端,又存了文件。

问题是:这条命令的退出码,是 tee 的,不是 npm test 的。

我本地实测过(git bash,非 GitHub Actions 环境):

# 不带 pipefail
( false | true ); echo "exit=$?"
# => exit=0   ← 前一段失败了,但整体返回成功

# 带 pipefail
( set -o pipefail; false | true ); echo "exit=$?"
# => exit=1   ← 前一段失败,整体失败

前一段明明失败了,退出码却是 0。CI 看到 0 就判通过。

好消息:GitHub Actions 的 bash shell 默认命令是

bash --noprofile --norc -eo pipefail {0}

已经带 -o pipefail 了。这是官方文档 workflow-syntax 里写明的。

坏消息:只有 shell: bash 才是这个。以下三种情况没有:

写法实际命令有 pipefail 吗
不写 shell(Linux)bash -e {0}❌ 没有
shell: bashbash --noprofile --norc -eo pipefail {0}✅ 有
shell: shsh -e {0}❌ 没有
Windows 默认pwsh -command ...❌ 不是 bash 语义

注意第一行:Linux 上不写 shell,用的是 bash -e,不带 pipefail。 只有你显式写 shell: bash,才会拿到那个带 pipefail 的版本。

这非常反直觉——大多数人以为「默认就是 bash,应该一样」。

怎么修:显式声明。

- name: Run tests
  shell: bash
  run: npm test | tee test-report.txt

或者在 run 里自己加:

- name: Run tests
  run: |
    set -o pipefail
    npm test | tee test-report.txt

二、坑 2:continue-on-error 之后,用错了判断字段

这个坑我见过不止一次。

- 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 有两个字段,语义不同:

字段含义
outcomecontinue-on-error 之前的结果
conclusioncontinue-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。

- name: Fail if bad
  if: steps.mutation.outcome == 'failure'
  run: exit 1

这条我建议所有写 continue-on-error 的人都回去 grep 一遍自己的 workflow。


三、坑 3:|| true 和「只警告不失败」

这个不算 bug,是设计选择,但它经常被当成 bug 用。

- name: Lint
  run: npm run lint || true

写的人想的是「lint 先别卡,等稳定了再卡」。三个月后没人记得这件事,这个 lint 检查等于不存在。

同理还有:

- name: Test
  continue-on-error: true   # 没有下面那个 outcome 判断
  run: npm test

continue-on-error 单独用,等于把这个 step 变成纯展示。

我不反对「先观察再卡」。但如果你这么做了,至少要在 workflow 里留个记号:

# 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 个可变异点)

[1/16] calc.js  === → !==        ✅ 被杀
[3/16] calc.js  >= → >           ❌ 存活
[6/16] calc.js  && → ||          ❌ 存活
[8/16] calc.js  数字边界          ❌ 存活
...
变异总数: 16
被杀: 7
存活: 9
★ 测试有效性: 44%

示例 B:full(19 个可变异点,原始测试)

变异总数: 19
被杀: 8
存活: 11
★ 测试有效性: 42%

同一份源码,只补测试,不改一行业务代码:

变异总数: 19
被杀: 19
存活: 0
★ 测试有效性: 100%

这就是关键:42% → 100%,源码一个字没动。

那 11 个存活变异里,有 5 个是数字边界、3 个是比较运算符。补的都是「刚好等于」和「差一」的用例:

// 原来只测了
expect(discount(200)).toBe(180);

// 补上边界
expect(discount(99)).toBe(99);    // 差一
expect(discount(100)).toBe(100);  // 刚好等于

五、把这两件事接起来

现在回到 CI。

上面三个坑让「检查可能空转」,变异测试让「检查可能本来就无效」。

两者叠加,就是:CI 全绿,代码有 bug,而且没人知道。

我的建议是把变异测试作为一个 step 接进去,并且用前面讲对的方式判断:

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

三个关键点,正好对应前面三个坑:

  1. shell: bash —— 拿到带 pipefail 的 bash
  2. steps.mutation.outcome —— 判断真实结果,不是被 continue-on-error 抹平的结果
  3. --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 在吃掉本该失败的步骤?
  • 我的测试,改坏源码后会红吗?(不知道就测一下)

前三条是流程问题,五分钟能改完。第四条要跑一次才知道。


八、工具

npx ai-test-check ./src --max=20

仓库:github.com/moqinghua20…

它很小,只做一件事:让你在 30 秒内知道,你的测试是不是真的。


如果你跑完发现自己项目的有效性很低,欢迎在评论区告诉我数字。

我更好奇的是:你觉得这个数有用吗? 如果没用,为什么?

—— 因为我不想做一个「看起来很酷但没人需要」的东西。