0
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?

GitHub ActionsでClaudeを活用したE2Eテスト自動選択システムの構築

0
Posted at

1. はじめに

こんにちは。今回は、Pull Request(PR)に対するE2Eテストの実行において、Claude APIを活用して必要なテストケースを自動選択する仕組みを構築したのですが、このような記事が少なかったため、その設計と実装についてご紹介します。

E2Eテストは包括的な品質保証に欠かせませんが、全てのテストを毎回実行するとCI/CDパイプラインの時間とコストが膨大になります。一方で、手動でテストを選択するのは属人化やヒューマンエラーのリスクがあります。そこで、LLMの文脈理解能力を活用し、PRの変更内容から影響範囲を分析して適切なテストケースを自動選択する仕組みを導入しました。

(* 執筆時点は2026年2月の情報です)

2. 背景・前提条件

課題の背景

従来のE2Eテスト実行には以下の課題がありました:

  • 実行時間の問題: アプリケーションの成長に伴い、フルリグレッションテストスイートがパイプラインを遅延させ、フィードバックを遅らせる最大のボトルネックとなっていました
  • コストの問題: CI/CDパイプラインの実行時間に応じた課金が発生し、すべてのテストを毎回実行するのは無駄なコストがかかっていました

先行技術の調査

必要なテストのみを実行する方法として、手動でテスト対象を制御するアプローチ(例:変更ディレクトリとテストファイルの対応表を管理する等)があります。これでは、依存関係の定義やメンテナンスが大きな負担となります。
新しい機能やテストファイルを追加するたびに、「このコード変更時にはこのテストを実行する」という依存関係ルールを手動で更新する必要があり、これを怠るとテスト漏れに繋がります。

「設定ファイル不要で、今の状態を見て判断してほしい」

この課題を解決するために、LLMを採用しました。
LLMであれば、事前に依存関係を定義する必要がありません。その場にある最新のテストファイル一覧と変更内容を渡すだけで、コードの意味的な繋がりを解析し、「現時点で実行すべきテスト」を動的に判断できます。
その方法としては、以下の記事を参考にして実装を行いました。
https://www.jampa.dev/p/best-of-both-worlds-using-claude

3. 設計

技術スタック

本システムでは以下の技術を採用しています:

  • TypeScript
  • Claude Agent SDK (v0.72.1)
  • Git

システムアーキテクチャ

システムは以下のコンポーネントで構成されています:

PR作成/更新
    ↓
GitHub Actionsトリガー
    ↓
プロジェクト内の現状e2e test file一覧を取得
    ↓
変更差分の取得 (git diff)
    ↓
Claude APIへリクエスト(影響分析)
    ↓
テストケース選択結果の取得(JSON形式)
    ↓
選択されたテストの実行

実装の詳細

ここからは、実際に稼働している主要なスクリプトを紹介します。

1. ノイズを除去したGit Diffの取得

LLMに渡すトークン数を節約するために、以下のオプションを設定しました。

  • minimal: Gitに対して冗長さを減らすよう指示し、より簡潔な差分出力を生成させます​
  • ignore-all-space: 空白(スペース・タブ)の変更を無視させます
  • giff-filter=ACMR: 削除されたファイル(D)を除外し、追加(A)・コピー(C)・変更(M)・リネーム(R)されたファイルのみを対象にします。削除されたファイルに対応するテストは既に参照エラーになっているか、そもそも実行不要なため除外させます
  • :(exclude)パターン: package-lock.jsonや.mdファイルなど、大容量かつテスト選択に無関係なファイルを除外してトークン数を削減させます
git-diff.sh
#!/bin/bash
set -e
# ベースブランチ(必要に応じて変更)
BASE_BRANCH="${BASE_BRANCH:-main}"

DIFF_OUTPUT=$(git diff origin/${BASE_BRANCH}...HEAD \
  --minimal \
  --ignore-all-space \
  --diff-filter=ACMR \
  -- . \
  ':(exclude)pnpm-lock.yaml' \
  ':(exclude)*.lock' \
  ':(exclude)package-lock.json' \
  ':(exclude)package.json' \
  ':(exclude)yarn.lock' \
  ':(exclude).github/**' \
  ':(exclude)*.md' \
  ':(exclude)**/README' \
  ':(exclude)**/docs/**' \
  ':(exclude).gitignore' \
  ':(exclude)scripts/**' \
  ':(exclude)**/*.test.ts' \
  ':(exclude).env*' \
)

