あなたのGherkin仕様は、あなたに嘘をついている。

故意ではない。最初は忠実だった。しかし6スプリント後、誰かがチェックアウトフローをリファクタリングし、When the user submits payment ステップの更新を忘れた。.feature ファイルはまだパスする。ステップ定義がまだ存在するからだ。ただ、それが呼び出すコードは、シナリオが実際に記述している内容と一致しなくなっている。テストはグリーンで、自信は偽物だ。これは、BDDに能動的に抵抗しない限りのデフォルトの軌道である。

問題は開発者が怠惰だからではない。.feature ファイルとステップ定義の関係が根本的に緩やかだからだ。Gherkinシナリオは文字列である。ステップ定義はそれらの文字列にマッチする正規表現またはアノテーションである。シナリオの変更に対応するコード変更が必要であることを強制するコンパイラは存在しない。その逆もまた然りだ。ツールチェーンは、あなたが手動で両者を整合させることを前提としている。あなたはそうしない。

なぜ手動の規律はスケールで破綻するのか

すべてのチームは同じ計画から始まる。仕様を書き、ステップを実装し、両方を一緒に更新する。これは最初の週は機能する。

リファクタリングの際に崩壊する。コード内のドメイン概念の名前を変更しても、Gherkinは古い用語を使い続ける。変更するには12のfeatureファイルを更新し、プロダクトと再レビューしなければならないからだ。あるいは新しいバリデーションルールを抽出しても、既存のシナリオが暗黙的に古い動作に依存していて、誰も気づかない。ステップ定義がテストを通過させるためにこっそり一般化されたからだ。仕様は並行し、ますます不正確な宇宙になる。

コストは古くなったドキュメントだけではない。信頼だ。開発者がfeatureファイルが現実を記述していると信じなくなったら、読むのをやめる。そして書くのもやめる。そして、曖昧な名前のユニットテストと、ステークホルダーとの共有言語のない世界に逆戻りだ。

「同期している」とは実際には何を意味するのか

仕様を同期させることは、テストを通過させることではない。通過は簡単だ。同期とは3つのことを意味する。

  1. すべてのGherkinステップには、仕様が記述していることを実行する対応するステップ定義がある。
  2. すべてのステップ定義は、少なくとも1つのシナリオから実際に到達可能である。
  3. 仕様内の言語が、コードベース内の言語と一致している。

ほとんどのチームは最初の点だけを確認し、それも実行時に行う。3つすべてを確認する必要があり、コードがマージされる前のCIで行う必要がある。

厳密なバインディングによる自動ステップ検証

Cucumberのようなツールにおける緩い文字列マッチングが根本原因だ。ステップ定義をビルドが検証できる第一級の参照にすることで、それを厳密にできる。

TypeScriptまたはJavaScriptプロジェクトでは、正規表現ベースのステップ定義を、Gherkinステップを実際の関数参照にマッピングする生成されたステップレジストリに置き換えることができる。重要なのは、そのマッピングが手書きではなく生成されることだ。そうすれば、シナリオが存在しないステップを参照した場合にビルドが失敗する。

カスタムパーサーと生成されたレジストリを使った最小構成を以下に示す。まず、ビルド時に .feature ファイルを解析する。

// scripts/validate-steps.ts
import { readFileSync, readdirSync } from 'fs';
import { parse } from '@cucumber/gherkin';
import { IdGenerator } from '@cucumber/messages';

const featureFiles = readdirSync('./features').filter(f => f.endsWith('.feature'));
const allSteps = new Set<string>();

for (const file of featureFiles) {
  const content = readFileSync(`./features/${file}`, 'utf-8');
  const gherkinDocument = parse(content, new IdGenerator());
  
  for (const feature of gherkinDocument.feature?.children || []) {
    for (const step of feature.scenario?.steps || []) {
      allSteps.add(step.text);
    }
  }
}

// Import the actual step registry from your test code
import { stepRegistry } from '../steps/registry';

const registeredSteps = new Set(Object.keys(stepRegistry));
const undefinedSteps = [...allSteps].filter(s => !registeredSteps.has(s));
const orphanedSteps = [...registeredSteps].filter(s => !allSteps.has(s));

if (undefinedSteps.length > 0) {
  console.error('Undefined steps:', undefinedSteps);
  process.exit(1);
}

if (orphanedSteps.length > 0) {
  console.error('Orphaned steps:', orphanedSteps);
  process.exit(1);
}

console.log(`Validated ${allSteps.size} steps against ${registeredSteps.size} definitions.`);

ステップレジストリは、正確なGherkinテキストによって関数を公開する。

// steps/registry.ts
import { given, when, then } from './step-helpers';

