Cucumberのシナリオの半分がスキップされている。失敗ではない。スキップだ。

その黄色いステータスは、赤いビルドよりも悪い。1つの丁寧なラベルの下に、全く異なる3つの問題を隠しながら、テストスイートを健全に見せかける。スキップされたシナリオは、tag filterが除外したのか、step definitionが消失したのか、あるいは最初のstepが実行される前にBefore hookが例外を投げたのかもしれない。あなたの仕事は、どのparasiteがスイートに潜んでいるのか突き止めることだ。

なぜ「Skipped」はCucumberで最も危険なステータスなのか

Cucumberには、シナリオの3つの終端ステータスがある:passed、failed、skipped。Passedとfailedは正直だ。Skippedはゴミ箱だ。

シナリオがスキップされると、Cucumberはあるprerequisiteが満たされていないことを伝えている。問題は、その「prerequisite」が、意図的なtag filterからsetup hook内のnull pointerに至るまで、何にでもなりうることだ。50%のシナリオがスキップされたスイートは、500のテストを実行したように見える。だがそうではない。250のテストを実行し、残りの250には確認せず手を振っただけだ。

ほとんどのCIダッシュボードはskippedを中立的に扱う。パイプラインはグリーンだ。しかし、行動仕様の半分は検証されていない。Cucumberをliving documentationとして使っているなら、文書の半分は嘘だ。

大量のスキップを生む3つの根本原因

Cucumberでskippedシナリオを生成する方法は厳密に3つある。そのうち2つはバグだ。1つは機能のように見える誤設定だ。

意図以上に除外してしまうtag filter

50%のシナリオがスキップされる最も一般的な原因は、runner config内のtag expressionが思っているよりも広範囲であることだ。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(' '),
};

本当の被害は、チームがpositive tag filterを使ったときに起こる。--tags '@regression'のようなフィルターは、@regressionタグが付いたシナリオだけを実行する。他のすべてのシナリオはスキップされる。これが、1つのfeature fileも変更せずに、実行率を100%から50%に落とす方法だ。

マッチしなくなったstep definition

いずれかのstepにマッチするstep definitionがない場合、Cucumberはシナリオ全体をスキップする。これはfailureではない。Skipだ。CIはグリーンのままだ。これは予想よりも頻繁に起こる。プロダクトマネージャーがstepのタイプミスを修正するためにGherkin fileを編集する:

# 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 definitionはこれを期待している:

// features/step_definitions/checkout_steps.js
Given('a guest user with items in the cart', function () {
  // setup
});

feature fileに「the」が欠けていることに注目。Cucumberのregexはもはやマッチしない。シナリオ全体がスキップされる。ローカルでフルスイートを実行せずにfeature fileをリファクタリングするチーム全体でこれが増殖すると、coverageの半分をdriftで失うことになる。

スキップに見せかけるfailing hook

Before hookが例外を投げると、Cucumberはそのfeature file内のすべてのシナリオをスキップする。failさせるのではない。スキップするのだ。

// 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がundefinedの場合、hookは例外を投げ、それに紐づくすべてのシナリオがスキップされる。ビルドはグリーンだ。テストは無価値だ。

どのparasiteがいるのか診断する方法

3つの原因のどれが責任を負っているか知らなければ、50%のスキップは修正できない。Cucumberのデフォルト出力は教えてくれない。尋問しなければならない。

--dry-runとtag filterなしでスイートを実行しろ:

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

dry runはすべてのfeature fileを解析し、何も実行せずにすべてのstepをstep definitionにマッチさせる。100% undefinedと表示されたら、step definition driftがある。100% passedと表示されたら、スキップはtag filterかruntime hookから来ている。

JSON formatterは各stepにstatusフィールドを含める。tag filterによってスキップされたシナリオは、stepをまったく持たない。missing step definitionによってスキップされたシナリオは、マッチしないstepにundefinedと表示される。failing hookによってスキップされたシナリオは、すべてのstepにskippedと表示され、embeddings配列にhook errorが含まれているかもしれない。

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

