退出码 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 会让它永远不被提醒
落地做法
- reason 必须带工单号:
skip("BUG-1234 ..."),没有 ID 的 skip 在 review 时直接打回。 - CI 卡 skip 增量:pytest 用
--junitxml解析 skipped 数,Go 解析go test -json的Action=skip,PHPUnit 直接--fail-on-skipped。基线允许存量,新提交新增 skip 就红。 - skip 数量做趋势看板。数量阶跃(比如一次升级后 +30)通常是环境变了、依赖装不上、requirement 不满足,而不是「这些用例本来就不用跑」。
- 定期清理:
pytest -rs、go test -v | grep '^--- SKIP'、PHPUnit 报告里的 skipped 列表,按 reason 分组,超过一个季度没动的直接删掉或改写成正式用例。
skip 是「没测」,pass 是「测了,好」。把两者合并成绿色,等于在可靠性报表里开了个后门。测试跳过语义在各框架文档里都是明确分开的,问题从来不在工具,而在 CI 配置默认把它们当通过。