你有一半的 Cucumber scenario 被跳過。不是失敗,是被跳過。

那個黃色的狀態比紅色的 build 還危險。它讓你的測試套件看起來很健康,卻把三種完全不同的問題藏在同一個禮貌的標籤底下。一個被跳過的 scenario 可能是你的 tag filter 把它排除了、某個 step definition 遺失了,或是一個 Before hook 在第一步還沒跑之前就拋出了例外。你的任務是找出是哪隻寄生蟲住在你的套件裡。

為什麼「Skipped」是 Cucumber 最危險的狀態

Cucumber 對一個 scenario 有三種終端狀態:passed、failed 和 skipped。Passed 和 failed 是誠實的。Skipped 是垃圾桶。

當一個 scenario 被標為 skipped,Cucumber 是在告訴你某個 prerequisite 沒有被滿足。問題在於,那個「prerequisite」可以是任何東西——從刻意的 tag filter 到 setup hook 裡的空指標。一個有 50% scenario 被跳過的套件看起來像是跑了 500 個測試。其實沒有。它只跑了 250 個測試,然後對另外 250 個揮揮手,完全沒檢查它們。

大多數 CI 儀表板把 skipped 當成中性狀態。你的 pipeline 是綠的。但你有一半的行為規格沒有被驗證。如果你把 Cucumber 當成活文件,那你有一半的文件是謊言。

大量 Skipped 的三個根本原因

Cucumber 產生 skipped scenario 的方式只有三種。其中兩個是 bug,一個是看起來像功能的錯誤設定。

Tag filter 排除的範圍比你預期的更大

50% scenario 被跳過最常見的原因,是 runner config 裡的 tag expression 比你想的更寬。你的 CI profile 可能長這樣:

// 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 filter 的時候。像 --tags '@regression' 這種 filter 只會跑標了 @regression 的 scenario,其他的全部變成 skipped。你就是這樣在完全沒改任何 feature file 的情況下,從 100% 執行率掉到 50% 的。

不再匹配的 Step definitions

如果任何一個 step 找不到對應的 step definition,Cucumber 會跳過整個 scenario。這不是失敗,這是 skip。你的 CI 維持綠燈。這發生的頻率比你預期的高。一個產品經理為了修正 typo 改了 Gherkin 檔案裡的一個 step:

# 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 file 裡少了 “the”。Cucumber 的 regex 不再匹配。整個 scenario 被跳過。把這種情況乘上一個會重構 feature file 卻不在本地跑完整套件的團隊,你可以因為 drift 而失去一半的覆蓋率。

失敗的 Hooks 把自己偽裝成 Skips

如果一個 Before hook 拋出例外,Cucumber 會跳過該 feature file 裡的每一個 scenario。它不是標為 failed,而是標為 skipped。

// features/support/hooks.js
const { Before } = require('@cucumber/cucumber');

Before(async function () {
  this.browser = await chromium.launch();
  this.page = await this.browser.newPage();
  // 如果這裡拋出例外,檔案裡的每個 scenario 都會被跳過
  await this.page.goto(process.env.TEST_URL);
});

如果 CI 裡的 TEST_URL 沒有定義,hook 就會拋出例外,所有掛在它上面的 scenario 都被跳過。你的 build 是綠的。你的測試毫無價值。

如何診斷你感染了哪種寄生蟲

你不知道是三種原因中的哪一個,就無法修復 50% 被跳過的 scenario。Cucumber 的預設輸出不會告訴你。你必須逼問它。

不加任何 tag filter,用 --dry-run 跑你的套件:

npx cucumber-js --dry-run --tags ''

Dry run 會解析每個 feature file 並把每個 step 匹配到 step definition,但完全不執行任何東西。如果顯示 100% undefined,那就是 step definition drift。如果顯示 100% passed,那你的 skips 來自 tag filter 或 runtime hook。

