📊

E2E自動テスト基盤にPRの複雑度判定を導入した

に公開

とある1on1で「PRの複雑度を判定して、複雑度が低いものはAIレビューで完結しても良い、というルールにしたら副次的にPRサイズが小さく/シンプルになった」という話を聞きました。

プロダクトコードにおいては、スコープをかなり絞りやすいですが、E2Eテストではどうしてもspecファイルが大きくなってしまったり、Page Objectなどのデザインパターンによって作成されるファイルや変更が大きくなりがちです。

さらに、specファイルを1つ追加するだけのPRもあれば、共通のfixtureやPage Objectを横断的に変更するPRもある。行数だけ見ても重さがわからない。

fixtureを5行変えるPRと、specを100行追加するPR。
前者の方がよほど怖いんですよね。全テストに影響するから。

この「怖さ」を数値化してPRレビューの工数見積もりの精緻化と心理的に覚悟を決められるように…と考えて、PR複雑度のスコアリングを作ることにしました。

2つのメトリクスを組み合わせる

スコアは「変更リスクスコア」と「Cognitive Complexity」の2つを足し合わせる構成にしました。

最終スコア = 変更リスクスコア + (Cognitive Complexity合計 x 2.0)

変更リスクスコアは「どこを・どれだけ変えたか」を捉える。Cognitive ComplexityはSonarSource社が提唱しているメトリクスで、コードの認知的な読みにくさを数値化するもの。

どちらか単体では足りなくて、変更リスクスコアだけだと「10行だけど極めて複雑なロジック」を見逃すし、Cognitive Complexityだけだと「単純だけど影響範囲の広い変更」を見逃す。
両方を足し合わせることで、レビュー負荷をより正確に見積もれるようになりました。

変更リスクスコアの設計

変更リスクスコアは 変更行数 x ディレクトリリスク係数 x ファイル種別補正 で計算します。

ディレクトリリスク係数

E2Eテストプロジェクトの構造に合わせて、5段階のリスクレベルを定義しました。

fixtures/**やCI設定は全テストに影響する基盤コードなので係数5.0。utils/**components/**など複数テストで共有されるコードは3.0。
個別のPage Objectは1.5。specは1.0。ドキュメントやツール設定は0.0にしてスコアから除外しています。

package-lock.jsonも0.0です。数千行の差分が出ることがありますが、人間がレビューする対象ではないので・・・。

(arXiv 2602.13170の研究では、コードの5%未満が欠陥の50%以上を占める「ホットスポット」の存在が報告されています。fixtureや基底クラスはまさにこれに該当するので、係数5.0という高い重みを付けています。)

ファイル種別の補正

新規ファイルか既存ファイルの修正かでも性質が変わります。

新規specの追加は既存コードへの影響が小さいので0.5。新規Page Objectは0.8。既存ファイルの修正は1.0。削除は0.3。

具体的な計算例を見てみましょう。

項目 行数 係数 補正 スコア
新規spec追加 100 x1.0 x0.5 50
既存Page Object修正 30 x1.5 x1.0 45
fixture修正 5 x5.0 x1.0 25

たった5行のfixture修正が、100行の新規spec追加の半分のスコアになります。直感的にも「fixtureを5行変える方が、specを100行追加するより怖い」というのは納得感があるのではないでしょうか。

Cognitive Complexity

なぜCyclomatic Complexityではないのか

コード複雑度の指標としてはMcCabeのCyclomatic Complexity(循環的複雑度)が歴史的に有名です。しかし今回はCognitive Complexityを採用しました。

Cyclomatic Complexityは分岐の数を数えるだけなので、switch文で10ケースあれば10になる。でも人間にとってswitch文は比較的読みやすい構造です。一方、ifの中にforがあり、その中にさらにifがある・・・というネストの深い構造は、分岐数が少なくても認知負荷が高い。

Cognitive Complexityはこのネストによるペナルティを加味します。人間が「読みにくい」と感じるものを、より正確に拾ってくれる。

ESLintによる計測

eslint-plugin-sonarjsを使い、閾値0で全関数のスコアを取得する専用のESLint設定を用意しました。

export default [
  {
    files: ['**/*.ts'],
    languageOptions: {
      parser: typescriptParser,
      parserOptions: {
        ecmaVersion: 'latest',
        sourceType: 'module',
      },
    },
    plugins: { sonarjs },
    rules: {
      'sonarjs/cognitive-complexity': ['warn', 0],
    },
  },
];

閾値0にするとCognitive Complexityが1以上の全関数がwarningとして報告されるので、JSON出力から各関数のCognitive Complexity値を取得できます。

これに重み 2.0を掛けて変更リスクスコアに加算したものが最終スコアです。重み 2.0は試行錯誤の結果で、1.0だとロジックの複雑さが行数に埋もれるし、3.0だと小さな分岐でもスコアが跳ね上がりすぎました。

閾値と分類