echo "$DIFF_OUTPUT"

2. E2Eテストファイル一覧の取得

claudeに現状のe2eテストファイル一覧を渡すために、typescriptsでプロジェクト内に存在するテストファイルのリストを取得するようにしました。

show-e2e-tests.ts
...
  const patterns = ["*.spec.ts"];
  const allTests = await glob(patterns); 
...

以下のようにテストファイルのパス一覧がJSON形式で出力されます。これをそのままプロンプトの一部としてClaudeに渡します。

[
  "Login.spec.ts",
  "Vendors.spec.ts",
  ...
]

これにより、Claudeは「ログイン機能の変更だからLogin.spec.tsを実行しよう」といった判断をファイル名から行うことを意図しました。

3. プロンプトエンジニアリング

Claudeへのプロンプトは、見逃しを絶対に避けるためことを意識してプロンプトを作成しました。
又、過去の参考にした記事から英語だとthink deepを入れると適切に判断してくれるとのことで、**重要: 深く体系的に考察してください。安易な判断は避けてください。**という文言を入れるようにしました。

select-e2e-prompt.md
# 分析ステップ

1. **差分の確認**: 下記の「ブランチのgit差分」で変更されたファイルを把握する
2. **テストとの照合**: 変更内容を「利用可能なE2Eテストファイル」と照らし合わせる
3. **影響範囲の分析**: 直接的な変更だけでなく、間接的な影響も考慮する
4. **依存関係の追跡**: 変更されたファイルに依存する他のファイルを調査する
5. **テストの選定**: 影響を受ける可能性のあるテストを判断し、リストアップする

# 安全バイアスの原則

**重要: Componentなどの影響範囲が大きい変更は常に多めにテストを選択してください。**

- 偽陰性(見逃し)は絶対に避ける
- 偽陽性(余分な実行)は許容される

**runAll判定:**

- 共通コンポーネント・認証・ルーティングの変更 → `runAll: true` にする
- 判断に迷ったら → `runAll: true` にする

## 重要な注意事項

1. **保守的な判断**
   - 影響範囲が不明な場合 → 全テストを実施(runAll: true)
   - 見逃しは許されません

2. **テストファイル名の理解**
   - `page_A.spec.ts` → page Aに関連するテストケース
   - `page_B.spec.ts` → page Bに関連するテストケース

**重要: 深く体系的に考察してください。安易な判断は避けてください。**

4. Claudeによるテスト選択ロジック

次に、git diffの結果とテスト一覧をプロンプトに埋め込み、Claudeに判断させるTypeScriptスクリプトを作成しました。

今回モデルには、claude 3.5 Haikuを使用しています。これは、最も安いモデルですが、他のコストは高いが推論に優れたモデルと比較しても、選択したテストファイルは同一であることを確認できたことから、最も安いモデルにしました。

又、Claudeはデフォルトではテキスト出力で、プロンプトで「JSONで返して」と指示してもMarkdownのコードブロックや余計な前置きが入り、パースエラーの原因になりました。そのため、output_config オプションで json_schema を強制することで、Claudeは Schemaに厳密に従ったJSONのみ を返すようになり、安定して出力できるようになりました。

e2e-smart-selection.ts
import Anthropic from "@anthropic-ai/sdk";
import { execSync } from "child_process";
import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

// 環境変数の読み込み
const API_KEY = process.env.ANTHROPIC_API_KEY;

const client = new Anthropic({
	apiKey: API_KEY,
});

// claudeが返すJSON Schemaの定義(構造化出力用)
const claudeE2EtestsJudgeSchema = {
	type: "object",
	properties: {
		explanation: {
			type: "string",
			description: "判定理由(1-2文)",
		},
		tests: {
			type: "array",
			items: { type: "string" },
			description: "実行すべきテストファイルのリスト",
		},
		runAll: {
			type: "boolean",
			description: "全テストを実行すべきか",
		},
	},
	required: ["explanation", "tests", "runAll"],
	additionalProperties: false,
} as const;

// TypeScript型定義
interface TestRecommendations {
	explanation: string;
	tests: string[];
	runAll: boolean;
}

