你有一半的 Cucumber 场景被跳过了。不是失败,是跳过。
那个黄色的状态比红色构建更糟糕。它让你的套件看起来很健康,却在一个礼貌的标签下隐藏了三种完全不同的问题。一个被跳过的场景可能意味着你的 tag 过滤排除了它,某个 step definition 丢失了,或者一个 Before hook 在第一个步骤运行之前就抛出了异常。你的任务是找出哪个寄生虫正寄生在你的套件里。
为什么 “Skipped” 是 Cucumber 最危险的状态
Cucumber 对一个场景有三种终态:passed、failed 和 skipped。Passed 和 failed 是诚实的。Skipped 是个垃圾桶。
当一个场景被跳过时,Cucumber 是在告诉你某个前置条件没有被满足。问题在于这个”前置条件”可能是从故意的 tag 过滤到 setup hook 中的空指针的任何东西。一个跳过 50% 场景的套件看起来像是运行了 500 个测试。实际上并没有。它只运行了 250 个测试,然后对另外 250 个挥了挥手,根本没有检查它们。
大多数 CI 面板把 skipped 视为中性。你的流水线是绿色的。但你有一半的行为规范没有被验证。如果你在用 Cucumber 作为活文档,那你有一半的文档是谎言。
大规模跳过的三大根本原因
在 Cucumber 中,恰好有三种方式会产生被跳过的场景。其中两种是 bug。一种是看起来像功能的错误配置。
Tag 过滤排除了比你预期更多的场景
50% 场景被跳过的最常见原因是 runner 配置中的 tag 表达式比你想象的更宽泛。你的 CI 配置可能是这样的:
// cucumber.js
module.exports = {
default: [
'--format', 'progress',
'--tags', 'not @wip',
].join(' '),
ci: [
'--format', 'json:reports/cucumber.json',
'--tags', 'not @wip and not @slow',
].join(' '),
};
真正的损害发生在团队使用正向 tag 过滤时。像 --tags '@regression' 这样的过滤只运行标记了 @regression 的场景。其他所有场景都会被跳过。这就是你如何在没改任何一个 feature 文件的情况下,从 100% 执行率掉到 50% 执行率。
不再匹配的 Step definitions
如果某个步骤缺少匹配的 step definition,Cucumber 会跳过整个场景。这不是失败,这是跳过。你的 CI 保持绿色。这发生的频率比你想象的高。一个产品经理编辑 Gherkin 文件来修正步骤中的拼写错误:
# features/checkout.feature
Feature: Checkout
Scenario: Guest user completes purchase
Given a guest user with items in cart
When they proceed to checkout
Then the order total should include tax
你的 step definitions 期望的是这个:
// features/step_definitions/checkout_steps.js
Given('a guest user with items in the cart', function () {
// setup
});
注意 feature 文件里少了 “the”。Cucumber 的正则不再匹配。整个场景被跳过。把这个情况乘以一个在本地运行完整套件之前就重构 feature 文件的团队,你会因为漂移而损失一半的覆盖率。
伪装成跳过的失败 Hooks
如果一个 Before hook 抛出异常,Cucumber 会跳过该 feature 文件中的每个场景。它不是让它们失败,而是跳过它们。
// features/support/hooks.js
const { Before } = require('@cucumber/cucumber');
Before(async function () {
this.browser = await chromium.launch();
this.page = await this.browser.newPage();
// If this throws, every scenario in the file is skipped
await this.page.goto(process.env.TEST_URL);
});
如果在 CI 中 TEST_URL 未定义,hook 会抛出异常,每个关联的场景都会被跳过。你的构建是绿色的。你的测试毫无价值。
如何诊断你感染了哪种寄生虫
如果你不知道三个原因中的哪个该负责,你就无法修复 50% 被跳过的场景。Cucumber 的默认输出不会告诉你。你必须审问它。
不带任何 tag 过滤运行 --dry-run:
npx cucumber-js --dry-run --tags ''
Dry run 会解析每个 feature 文件并将每个步骤与 step definition 匹配,但不执行任何东西。如果它显示 100% undefined,说明你有 step definition 漂移。如果它显示 100% passed,说明你的跳过来自 tag 过滤或运行时 hooks。
JSON formatter 为每个步骤包含一个 status 字段。被 tag 过滤跳过的场景不会有任何步骤。被缺失 step definition 跳过的场景会在不匹配的步骤上显示 undefined。被失败 hook 跳过的场景会在每个步骤上显示 skipped,而且 embeddings 数组可能包含 hook 的错误信息。
npx cucumber-js --format json:report.json
解析 JSON 并统计有多少场景有零个步骤,有多少场景的步骤状态是 undefined 或 skipped。这告诉你在哪里查找。
最安全的长期修复方案是将任何意外的跳过视为构建失败。一个针对 JSON 报表的小型后处理器就能做到:
// scripts/fail-on-skips.js
const fs = require('fs');
const report = JSON.parse(fs.readFileSync('report.json', 'utf8'));
let unexpectedSkips = 0;
for (const feature of report) {
for (const element of feature.elements) {
if (element.type !== 'scenario') continue;
const hasSkip = element.steps.some(s => s.result?.status === 'skipped');
const hasUndefined = element.steps.some(s => s.result?.status === 'undefined');
// Allow @wip to skip; everything else must run
const isWip = element.tags?.some(t => t.name === '@wip');
if ((hasSkip || hasUndefined) && !isWip) {
console.error(`Unexpected skip: ${feature.name} > ${element.name}`);
unexpectedSkips++;
}
}
}
if (unexpectedSkips > 0) {
console.error(`\n${unexpectedSkips} scenario(s) skipped unexpectedly. Failing build.`);
process.exit(1);
}
在 CI 中每次调用 Cucumber 之后运行这个。它会在 tag 配置错误、缺失 step definition 和失败 hook 悄无声息地腐蚀你的套件之前捕获它们。
权衡:为什么有些跳过是可以接受的
有意的跳过是一种合法工具。@wip tag 存在是有原因的。你先写场景,再实现代码,标记为 @wip,让 runner 跳过它直到代码就绪。
健康的跳过和套件腐烂之间的区别在于卫生。@wip 应该是临时的。它应该活在分支上,而不是在主分支上待六个月。如果你默认分支上 50% 的场景都标记了 @wip,那你拥有的不是一个测试套件,而是一份愿望清单。
基于 tag 的过滤对环境特定的测试也有意义。一个需要实体支付终端的场景不应该在 CI 中运行。但这个场景应该标记为 @hardware,而不是 @slow 或 @manual。对排除的原因要明确,并在代码审查中像审查代码本身一样审查这些排除项。
如何阻止腐烂
如果你现在有 50% 的场景被跳过,这里是最快的回到诚实状态的路径。
-
不带 tag 运行 dry run。统计
undefined的步骤数量。修复每一个不匹配。这通常只需要五分钟的查找替换。 -
审计你的 runner 配置。列出
cucumber.js、Mavenpom.xml或 Gradle 配置中的每个 tag 表达式。确认每一个都是故意的。用负向过滤(--tags 'not @wip')替换正向过滤(--tags '@regression'),这样新场景默认会运行。 -
将跳过检查脚本添加到 CI。让它在任何意外跳过时失败构建。这是一次性设置,收益永久。
-
安排每月 tag 审计。在你的 feature 文件中搜索
@wip并统计数量。如果数字在增长,你有一个流程问题,而不是工具问题。
FAQ
为什么 Cucumber 在 step definition 缺失时跳过场景而不是让它们失败?
Cucumber 将缺失的 step definition 视为不完整的规范,而不是代码缺陷。场景被跳过是因为 Cucumber 无法执行它不理解的东西。这是原始 Ruby 实现的历史行为,并在各个移植版本中延续。让它失败的唯一方法是添加一个检查后处理器来查找 undefined 状态。
被跳过的场景和 pending 场景有什么区别?
在现代 Cucumber 版本中,“pending” 是一个步骤级状态,由 step definition 显式抛出(例如在 Ruby 中调用 pending() 或在 JavaScript 中返回 'pending')。“Skipped” 是一个场景级状态,当某个前置条件失败、某个 tag 过滤排除了场景,或某个前置步骤失败时应用。实际上,两者都显示为黄色,都意味着”这个没有运行到完成”。
我能让 Cucumber 在缺失 step definition 时失败吗? 在大多数 Cucumber 移植版本中没有内置的 CLI 标志来实现这个。你必须自己解析 JSON 或 JUnit 输出并让构建失败。本文中的脚本是一个最小可用示例。
如何找到我的套件中实际使用的所有 tag?
在你的 feature 文件上运行 grep:grep -roh '@[a-zA-Z0-9_-]*' features/ | sort | uniq -c | sort -rn。这会给出每个 tag 的频率表。将它与你的 runner 配置交叉对比,以找出那些没有文档说明却在过滤的 tag。