最終スコアと変更ファイル数で3段階に分類します。スコア50以下かつ5ファイル以下ならlow。150以下かつ15ファイル以下ならmedium。それ以外はhigh。

SmartBear社とCisco社の共同研究では最適なPRサイズが200-400行とされていて、さらにPropel社の研究では1000行超のPRは欠陥検出率が70%低下するとのこと。これらを参考に、mediumの上限を「通常のレビューフローで対応可能な範囲」に設定しました。

新規specの係数0.5がここで効いてきます。

spec 1つ (100行): 100 x 1.0 x 0.5 = 50 → low
spec 3つ (300行): 300 x 1.0 x 0.5 = 150 → medium
spec 4つ (400行): 400 x 1.0 x 0.5 = 200 → high

spec 1-2個ならlow、3個ならmedium、4個以上はhigh。このラインが実務感覚としても妥当だったので、いい感じに落ち着きました。

GitHub Actionsでの自動化

PRが作られると、まず型チェックとESLintを通して、次に複雑度スコアリングを実行します。結果はPRにcomplexity:low(緑)、complexity:medium(黄)、complexity:high(赤)のラベルで付与され、加えてファイルごとのスコア内訳をテーブル形式でコメントとして投稿します。

analyze-complexity:
  needs: code-quality
  runs-on: ubuntu-latest
  steps:
    - run: node .github/pr-complexity/analyze.mjs
      env:
        GITHUB_BASE_REF: ${{ github.event.pull_request.base.ref }}

Draft PRでは実行しないようにしています。開発途中のPRに複雑度ラベルが付くと、かえってノイズになるためです。

既存のコメントがあれば更新、なければ新規作成するようにしたので、pushのたびにコメントが増えることもありません。<!-- pr-complexity-bot -->というマーカーをコメント本文に埋め込んで判別しています。

スコアリングのパラメータはすべてconfig.jsonに外部化してあります。コードを触らずに係数や閾値を調整できるので、運用しながらのチューニングが楽です。設定ファイルにはreferencesフィールドで根拠となる論文も記載していて、「なぜこの数値なのか」がconfig自体に残る設計にしました。後から参加したメンバーにも設計意図が伝わるように。

E2Eテスト特有の苦労

ここからが本題というか、汎用的な仕組みをE2Eテスト基盤に適用したときに出てきた問題の話です。

Page Objectの変更影響が測りにくい

E2Eテストでは以前の記事で紹介したようなfixtures設計でPage Objectを管理しています。Page Objectは複数のspecから参照されるので、1つのPage Objectの変更が何本のテストに波及するかが行数からは見えない。

結果的に、この影響範囲の把握はスコアリングとは別のツールとして作ることになりました。app.ts(fixtureでPage Objectを集約するファイル)をASTで解析して、アクセサ名とファイルパスのマッピングを構築し、各specファイルの使用アクセサと突き合わせることで「この変更はどのテストに影響するか」を特定します。

複雑度スコアリングでは、この影響範囲の大きさがディレクトリリスク係数として間接的に反映されている形です。

テストコードはCognitive Complexityが低い

これは割とE2Eテストあるあるだと思うのですが、テストコードって本質的に「手順の羅列」なんですよね。複雑な分岐やネストが少ない。だからCognitive Complexityだけでは変更の重さを捉えきれない。

これが2つのメトリクスを組み合わせた最大の理由です。テストコードの世界では、変更リスクスコアの方がより多くの情報を運んでくれる。

ただし、ポーリングやリトライロジックを含むユーティリティ関数ではちゃんとCognitive Complexityが高く出るので、そこでの値は有意な指標になっています。

導入後の変化

定量的な効果測定はまだですが、いくつか変化がありました。

complexity:highのラベルが付くと「分割しよう」という意識が自然に働くようになった。以前は「まとめて出した方が楽」だったのが、「小さいPRの方がレビューが早く回る」という認識に変わりつつあります。
ローカルでは、complexity:highのラベルが付いたものをいい感じにPRを分割するようなClaude Skillsを作っています。

またPR一覧でラベルの色を見て、「まずlowを片付けてからmediumに取りかかろう」というレビューの優先順位づけもできるようになりました。
会議やタスクが積まれている時に、少しでも前に進めるための切り分けがやりやすくなりました。

(完璧なメトリクスは存在しないと思っていますが、「レビューの負荷感をざっくり伝える」という目的には十分機能しています。)

今後やりたいこと

まだ検討段階ですが、いくつか。

過去のPR履歴から「一緒に変更されやすいファイル」を特定してスコアに反映する変更結合度(Change Coupling)の導入。Adam Tornhillの「Your Code as a Crime Scene」で提唱されているホットスポット分析のアプローチを参考にしています。

あとは、ASTベースの影響テスト数をスコアに直接統合すること。ファイル単位のリスク係数よりも精密な重み付けが期待できるので、ここはぜひやりたい。

Discussion