コードレビューの負担を減らす自動チェック
OpenAPI、dependency-cruiser、Strykerのミューテーションテストでコードレビューを自動化。CIチェックで機械的な不具合を検出し、レビューの負荷を減らす方法を紹介します。
決定論的な形での自動コードレビューとは、コードがルールに違反したときにプルリクエストを失敗させる一連の CI チェックのことです。これにより、レビュアーは機械的なミスではなく、設計、意図、プロダクトとの適合性に時間を使えるようになります。
いまやコーディングエージェントは、人間が 1 行ずつ読むよりも速いペースでプルリクエストを作成します。リードの立場にあると、差分を形式的に承認するだけになるか、自分がボトルネックになるかのどちらかに陥りがちで、どちらも長くは続きません。
解決策は、機械的な問題は機械に検出させ、人間の注意はそれ以外のことに振り向けることです。こうしたチェックは、コードを誰が書いたかにかかわらず実行する価値があります。本記事では 3 つのチェックを取り上げ、さらに低コストで導入できる 2 つの追加チェックを紹介し、それらすべてを CI に組み込む方法を示したうえで、最後にこれらのどれでも検出できないものについて説明します。Lint、フォーマット、ユニットテストはすでに実行していることを前提とします。まだの場合は、こちらの ESLint と Prettier のセットアップから始めてください。
| チェック | 検出するもの | レビュアーが確認しなくて済むこと | ツール |
|---|---|---|---|
| コントラクトからのコード生成 | API が返さないフィールド | 各レスポンスフィールドが存在するか | Hey API openapi-ts |
| レイヤー境界 | アーキテクチャ上のレイヤーをまたぐ import | すべての import が正しい方向を向いているか | dependency-cruiser |
| ミューテーションテスト | コードを実行するだけでアサーションしていないテスト | 新しいテストが実際に振る舞いを検証しているか | Stryker |
| デッドコード | 未使用のファイル、エクスポート、依存関係 | 何かが孤立していないか | Knip |
| 重複 | コピー&ペーストされたブロック | 同じロジックがすでに存在しないか | jscpd |
重要なポイント
- TypeScript クライアントを OpenAPI 仕様から生成していれば、
user.fullNameのように API が返さないフィールドを読み取るコードは、レビューコメントではなくコンパイルエラーになります。 - dependency-cruiser で severity を
errorにしたforbiddenルールを設定すると、サービスがコンポーネントを import したときに CI が失敗します。err-longレポーターで実行すれば、各違反の横にルールのcommentが表示され、その import をどこに移すべきかを開発者に伝えられます。 - ラインカバレッジが示すのは、コードが実行されたということだけです。ミューテーションテストは、そのコードを意図的に壊したときに失敗するテストがあるかどうかを示します。
- ミューテーションスコアは検出器として扱い、Stryker の
thresholds.breakは設定しないでおきます。等価ミュータントが存在するため、厳格な閾値は誤解を招くからです。 - 新しいルールは、変更されたファイルに対する警告として導入します。実際のバグを検出したものをエラーに昇格させ、ジョブを必須にするのは運用が安定してからにします。
OpenAPI 仕様から API クライアントを生成する
TypeScript クライアントを OpenAPI 仕様から生成していれば、user.fullName のように API が返さないフィールドを読み取るコードは、レビューコメントではなくコンパイルエラーになります。手書きのインターフェースは、誰にも気づかれないままバックエンドとの乖離が生じることがありますが、生成されたものではそうはなりません。
手書きの型は、間違っていてもコンパイルが通ってしまいます。
// Hand-written: compiles, but the backend never sends fullName
interface User { id: string; fullName: string }
declare const user: User;
const label = user.fullName;
代わりに仕様から型を生成すれば、同じ読み取りはコンパイル時に失敗します。
// Generated from the spec
type User = { id: string; firstName: string; lastName: string };
declare const user: User;
const label = user.fullName;
// error TS2339: Property 'fullName' does not exist on type 'User'.
const fixed = `${user.firstName} ${user.lastName}`;
Hey API openapi-ts などのツールは、OpenAPI ドキュメントを読み込んで型とクライアントを生成します。クライアントを生成するようにしたら、レビューの対象は仕様のほうに移します。生成されたコードはビルド成果物として扱い、手で編集したり 1 行ずつレビューしたりしないでください。
型はコンパイル時に消えてしまうため、信頼できないレスポンスには Zod などのランタイムバリデーターを追加しましょう。なお、これらはすべて、バックエンドが常に最新の状態に保っている信頼できる仕様がなければ機能しません。エンドツーエンドのセットアップについては、OpenAPI と TypeScript で型安全な API クライアントを構築するで解説しています。
dependency-cruiser でレイヤー境界を強制する
dependency-cruiser は、JavaScript または TypeScript プロジェクト内のすべての import を、設定ファイルの forbidden ルールと照合します。違反は ESLint の出力によく似た形式で表示され、error に設定されたルールへの違反が 1 つでもあると、コマンドは非ゼロの終了コードで終了し、CI ジョブが失敗します。これにより、「このサービスが本当にコンポーネントを import すべきなのか?」という確認項目がレビュアーのリストから消えます。
npm install --save-dev dependency-cruiser
npx dependency-cruiser --init
--init は .dependency-cruiser.js を生成します。package.json で "type": "module" が設定されている場合は .dependency-cruiser.cjs になります。生成されるファイルには、デフォルトのルールセットが含まれています。これらのルールは、import の循環、孤立したモジュール、import しているのに package.json に記載されていないパッケージ、そして devDependencies を取り込んでいる本番コードを検出します。これらと並べて、独自のレイヤールールを追加します。
module.exports = {
forbidden: [
{
name: "services-not-to-components",
comment:
"Services must not import UI. Move shared types to src/types or next to the service.",
severity: "error",
from: { path: "^src/services" },
to: { path: "^src/components" },
},
],
};
comment はラベルとしてではなく、指示として書きましょう。デフォルトの err レポーターはこれを表示しませんが、err-long レポーターは違反ごとにこれを出力します。そのため、失敗を引き起こした開発者やエージェントは、エラーだけでなく修正方法も読むことができます。
npx dependency-cruiser --output-type err-long src
このチェックを Linter の中で完結させたい場合は、eslint-plugin-boundaries が ESLint 内で同じ役割を果たします。oxlint にはアーキテクチャのレイヤーを扱う組み込みルールはありませんが、no-restricted-imports ルールでパターンに一致する import をブロックできます。
ミューテーションテストで、テストが破損を検出できるかを確認する
ミューテーションテストはテストの品質をチェックするものです。ラインカバレッジはコードが実行されたことを示すのに対し、ミューテーションテストはそのコードを意図的に変更したときに失敗するテストがあるかどうかを示します。テストスイートはカバレッジ 100% を達成していても、ほとんど何もアサーションしていないことがあり、それを明らかにするのがミューテーションテストです。
Stryker はソースコードに小さな変更を加えます。変更された各バージョンはミュータントと呼ばれます。そして、すべてのミュータントに対してテストを実行します。いずれかのテストが失敗すればそのミュータントは*キル(killed)され、スイート全体が通ってしまえば生存(survive)*したことになります。
Stryker 自身の例では、return user.age >= 18 を > 18、< 18、return false、return true の 4 通りに変異させています。
export function isUserOldEnough(user: { age: number }): boolean {
return user.age >= 18;
}
// Weak: 100% line coverage, two survivors
it("lets adults in", () => {
expect(isUserOldEnough({ age: 25 })).toBe(true);
});
// Added: boundary cases
it("lets in someone who is exactly 18", () => {
expect(isUserOldEnough({ age: 18 })).toBe(true);
});
it("keeps out someone who is 17", () => {
expect(isUserOldEnough({ age: 17 })).toBe(false);
});
25 歳のケースだけを確認するテストは、> 18 のミュータントでも return true のミュータントでも通ってしまうため、ラインカバレッジが 100% であるにもかかわらず両方が生存します。このテストがキルできるのは < 18 と return false だけです。18 歳のケースは > 18 をキルし、17 歳のケースは return true をキルします。3 つのテストがそろって初めて、すべてのミュータントがキルされます。
生存ミュータントは、たいていいくつかの見分けやすい弱いテストのパターンから生じます。
- 関数が呼ばれたことは確認しているが、どの引数で呼ばれたかは確認していない。
- 結果が存在すること、またはその長さは確認しているが、中身は見ていない。
- 期待される出力と照合されていないスナップショットをコミットしている。
- テスト対象のコードと同じロジックで期待値を計算している。
- コードがモックの戻り値をどう扱うかではなく、モックが何を返すかを確認している。
生存ミュータントは、どのテストでも固定されていない振る舞いを示しています。ただし、元のコードと等価なミュータントも存在するため、ミューテーションスコアは合否の閾値としてよりも検出器として使うほうが効果的です。また、厳格な閾値を設けると、振る舞いを検証するためではなくミュータントをキルするためのテストを書くことが評価されてしまいます。Stryker の thresholds.break を設定すると、スコアが一定値を下回ったときにビルドが失敗します。デフォルトは null であり、そのままにしておくべきです。代わりに、PR 上で生存ミュータントを読みましょう。
実行時間を短く保つには、npx stryker run --mutate <comma-separated files> で PR が変更したファイルだけを変異させます。インクリメンタルモード(--incremental)は前回の実行結果を reports/stryker-incremental.json に保存し、再利用できるものを再利用しますが、CI で時間短縮につながるのは、このファイルが実行間でキャッシュされている場合だけです。また、使用しているフレームワーク用のテストランナープラグイン(@stryker-mutator/vitest-runner または @stryker-mutator/jest-runner)もインストールしてください。これがないと Stryker はコマンドランナーにフォールバックし、ミュータントごとにスイート全体を実行してしまいます。テスト品質の判断面、つまりそもそも何をテストすべきかの判断については、AI 時代のテストに関する記事を参照してください。
各チェックを個別の必須 CI ジョブとして実行する
スキップできるチェックは、強制されているとは言えません。各チェックに専用のジョブを割り当てて失敗が一目でわかるようにし、そのうえで各ジョブをブランチ保護またはルールセットの必須ステータスチェックに設定しましょう。GitHub Actions を初めて使う場合は、こちらの GitHub Actions 実践ガイドから始めてください。
on: pull_request
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with: { node-version: 22, cache: npm }
- run: npm ci
- run: npx eslint . && npx prettier --check .
- run: npx dependency-cruiser --output-type err-long src
typecheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with: { node-version: 22, cache: npm }
- run: npm ci
- run: npx tsc --noEmit
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with: { node-version: 22, cache: npm }
- run: npm ci
- run: npm test
mutation:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with: { fetch-depth: 0 }
- uses: actions/setup-node@v7
with: { node-version: 22, cache: npm }
- run: npm ci
- name: Mutate changed files only
run: |
FILES=$(git diff --name-only --diff-filter=AMR "origin/${{ github.base_ref }}...HEAD" -- ':(glob)src/**/*.ts' ':(glob)src/**/*.tsx' \
| grep -vE '\.(test|spec)\.tsx?$' | paste -sd, -)
if [ -z "$FILES" ]; then echo "No source changes"; exit 0; fi
npx stryker run --mutate "$FILES"
build:
needs: [lint, typecheck, test, mutation]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with: { node-version: 22, cache: npm }
- run: npm ci
- run: npm run build
mutation ジョブは、ベースブランチとの差分を取れるように履歴全体を取得します(fetch-depth: 0)。そのうえで、テストファイルを除いた変更済みのソースファイルだけを Stryker に渡します。
新しいルールは段階的に導入しましょう。
- 新しいルールはまず警告として追加します。dependency-cruiser では、
severity: "warn"は違反を報告しますがジョブを失敗させません。失敗させるのはerrorだけです。 - チェックは変更されたファイルに対してのみ実行します。
- 実際のバグを検出した警告はエラーに昇格させ、ノイズしか生まないルールは削除します。
- ジョブを必須にするのは、運用が安定してからにします。
さらに、低コストで追加する価値のある自動チェックが 2 つあります。デッドコード検出の Knip と、重複検出の jscpd です。Knip は、どこからも import されていないファイル、誰も使っていないエクスポート、もう必要のないパッケージを報告します。jscpd は重複したコードブロックを検出します。
自動コードレビューのチェックでわからないことは?
決定論的なチェックでは、その機能が正しいものなのか、抽象化が時期尚早ではないか、チームの誰かがそのコードを理解しているかどうかはわかりません。これらは判断を要する問題であり、人間が必要です。
すべての決定論的チェックを通過したプルリクエストについて示されているのは、それらのチェックが探す欠陥がないということだけです。レビューの人間側の役割については、AI 時代のテストに関する記事を参照してください。
まずはコントラクトから始めよう
機械的な確認項目を 1 つ機械に任せるたびに、人間にしか答えられない問いのためのレビュー時間が取り戻されます。今回のスプリントで 1 つだけチェックを追加するなら、仕様から API クライアントを生成しましょう。誰かがコードを書く前に、ある種類の欠陥を丸ごと防げるからです。次に、PR の差分に対するミューテーションテストを警告のみのジョブとして追加し、生存したものを読んでみてください。
よくある質問
すでに数百件の違反がある既存のコードベースに dependency-cruiser のルールを追加するにはどうすればよいですか?
既存の違反をベースラインとして記録し、新しい違反のみで失敗させます。dependency-cruiser 18.3.0 時点で推奨される方法は --baseline オプションです。デフォルトではプロジェクトルートに .dependency-cruiser-known-violations.json を書き出し、--ignore-known を付けて実行すると、そこに記載されたすべての違反が ignore に引き下げられます。同じルールに違反する新しい import は引き続き失敗します。照合では severity が無視されるため、ルールの severity を変更してもベースラインのエントリはそのまま残ります。
Stryker のミューテーションテストは Vitest で動作しますか?
はい。StrykerJS はバージョン 7.0 で @stryker-mutator/vitest-runner プラグインによる Vitest サポートを追加しました。Jest プロジェクトでは @stryker-mutator/jest-runner を使用します。Vitest ランナーは常に perTest のカバレッジ分析を使用し、設定した coverageAnalysis の値は無視されます。Vitest の Browser Mode はサポートされていません。Stryker はワーカーごとに単一スレッドでテストを実行し、代わりに自身のワーカー間で並列化します。
ミューテーションテストにおける等価ミュータントとは何ですか?
等価ミュータントとは、ソースコードを変更してもプログラムの振る舞いが変わらないミュータントのことで、どのテストでもキルすることはできません。典型的な例は、0 から 1 ずつカウントアップするループで i < length を i != length に置き換えるケースです。どちらも同じ時点で停止します。等価ミュータントは必ず生存するため、ミューテーションスコア 100% はしばしば達成不可能であり、生存ミュータントは人間が読んで判断する必要があります。
テストのない変更ファイルに対して Stryker を実行するとどうなりますか?
デフォルトでは、最初のドライランでテストが 1 つも実行されないと、Stryker は No tests found エラーで停止します。そのため、PR の差分のみを変異させる CI ジョブは失敗します。StrykerJS 7.0 以降では、--allowEmpty フラグ(または設定ファイルでの allowEmpty: true)を使うと、これがエラーにならなくなります。どのテストからも実行されないコード内のミュータントはカバレッジなしとして報告されるため、不足しているテストを直接特定できます。