JSONを解析し、zero stepsを持つシナリオと、undefinedまたはskippedステータスのstepを持つシナリオの数を数えろ。そうすればどこを調べればいいかわかる。

最も安全な長期的な修正は、予期しないスキップをすべてbuild failureとして扱うことだ。JSON report用の小さなpost-processorがその役割を果たす:

// 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 misconfiguration、missing step definition、failing hookを、テストスイートを静かに腐らせる前に捕捉するだろう。

トレードオフ:なぜ一部のスキップは許容されるのか

意図的なスキップは有効なツールだ。@wipタグには理由がある。実装の前にシナリオを書き、@wipとマークして、コードが準備できるまでrunnerにスキップさせる。

健全なスキップとsuite rotの違いは衛生管理だ。@wipは一時的であるべきだ。ブランチ上に存在すべきで、6か月もmainにいてはいけない。default branch上のシナリオの50%が@wipタグ付けされているなら、あなたはテストスイートを持っていない。wish listを持っているだけだ。

tag-based filteringは環境固有のテストにも意味がある。物理的な決済端末を必要とするシナリオはCIで実行すべきではない。しかし、そのシナリオは@hardwareとタグ付けされるべきで、@slow@manualではない。何が除外されるのかを明示的にし、コードそのものをauditするのと同じように、code reviewでそれらの除外をauditしろ。

腐敗を止める方法

今日50%スキップに陥っているなら、正直さを取り戻す最速の道はこれだ。

  1. タグなしでdry runを実行しろ。undefinedのstepを数えろ。すべてのミスマッチを修正しろ。これは通常、5分で終わるfind-and-replace作業だ。

  2. runner profileをauditしろ。cucumber.js、Maven pom.xml、Gradle configのすべてのtag expressionを列挙しろ。それぞれが意図的であることを確認しろ。positive filter(--tags '@regression')をnegative filter(--tags 'not @wip')に置き換えて、新しいシナリオがデフォルトで実行されるようにしろ。

  3. skip-check scriptをCIに追加しろ。予期しないスキップでビルドをfailさせろ。これは一度設定すれば永遠に報われる。

  4. 月次のtag auditをスケジュールしろ。feature fileで@wipを検索し、数えろ。数が増えているなら、それはtoolingの問題ではなくprocessの問題だ。


よくある質問

なぜCucumberはstep definitionが欠落しているときにシナリオをfailさせるのではなくスキップするのか? Cucumberはmissing step definitionをincomplete specificationとして扱い、code defectとは見なさない。Cucumberは理解できないものを実行できないため、シナリオはスキップされる。これは元のRuby実装からの歴史的な挙動であり、移植先でも継続されている。failさせる唯一の方法は、undefinedステータスをチェックするpost-processorを追加することだ。

skippedシナリオとpendingシナリオの違いは何か? 現代のCucumberバージョンでは、「pending」はstep definitionが明示的に投げるstep-levelステータスである(例えばRubyでpending()を呼ぶ、あるいはJavaScriptで'pending'を返す)。「skipped」は、prerequisiteが失敗したとき、tag filterがシナリオを除外したとき、または先行するstepが失敗したときに適用されるscenario-levelステータスである。実際には、両方とも黄色で表示され、「これは最後まで実行されなかった」という意味だ。

missing step definitionでCucumberをfailさせることはできるか? ほとんどのCucumber移植版にはこれ用の組み込みCLIフラグはない。JSONまたはJUnit outputを解析して、自分でビルドをfailさせる必要がある。この記事のスクリプトは最小限の動作例だ。

スイートで実際に使われているタグを見つけるにはどうすればいいか? feature file全体でgrepを実行しろ:grep -roh '@[a-zA-Z0-9_-]*' features/ | sort | uniq -c | sort -rn。これにより、すべてのタグの頻度表が得られる。それをrunner profileと照合して、ドキュメントなしでfilterしているタグを見つけろ。