export const stepRegistry: Record<string, Function> = {
  'the user is logged in': given.theUserIsLoggedIn,
  'the user adds an item to the cart': when.theUserAddsAnItemToTheCart,
  'the total should be {int}': then.theTotalShouldBe,
};

givenwhenthen の各オブジェクトは、関数を持つ単純なモジュールである。正規表現の魔法はない。開発者がGherkinテキストを変更した場合、レジストリに対応するエントリを追加しなければならない。さもなければビルドは失敗する。シナリオを削除した場合、孤児ステップの検出が残った定義を捕捉する。

マージ前のCIへの統合

開発者がローカルで実行するスクリプトは、開発者が実行するのを忘れるスクリプトだ。検証がビルドを失敗させるようにする必要がある。

テストパイプラインに追加する。

# .github/workflows/ci.yml
jobs:
  validate-specs:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
      - run: npm ci
      - run: npx ts-node scripts/validate-steps.ts
      - run: npm test

重要な詳細は、validate-steps.ts が実際のテストスイートの前に実行されることだ。featureファイルとステップ定義の間に不整合がある場合、古いロジックで静かにパスする可能性のある100のCucumberシナリオを実行するのではなく、明確なエラーですばやく失敗したい。

リビングドキュメントには生成されたレポートが必要だ

検証は構文を整合させるが、仕様が読みやすく有用であることを保証しない。そのためには、featureファイルからHTMLレポートを生成し、mainへのマージごとに公開するリビングドキュメントパイプラインが必要だ。

Cucumber ReportsやPicklesのようなツールは、.feature ファイルを閲覧可能なドキュメントに変換できる。重要なのは、ドキュメントがCIが検証するのと同じファイルから生成されることだ。シナリオが削除されれば、ドキュメントから消える。言語が変われば、ドキュメントは自動的に更新される。維持すべき第二の信頼できる情報源は存在しない。

レポートをCIのアーティファクトとして公開するか、静的サイトにデプロイする。

# .github/workflows/docs.yml
jobs:
  publish-docs:
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main'
    steps:
      - uses: actions/checkout@v4
      - run: npm install -g @picklesdoc/pickles
      - run: pickles --feature-directory=./features --output-directory=./docs
      - uses: actions/upload-pages-artifact@v3
        with:
          path: ./docs

ステークホルダーは生のGherkinを読む必要はない。彼らは、最新であると信頼できる読みやすいページが必要だ。自動化がその信頼を構築する。

トレードオフ:厳密性対表現力

レジストリ方式にはコストがある。/^the user adds (\d+) items? to the cart$/ のような正規表現パターンの柔軟性を失う。すべてのバリエーションが明示的なエントリ、あるいは型付きプレースホルダーを持つパラメータ化されたステップになる。これは冗長だ。

代替案は、正規表現を維持しつつ、パターンが広すぎる場合やステップテキストが既知のパターンにマッチしない場合に警告を出すより厳格なリンターを追加することだ。Cucumberに組み込まれた dry-runpublish フラグを使用し、未使用のステップ定義をチェックするカスタムリンターを組み合わせることで、20%の冗長性で80%の安全性を得られる。

# Dry-run parses all features without executing them, surfacing undefined steps
npx cucumber-js --dry-run

これはレジストリ方式ほど厳格ではない。未定義のステップは捕捉するが、孤児ステップは捕捉せず、意味的な整合性も強制しない。大規模な既存スイートを持つチームにとっては、実用的な出発点だ。新規プロジェクトでは、レジストリ方式は1か月以内に実を結ぶ。

試して失敗したこと

コードコメントからGherkinを生成する実験をした。開発者がテストメソッドにアノテーションを付け、ツールが .feature ファイルを生成するというアイデアだ。失敗した。なぜならGherkinは非開発者にも読めるべきだからだ。メソッド名から生成された散文は読めない。散文ですらない。

仕様変更ごとにペアプログラミングを義務付けることも試した。効果はあったが、スケールしなかった。問題は機械的であり、修正も機械的であるべきだ。

今日から未定義ステップの検出を始めよう

既存のCucumberスイートを持っているなら、最小で最も有用な変更は、CIパイプラインに --dry-run を追加することだ。5分で完了し、最も一般的な漂流を捕捉する。すなわち、リファクタリングされたシナリオがどのステップ定義にもマッチしなくなった場合だ。

新規に始めるなら、レジストリベースのアプローチを検討する。明示的なマッピングの前払いコストは、ビルド時の保証と、仕様が静かに陳腐化するのを心配せずに自由にリファクタリングできる自信によって支払われる。

Gherkin仕様は、システムが何をするかを記述するべきだ。それを信頼できないなら、それらは高価なコメントに過ぎない。それらを正直に保つチェックを自動化するか、それらがあなたに嘘をつくことを受け入れるかだ。