编程 退出码 0,测试数从 412 掉到 380:skip 为什么不算 PASS

2026-10-28 00:03:58

退出码 0,测试数从 412 掉到 380:skip 为什么不算 PASS

一次 CI 变绿的事故

review CI 报告时发现:测试总数从 412 掉到 380,退出码仍然是 0,摘要里只有一行 OK, but some tests were skipped!。查下去是三个集成用例在 env 调整后走了 skip 分支,没人注意到。

问题出在语义上。skip 应该表示「这条用例在当前环境根本无法执行」,不是「执行了但结果不对」。前者是执行前的前置判断,后者必须记为 fail。

Go

func TestPaymentCallback(t *testing.T) {
    if testing.Short() {
        t.Skip("skip in -short mode")
    }
    ...
}

t.Skip 等价于 Log + SkipNow。SkipNow 内部调 runtime.Goexit,defer 仍会执行,但测试函数剩余部分不再运行,结果记为 skip,不影响 exit code。

命令行侧,go test -skip '^TestIntegration' ./... 可以按正则排除测试名(Go 1.21 起支持,和 -run 互补)。想统计 skip 数得自己解析 go test -json,skip 事件是 {"Action":"skip"};-v 下会打印 --- SKIP: TestX (0.00s),而默认摘要只给一行 ok。

PHPUnit

public function testGatewaySandbox(): void
{
    if (!getenv('PAY_GATEWAY_SANDBOX')) {
        $this->markTestSkipped('PAY_GATEWAY_SANDBOX not configured');
    }
}

PHPUnit 10+ 的 #[RequiresPhp('>= 8.4')] 这类 requirement 属性,不满足时同样计为 skipped 而不是 failed——升完 PHP 之后一批用例静默变成 skip 是常见事故。

PHPUnit 10 起提供 --fail-on-skipped(同族还有 --fail-on-incomplete / --fail-on-risky / --fail-on-warning),把 skipped 变成非零退出码。这是让 CI 变红成本最低的一行配置。

pytest

@pytest.mark.skipif(not os.getenv("MYSQL_DSN"), reason="requires MYSQL_DSN")
def test_replica_lag(): ...

def test_sth():
    pytest.skip("unsupported configuration")   # 运行时跳过
  • skipif 在 collection 阶段求值;skip() 在运行时调用,适合导入期算不出来的条件。
  • pytest.skip(reason, allow_module_level=True) 跳过整个模块。
  • pytest.importorskip("docutils", minversion="0.3") 在依赖缺失时跳过。
  • xfail 是另一回事:预期失败。@pytest.mark.xfail(strict=True) 下 xpass(本该失败却通过)会变成 failure,不加 strict 只会多一行 XPASS。
  • pytest -rs 在摘要里列出 skip 原因,-ra 列出除 pass 之外的全部结果。

Node(node:test)

test('需要真实 S3', { skip: !process.env.S3_BUCKET }, (t) => { ... });
test('逻辑未就绪', (t) => { t.skip('TODO #1234'); });

TAP 输出里是 # SKIP,退出码仍为 0。

Playwright

test('上游 5xx', async ({ request }) => {
  const res = await request.post('/api/chat');
  test.skip(res.status() >= 500, 'upstream degraded'); // 反例
});

在 test 体内调 test.skip 时,被测系统已经被调用过了,这一跳抹掉的是失败信号。声明阶段的 test.skip(condition, desc)、test.fixme() 才是「不执行」的语义。

Rust

#[test]
#[ignore = "requires PAY_GATEWAY_SANDBOX; run with --ignored"]
fn sandbox_callback() {}

cargo test 默认跳过 ignored 并打印 ignored 数量;cargo test -- --ignored 只跑这些,--include-ignored 全跑。

判定规则

能 skip 的只有前置条件,而且前置条件必须在调用被测系统之前判断:

  • 缺少凭据 / 密钥
  • 平台或架构不匹配(windows-only / aarch64-only)
  • feature flag 在本次构建没开、代码路径不存在
  • 可选依赖未安装

不能 skip 的:

  • 上游 5xx、429、连接超时——用例需要上游时,上游挂掉就是失败
  • 断言结果不符——那是被测系统的问题
  • flaky 重试后仍失败——改用重试机制,不是 skip
  • 「等这个 bug 修了再打开」——应该标 xfail 并留 issue,skip 会让它永远不被提醒

落地做法

  1. reason 必须带工单号:skip("BUG-1234 ..."),没有 ID 的 skip 在 review 时直接打回。
  2. CI 卡 skip 增量:pytest 用 --junitxml 解析 skipped 数,Go 解析 go test -json 的 Action=skip,PHPUnit 直接 --fail-on-skipped。基线允许存量,新提交新增 skip 就红。
  3. skip 数量做趋势看板。数量阶跃(比如一次升级后 +30)通常是环境变了、依赖装不上、requirement 不满足,而不是「这些用例本来就不用跑」。
  4. 定期清理:pytest -rs、go test -v | grep '^--- SKIP'、PHPUnit 报告里的 skipped 列表,按 reason 分组,超过一个季度没动的直接删掉或改写成正式用例。

skip 是「没测」,pass 是「测了,好」。把两者合并成绿色,等于在可靠性报表里开了个后门。测试跳过语义在各框架文档里都是明确分开的,问题从来不在工具,而在 CI 配置默认把它们当通过。

复制全文 生成海报 skip 测试 pytest PHPUnit Go CI

推荐文章

程序员茄子在线接单