JSON formatter 會為每個 step 包含一個 status 欄位。被 tag filter 跳過的 scenario 完全不會有任何 step。被缺少 step definition 跳過的 scenario 會在不匹配的 step 上顯示 undefined。被失敗 hook 跳過的 scenario 會在每個 step 上顯示 skipped,而且 embeddings 陣列裡可能包含 hook 的錯誤訊息。

npx cucumber-js --format json:report.json

解析 JSON,然後數有多少 scenario 的 step 數量是零,又有多少 scenario 的 step 狀態是 undefinedskipped。這會告訴你該往哪裡找。

最安全的長期解法是:把任何非預期的 skip 都當成 build failure。一個簡單的 JSON report 後處理器就能做到:

// 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');

    // 允許 @wip 跳過;其他一切都必須執行
    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 默默腐蝕你的套件之前就把它們抓出來。

權衡:為什麼有些 Skipping 是合理的

刻意的 skipping 是一種合理的工具。@wip tag 的存在是有原因的。你先寫 scenario 再寫實作,標上 @wip,讓 runner 跳過它,直到程式碼準備好。

健康 skipping 和套件腐敗之間的差別是衛生。@wip 應該是暫時的。它應該活在分支上,而不是在 main 上待六個月。如果你預設分支上有 50% 的 scenario 標了 @wip,那你沒有測試套件。你只有願望清單。

基於 tag 的過濾對環境特定測試也有道理。一個需要實體刷卡機的 scenario 不應該在 CI 裡跑。但這種 scenario 應該標 @hardware,而不是 @slow@manual。對於為什麼某個東西被排除,要明確說明,並且在 code review 裡像審查程式碼一樣審查這些排除條件。

如何阻止腐化

如果你今天已經有 50% 被跳過,這裡是回到誠實的最快路徑。

  1. 不加任何 tag 跑一次 dry run。數 undefined step 的數量。修復每一個不匹配。這通常只需要五分鐘的 find-and-replace。

  2. 審查你的 runner profile。列出 cucumber.js、Maven pom.xml 或 Gradle config 裡的每一個 tag expression。確認每一個都是刻意的。把正向 filter(--tags '@regression')換成負向的(--tags 'not @wip'),讓新的 scenario 預設會被執行。

  3. 把 skip-check 腳本加進 CI。讓它對任何非預期的 skip 標為 build failure。這是一次性的設定,但會永遠有回報。

  4. 排定每月 tag 審查。在你的 feature file 裡搜尋 @wip 並計算數量。如果數字在成長,那你有的不是工具問題,而是流程問題。


FAQ

為什麼 Cucumber 在缺少 step definition 時是跳過 scenario,而不是標為失敗? Cucumber 把缺少的 step definition 視為不完整的規格,而不是程式碼缺陷。Scenario 被跳過是因為 Cucumber 無法執行它不懂的東西。這是原始 Ruby 實作的歷史行為,並延續到所有 port。唯一能讓它變成失敗的方法是加一個後處理器來檢查 undefined 狀態。

Skipped scenario 和 Pending scenario 的差別是什麼? 在現代 Cucumber 版本中,「pending」是一種 step-level 狀態,由 step definition 明確拋出(例如在 Ruby 裡呼叫 pending() 或在 JavaScript 裡回傳 'pending')。「Skipped」則是 scenario-level 狀態,在 prerequisite 失敗、tag filter 排除 scenario,或前面的 step 失敗時套用。實務上,兩者都顯示為黃色,也都代表「這個沒有跑完」。

我可以讓 Cucumber 對缺少的 step definition 直接標為失敗嗎? 大多數 Cucumber port 沒有內建的 CLI flag 能做到這點。你必須自己解析 JSON 或 JUnit 輸出,然後讓 build failure。這篇文章裡的腳本就是一個最小可行範例。

我要怎麼找出我的套件裡實際用了哪些 tag? 在你的 feature file 裡跑 grep:grep -roh '@[a-zA-Z0-9_-]*' features/ | sort | uniq -c | sort -rn。這會給你每個 tag 的使用頻率表。把它跟你的 runner profile 交叉比對,找出那些沒有文件就開始過濾的 tag。