11
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

重複率0%でも安心できない?Cucumberのステップ定義に潜む重複を見つける

11
Posted at

はじめに

CukeDedupは、Cucumber/Gherkinプロジェクトのステップ定義を静的解析し、重複や曖昧さ、再利用の可能性、未使用の定義を見つける CLI ツールです。Rust で書いていて、MITライセンスで GitHub に公開しています。

設定ファイルは不要です。テストプロジェクトのルートで次のコマンドを実行するだけで動きます。

npx cuke-dedup .

image.png

サンプルのシナリオは日本語のGherkin(# language: jaで書いていますが、英語の場合とまったく同じように動きます。

この記事では、なぜこのツールを作ったのか、そして実際のOSSプロジェクトで動かしてみて何を学んだのかを、個人的な経緯を中心に書きます。詳しい使い方や内部実装は、別の記事で改めて書く予定です。

きっかけ:Gherkinは書きやすいが、整理し続けるのは難しい

以前、フロントエンドのリードとして E2E テストの導入を支援したとき、Cucumber と Gherkin を選びました。目的はテストの自動化だけではありませんでした。QA やプロダクトのメンバーもシナリオの議論に参加できるように、共通の言葉で振る舞いを書ける環境を作りたかったのです。

Feature: User authentication

  Scenario: Accessing a protected page
    Given the user is authenticated
    When the user accesses the dashboard
    Then the dashboard should be visible

この狙い自体はうまくいきました。ただ、以前の経験から予想していた問題も出てきました。新しいテストを書くのは簡単でも、大きくなったスイートを整理された状態に保つのは別の話です。

Cypress や Playwright を使った基本的なテストの書き方は、経験の浅いエンジニアでも比較的早く身につけられます。難しいのは「すでに何があるか」を把握することです。新しいステップを実装する前に、似たステップがないか、既存の実装を再利用やパラメータ化できないかを判断するには、コードベースの知識が必要です。

Given('the user is logged in', async () => {
  await login();
});

Given('the user is authenticated', async () => {
  await login();
});

Given('an authenticated user', async () => {
  await login();
});

どれも正しく動きます。でも、3つも必要でしょうか。

経験のあるエンジニアならすぐに気づきますが、プロジェクトに入ったばかりの人には分かりにくいものです。その結果、こうした点の多くはコードレビューで初めて指摘されることになります。レビュアーとして私が一番時間を使っていたのも、まさにこの部分でした。

コードの重複と、振る舞いの重複は違う

私はリンターや静的解析ツールを積極的に使う派です。ルールがあっても検証する手段がなければ、開発者は PR を出してから初めて違反に気づきます。レビュアーは、機械でも見つけられる問題の指摘に時間を使うことになります。ルールが「レビュアーの解釈」ではなく「プロジェクトの明示的な合意」になれば、チーム内の余計な摩擦も減ります。

当時はESLint、Danger.js、jscpdを組み合わせていました。特にjscpdはコードの重複検出に役立ちましたが、限界もありました。jscpdで重複率が0%でも、次のようなステップ定義は残ります。

When('I create a red project', async () => {
  await createProject('red');
});

When('I create a blue project', async () => {
  await createProject('blue');
});

これは一つのパラメータ化されたステップにまとめられるかもしれません。

When('I create a {word} project', async color => {
  await createProject(color);
});

逆に、文言がとても似ていても、統合すべきではないステップもあります。ドメイン上は別の概念を表しているかもしれないからです。必要だったのは、文言の近さだけでなく、実装の特徴も合わせて見るツールでした。

手元のスクリプトからOSSへ

最初は小さなスクリプトの寄せ集めでした。文字列の類似度を計算する、表記揺れを正規化する、使われていない定義を探す、ハンドラー同士を簡単に比べる、といったものです。それなりに役立ちましたが、スイート全体の構造や再利用の機会を見渡せるものではありませんでした。

現在の職場で新しい E2E テスト基盤の構築を手伝ったとき、同じ問題にまたぶつかると分かっていました。話してみると、ほかのチームの人たちも同じような悩みを抱えていました。そこで、これまでの経験を一つのツールにまとめると決めました。

最初から OSS にするつもりでした。自分だけが使うスクリプトなら、制限を知っているので雑でも構いません。でも公開するなら、私の経緯を知らない人でも理解して使えるものでなければなりません。この責任が、設計を整理し直す良いきっかけになりました。

それまで公開してきたのは比較的小さなプロジェクトが中心だったので、もう少し複雑なツールを設計し、継続して育てていくこと自体も、自分にとって楽しみな挑戦でした。

もう一つ、個人的に Rust で本格的なツールを作ってみたかったという理由もあります。開発中に何度も実行するツールなので、速度とメモリをきちんと制御できる言語にしたかったのです。パーサーには tree-sitter を使っています。途中で oxc や swc への置き換えも検討しましたが、構文エラーがあるファイルからも解析できる部分は取り出せる、tree-sitter のエラー回復をそのまま活かすことにしました。

設計で大事にしたこと

設定なしで使い始められること

お手本にしたのはjscpdの体験です。jscpd .と打つだけで、役に立つ情報が出てきます。重複を探したい人は自分のプロジェクトの問題を解決したいのであって、新しいツールの設定方法を覚えたいわけではありません。

CukeDedupは.gitignoreを尊重し、一般的なディレクトリ構成やCucumber.js・Playwright-BDD・Cypress Cucumberなどの設定を自動で認識します。設定ファイルは、例外や細かい調整のためにあります。使い始めるための前提条件ではありません。

判断ではなく、根拠を出すこと

「この2つは似ています」と言うだけのレポートにはしたくありませんでした。定義がどこにあるか、どのマッチャーを比べたか、どんな根拠でそう判断したかを示し、可能であれば検討する価値のあるアクションも添えます。

ただし、最終判断はエンジニアに委ねます。実装が似ていても、ドメイン上は別の概念かもしれません。すべての抽象化の機会を活かすべきだとも限りません。

「分からない」と言えること

静的解析では、実行しないと分からないことがあります。動的な設定、ラッパー関数、解決できない import などです。こうした場合、CukeDedupは「問題なし」とは言わず、解析が不完全(incomplete)だったことを報告します。--fail-on-incompleteを付ければ、CIでこれを失敗として扱えます。

検出結果ゼロは、問題ゼロを意味しません。ツールが確信を持てないときに、すべて正しいかのような印象を与えるよりは、正直に「判断できなかった」と伝える方がいいと考えています。

検出できるもの

現在のバージョン(v0.12.0)には8つのルールがあります。完全一致の衝突はデフォルトでエラー、ヒューリスティックによる検出はデフォルトで警告です。

ルール 見つけるもの
duplicate-matcher 同じマッチャーを持つ定義
normalized-matcher 正規化すると同じになるマッチャー
duplicate-handler マッチャーは違うが、実装が同じ定義
near-duplicate-step マッチャーと実装の両方が近い定義
parameterization-candidate マッチャーと実装の構造から、パラメータ化による再利用が考えられる定義
ambiguous-step 一つのステップに複数の定義がマッチする状態
overlapping-matcher 同じ文字列にマッチし得るマッチャーの組
unused-definition どのステップにもマッチしない定義

duplicate-handler は、必ずしも定義を削除せよという意味ではありません。ドメインの言葉として別々の言い回しを残したい場合もあります。その場合でも、実装を共有するきっかけにはなります。

もちろん、CukeDedupが自然言語の意味を理解しているわけではありません。たとえば logged inとauthenticatedの意味が同じかどうかは判断していません。報告の根拠は「実装が同じである」ことなどの静的な情報で、そこから確認する価値のある関係を示しています。

unused-definitionも、解析対象に含めたファイルが前提です。対象外の feature ファイルで使われている可能性があるため、そうした範囲の扱いも含めて慎重に報告するようにしています。

指摘を直したら、同じコマンドをもう一度実行するだけです。

image.png

使ってみる

JavaScript/TypeScriptプロジェクトでは、npm からビルド済みのネイティブバイナリを利用できます。Rustの開発環境がある場合は、Cargoからソースコードをビルドしてインストールすることもできます。

# npm
npm install --save-dev cuke-dedup
npx cuke-dedup .

# Cargo
cargo install cuke-dedup
cuke-dedup .

対応しているのは Cucumber.js、Playwright-BDD、Cypress Cucumber、Jest-Cucumber、Vitest Cucumber Pluginです。通常の.featureファイルに加えて、Gherkin Markdown(.feature.md)も読めます。

レポートは用途に合わせて選べます。

形式 主な用途
terminal 開発中の素早いフィードバック、CI ログ
html 検索できるレポートでの目視確認と共有
json 構造化された連携
jsonl シェルや AI エージェントでのストリーム処理
sarif GitHub code scanning などのプラットフォーム
cuke-dedup . --reporters terminal,html

image.png

CI に組み込む

GitHub Action を用意しています。ネイティブバイナリをダウンロードし、チェックサムとビルドプロベナンスを検証してから実行します。最小構成のワークフローは次のとおりです。

# .github/workflows/cuke-dedup.yml
name: CukeDedup
on: pull_request

permissions:
  contents: read

jobs:
  cuke-dedup:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
        with:
          fetch-depth: 0
          persist-credentials: false
      - uses: figueiredoluiz/cuke-dedup@v0.12.0
        with:
          threshold: 5

終了コードは3種類です。0は品質ゲート通過、1は閾値やエラールールで失敗、2は設定や解析ができなかった運用上のエラーです。警告だけでは1になりません。

配布物にも気を使っています。リリース成果物にはSHA-256チェックサム、Sigstoreの署名、GitHubのビルドプロベナンスを付けています。開発者のマシンやCIで動くツールなので、入手したバイナリが本物かを検証できるようにしておきたかったのです。

既存プロジェクトにはベースラインを

レガシーなプロジェクトで、既存の重複がすべて解消されるまでツールの導入を待つのは難しいでしょう。そこでベースライン機能を用意しました。今ある検出結果を記録しておき、新しく増えたものだけを失敗にできます。技術的負債を一度に返すのではなく、まず「これ以上増やさない」ところから始められます。

image.png

実際のOSSで動かして分かったこと

自分で用意した例だけでテストすると、ツールは都合よく正しく動きます。そこで、Cucumber系のツールを使っているJavaScript/TypeScriptのOSSリポジトリ92件で動かしてみました。生態系全体の統計を取ることが目的ではありません。自分の知らない書き方に、ツールをぶつけてみたかったのです。

そこで見えてきたのは、CukeDedup自体の改善点でした。

命名規則が揃っていると「似て見える」

あるプロジェクトでは、near-duplicate-stepの検出が138件あり、そのすべてでマッチャーの類似度が0.90以上でした。一貫した命名規則で、別々の要素に対して同じ形のステップを書いていたためです。

文言の近さだけでは足りないと分かり、実装(ハンドラー)の類似度の下限を 0.50 から 0.70 に引き上げました(設定で変更可能)。誤検知の可能性が高い検出を減らすための見直しで、調査対象のコーパスでは、このルールの検出結果が約半分になりました。当時の回帰テストでは、期待する検出結果を維持できています。

値を消すと、無関係な実装が同じに見える

parameterization-candidateでは、実装の一部のリテラル値を抽象化して構造を比較します。すると、cy.log(...) を1回呼ぶだけのような無関係な小さいハンドラーまで「パラメータ化できる」と判定されていました。あるプロジェクトでは、352件の検出のうち169件がこのルールでした。

そこで、マッチャーの種類が同じであること、notやun-のような否定の対立がないこと、違いが1か所にまとまっていることを条件に加えました。調査対象のコーパスでは、このルールの検出結果が約40%減り、期待する検出結果は回帰テストで維持できています。汎用的な抽象化をむやみに勧めるツールにはしたくない、という考えがはっきりしたのはこのときです。

「曖昧」の正体は、解析範囲の問題だった

2つのプロジェクトで、それぞれ 2,263件と1,563件のambiguous-stepが出ました。調べてみると、実行時には別々のスイートとして動く定義を、一つのレジストリとして扱っていたのが原因でした。実際の実行単位ごとに解析すると、0 件になりました。

直すべきだったのはアルゴリズムではなく、解析範囲の理解でした。静的解析ツールは、コードを正しく読むだけでなく、プロジェクトの構成の中でどの関係に意味があるかも理解する必要があります。

バンドル済みのjQueryで20分止まった

2つのリポジトリでは、20分以上待ってもレポートが出ませんでした。原因は、リポジトリに含まれていたバンドル済みのjQueryなどのファイルです。

最初はファイルサイズや行の長さで判定しようとしましたが、反例がすぐに見つかりました。89KB の実ファイルでは止まるのに、1MBの合成ファイルは数秒で終わるのです。原因は入力の大きさではなく、解析処理の計算量にありました。修正後、jQueryの解析は0.6秒で終わります。この調査の詳しい過程は、別の記事で紹介する予定です。

生成物と判断して内容ベースのフィルターで除外したファイルについては、その理由を報告し、解析が不完全だったことも明示します。黙って除外はしません。

メモリ使用量

2万件のステップ定義を使ったベンチマークでは、正規表現のコンパイル方法を見直し、ピークメモリを約6分の1に減らしました。

品質をどう守っているか

テストコーパスを「仕様」として扱う

あるルールを改善した結果、別の場所を壊してしまうことは珍しくありません。重複をよく見つけるようにした修正が、独立させておくべき実装まで報告し始めることもあります。逆に、誤検知を減らした修正が、本当の問題を見逃すようになることもあります。

そこで、期待する振る舞いを集めたコーパスを維持しています。「検出すべきケース」と「検出してはいけないケース」の両方を含めます。大事にしているのは、今の実装の出力をそのまま正解として固定するのではなく、望ましい振る舞いを書くことです。まだ対応できていないものは、既知の見逃し(known miss)として明示しています。OSSの調査で再現できる問題が見つかった場合は、できるだけ小さなテストケースに落とし込み、今後の回帰を防ぐようにしています。

そのテストは本当に問題を検出できるか

コーパスがあっても、テスト自体が回帰を検出できなければ意味がありません。そこで、判定に関わる処理をわざと壊し、テストが失敗することを確認しています。壊しても緑のままのテストは、何も守っていないからです。

特に注意しているのは「検出してはいけない」ケースです。比べる対象がそもそも解析されていなくても、結果は同じく「検出なし」になります。そのため、そのケースが本当に意図した違いだけを確かめているかを、対になる「検出すべき」ケースと合わせて確認するようにしています。

AIエージェントと一緒に使う

AIエージェントは、テストを書くこともリファクタリングを提案することも、かなり上手になりました。だからこそ、速くて安い決定論的な静的解析によるガードレールが大切になると考えています。ツールで確実に見つけられる重複を、エージェントに何百ものファイルを読ませて探させる必要はありません。ツールが根拠を出し、エージェントは解釈が必要な判断に集中する。役割分担です。

CukeDedupはJSONL形式で、場所・根拠・推奨アクションを含む結果を出力できます。また、エージェント向けのAgent Skillも用意しています。

npx skills add figueiredoluiz/cuke-dedup --skill cuke-dedup

このスキルは、検出結果を「似ているものはすべて統合せよ」という指示ではなく、判断の根拠として扱うようにエージェントを導きます。変更する前に、関係するファイルを必ず確認させる点も同じ考え方です。

これから

現在は JavaScript・TypeScriptを中心に対応しており、Cucumber-Ruby も実験的にサポートしています。今後は対応フレームワークを増やし、JavaやPHPなど、ほかの言語への展開も検討していきたいと考えています。

ほかにも、次のようなことを考えています。

  • 誤検知の削減と、テストコーパスの拡充
  • OSSプロジェクトを調査したレポートの公開
  • 調査で見つけた問題を、それぞれのプロジェクトと一緒に直していくこと

どれも、最初から大事にしてきた「導入の手軽さ、柔軟さ、速さ」を保ったまま進めたいと思っています。

おわりに

CukeDedupを作ったのは、コードレビューで同じ指摘を繰り返す時間を減らし、本当に判断が必要な議論に時間を使いたかったからです。それを OSS にしたことで、学んだことを共有する機会にもなりました。

CucumberやGherkin、対応しているフレームワークを使っている方は、ぜひ一度試してみてください。うまく解析できなかったケースも、改善のための貴重なフィードバックになります。誤検知の報告、まだ対応していない書き方の共有、機能の提案、コードでの貢献、どれも歓迎です。

なお、CukeDedupは個人の独立したプロジェクトで、Cucumberプロジェクトやそのメンテナーとは関係ありません。

参考

11
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
11
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?