async function generatePrompt(): Promise<string> {	
	const webDir = path.join(__dirname, "../../..");

	try {
		// プロンプトファイルを読み込み
		const promptTemplate = fs.readFileSync(path.join(__dirname, "select-e2e-prompt.md"), "utf-8");

		// テストリストを取得
		const testList = execSync("pnpm run --silent e2e:list-e2e-files", {
			cwd: webDir,
			encoding: "utf-8",
		});

		// Git diff を取得
		const gitDiff = execSync("pnpm run --silent e2e:git-diff", {
			cwd: webDir,
			encoding: "utf-8",
		});

		// 完全なプロンプトを構築
		const fullPrompt = `${promptTemplate}
## Available E2E test files:
\`\`\`json
${testList}
\`\`\`
## Branch git diff:
\`\`\`diff
${gitDiff}
\`\`\``;

		return fullPrompt;
	} catch (error) {
		console.error("❌ Error generating prompt:", error);
		throw error;
	}
}

async function analyzeWithClaude(prompt: string): Promise<TestRecommendations> {
	try {
		const response = await client.messages.create({
			model: "claude-haiku-4-5-20251001",
			max_tokens: 2048,
			system:
				"あなたはPlaywright E2Eテストの選択を自動化する専門家です。\
			PRの変更内容を分析し、実行すべきE2Eテストを選択してください。",
			messages: [
				{
					role: "user",
					content: prompt,
				},
			],
			temperature: 0.0,
			output_config: {
				format: {
					type: "json_schema",
					schema: claudeE2EtestsJudgeSchema,
				},
			},
		});

		const content = response.content[0];
		if (content.type !== "text") {
			throw new Error("Unexpected response type from Claude");
		}

		// JSON文字列をパースして検証
		const result: TestRecommendations = JSON.parse(content.text);
		return result;
	} catch (error) {
		console.error("❌ Error calling Claude API:", error);
		throw error;
	}
}

async function main() {
	const outputPath = path.join(__dirname, "test-recommendations.json");

	try {
		// 1. プロンプトを生成
		const prompt = await generatePrompt();

		// 2. Claude で分析
		const response = await analyzeWithClaude(prompt);

		// 3. 結果を保存
		fs.writeFileSync(outputPath, JSON.stringify(response, null, 2));
	} catch (error) {
		console.error("");
		console.error("💥 Script failed:", error);
		process.exit(1);
	}
}

await main();

5. GitHub Actionsワークフローによる制御

最後に、これらを繋ぎ合わせるGitHub Actionsの設定です。
Claudeが出力したJSONファイル(test-recommendations.json)をjqコマンドでパースし、Playwrightの引数として渡しています。

ここで今更になりますが、そもそもanthropics/claude-code-actionではなくて、claude agent sdkを用いた理由として、完全自動でe2eテストができない課題がありました。具体的には、anthropics/claude-code-actionでは@claude メンション方式でclaudeを呼び出せるのですが、現プロジェクトではE2EはPR作成時に自動で実行されるようになっており、anthropics/claude-code-actionを導入すると手動で毎回@claudeをコメントする一手間が発生してしまい、現状の開発環境に手間が増えてしまいます。
そのため、今回はTypeScript + Claude Agent SDKを使用することで、GitHub Actionsの on: pull_request トリガーで完全に自動化しました。

      - uses: actions/checkout@v4
        with:
          fetch-depth: 0 # git diff で差分を正しく取得するために履歴全体を取得しています

      # Claudeによるテスト選択スクリプトを実行
      - name: Run Claude test selection
        id: select
        # AI分析が失敗してもCI自体は止めず、後続で全実行に倒すためにtrueにする
        continue-on-error: true
        if: github.event_name == 'pull_request'
        working-directory: typescript/apps/web
        run: pnpm run e2e:select

      # 出力されたJSONをパースして環境変数にセット
      - name: Parse test recommendations
        id: parse
        if: github.event_name == 'pull_request'
        working-directory: typescript/apps/web
        run: |          
          # もしClaudeのAPI呼び出しが失敗したり、JSON生成に失敗した場合は「全テストを実行 (run_all=true)」するように制御しています

          if [ ! -f "e2e/.github/scripts/test-recommendations.json" ]; then
            echo "tests=[]" >> $GITHUB_OUTPUT
            echo "run_all=true" >> $GITHUB_OUTPUT
            echo "explanation=Claude analysis failed - running all tests as fallback" >> $GITHUB_OUTPUT
            exit 0
          fi                  
          
          # jqを使ってJSONから必要な情報を抽出
          TESTS=$(cat e2e/.github/scripts/test-recommendations.json | jq -c '.tests')
          RUN_ALL=$(cat e2e/.github/scripts/test-recommendations.json | jq -r '.runAll')
          EXPLANATION=$(cat e2e/.github/scripts/test-recommendations.json | jq -r '.explanation')
          
          echo "tests=$TESTS" >> $GITHUB_OUTPUT
          echo "run_all=$RUN_ALL" >> $GITHUB_OUTPUT
          {
            echo "explanation<<EOF"
            echo "$EXPLANATION"
            echo "EOF"
          } >> $GITHUB_OUTPUT
      
      # 条件分岐によるテスト実行
      - name: Run E2E tests
        run: |          
          # run_allがtrueの場合は引数なしで実行(全テスト実行)
          if  [ "${{ steps.parse.outputs.run_all }}" = "true" ]; then            
              pnpm run playwright test
          else
            # JSON配列をスペース区切りの文字列に変換
            TESTS_JSON='${{ steps.parse.outputs.tests }}'
            TESTS=$(echo "$TESTS_JSON" | jq -r '.[]' | tr '\n' ' ')
            
            # テスト対象がない場合はスキップ
            if [ -z "$TESTS" ]; then
              echo "✅ No E2E tests need to run for these changes"
              exit 0
            fi            
            
            # 指定されたテストファイルのみ実行
            pnpm run playwright test $TESTS
          fi

package.json
{
  "scripts": {
		"e2e:list-e2e-files": "tsx show-e2e-tests.ts",
		"e2e:git-diff": "bash git-diff.sh",		
		"e2e:select": "tsx e2e-smart-selection.ts"
  }
}

5. 結果

この仕組みを導入した結果、開発プロセスに以下の変化がありました。

実行時間の削減:

  • Claude推論時間: 約5秒/PR
  • E2E実行時間: PRに関係ない重たいテストケースを省くことで、従来の実行時間を約半減することに成功しました

コスト:

  • Claude API(Haiku): $0.01$0.04/回(変更規模により変動)
  • 損益分岐点: 現状ではgithub actionsをblacksmithを用いて動かしており、blacksmthではCI実行時間の費用として$0.004/分がかかります。そのため、毎PRあたりClaudeによって3分以上のCI実行時間を削減できればコスト的にプラスになりそうです

実際の選択精度:

PR内容 変更統計 選択されたテスト API費用
page Aのフロントの変更 24 files, 844 insertions(+), 53 deletions(-) page_A.spec.ts $0.04
Page_Bのボタン修正 8 files, 395 insertions(+), 40 deletions(-) page_B.spec.ts $0.01
page_Cのラベル修正 7 files, 790 insertions(+), 4 deletions(-) page_C.spec.ts $0.01

定性的な効果

開発体験の大幅な改善:
これまでE2Eテストが失敗した場合、以下の分析が必要でした:

  • PRの機能変更によってテストが失敗しているのか?
  • そもそも機能変更に関係ないE2EテストがFlakyなために失敗したのか?

Claudeによる選択的実行により、この分析作業が不要になりました。 PRに関連したテストのみが実行されるため、失敗時の原因特定が容易になり、開発のしやすさという側面でプラスになりそうです。

現時点での課題と今後の展望

1. より細かい粒度でのテスト選択の実現

現在のシステムは、テストファイル名からそのテストファイルを実行するかを判断しています。しかし、1つのファイルに複数の describe ブロックや test ケースが含まれている場合、ファイル全体を実行すると不要なテストまで実行してしまう可能性があります。

今後は、テストファイルの中身(describetest ブロック)までClaudeに解析させ、「このPRはログイン機能に影響するから、auth.spec.ts の中でもログイン関連のテストケースだけを実行する」といった、より細かい粒度での選択を実現したいと考えています。

2. コスト削減
今回、自動でclaudeが走るようにするためにtypescript claude agent sdkを使用しました。これによって、毎回呼び出すたびにコストがかかる問題があります。そのため、'@claude'を自動で呼び出すことでMax plan内で収まるようにしたいと思いました。

6. 終わりに

LLMを用いたCI/CDの最適化は、E2Eテストケースが増えるほど価値が上がりそうな結果になりました。

一方で、まだプロジェクトも小さいアプリであるため、そこまで大きな価値は見えない結果となりました。
今後、アプリが大規模になるにつれて、今回導入したLLMでテスト自動選択は本当に価値があったのかを観測していければと思います。

この記事が、皆さんのCI/CD最適化の参考になれば幸いです。ご質問やフィードバックがあれば、ぜひコメント欄でお聞かせください。

0
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
0
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?