なぜ AI でE2Eテストを自動生成するのか
E2Eテスト(エンドツーエンドテスト)は、Webアプリケーションの品質を保証するうえで最も信頼性の高いテスト手法の一つです。しかし現実には、テストコードの作成コストが高く、UIの変更に伴う保守作業が開発チームの大きな負担となっています。
Gemini API の強力なマルチモーダル解析能力と、Playwright のブラウザ自動操作を組み合わせることで、この課題を根本から解決できます。ここではWebページのスクリーンショットやHTML構造を Gemini に渡し、テストシナリオの策定からテストコードの生成、さらにはUI変更時の自動修復(セルフヒーリング)までを実現するシステムを構築します。
この記事で扱う技術スタックは以下の通りです。
- Gemini 2.5 Pro: ページ解析・テストコード生成の中核エンジン
- Playwright: クロスブラウザE2Eテスト実行フレームワーク
- Node.js / TypeScript: システム全体の実装言語
- GitHub Actions: CI/CDパイプラインへの統合
対象読者は、Playwright の基本的な使い方を知っている中〜上級のフロントエンド/QAエンジニアです。
アーキテクチャ概要
システム全体は以下の3つのレイヤーで構成されます。
1. ページ解析レイヤー(Page Analyzer)
Playwright でターゲットページにアクセスし、スクリーンショットの撮影、DOM構造の抽出、アクセシビリティツリーの取得を行います。これらのデータを Gemini API に送信し、ページの構成要素と操作可能なインタラクションを特定します。
2. テスト生成レイヤー(Test Generator)
Gemini が解析結果をもとに、Playwright のテストコードを TypeScript で生成します。単なるスナップショットテストではなく、ユーザーフローに沿った意味のあるテストシナリオを自動で組み立てます。
3. テスト実行・修復レイヤー(Test Runner & Self-Healer)
生成されたテストを実行し、失敗した場合は Gemini にエラー内容と最新のページ状態を渡して、テストコードの自動修正を試みます。これがセルフヒーリングの仕組みです。
環境構築
まず、プロジェクトをセットアップします。
mkdir gemini-e2e-generator && cd gemini-e2e-generator
npm init -y
npm install @google/generative-ai playwright typescript tsx
npx playwright install chromium
npx tsc --inittsconfig.json を以下のように設定します。
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"outDir": "./dist",
"rootDir": "./src",
"esModuleInterop": true
},
"include": ["src/**/*"]
}環境変数ファイル .env に API キーを設定します。
GEMINI_API_KEY=YOUR_GEMINI_API_KEY
ページ解析モジュールの実装
最初に、ターゲットページの情報を収集するモジュールを作成します。
// src/page-analyzer.ts
import { chromium, type Page, type Browser } from "playwright";
interface PageAnalysis {
url: string;
title: string;
screenshot: Buffer;
htmlStructure: string;
accessibilityTree: string;
interactiveElements: InteractiveElement[];
}
interface InteractiveElement {
role: string;
name: string;
selector: string;
type?: string;
}
export class PageAnalyzer {
private browser: Browser | null = null;
async init(): Promise<void> {
this.browser = await chromium.launch({ headless: true });
}
async analyze(url: string): Promise<PageAnalysis> {
if (!this.browser) throw new Error("Browser not initialized");
const context = await this.browser.newContext({
viewport: { width: 1280, height: 720 },
});
const page = await context.newPage();
await page.goto(url, { waitUntil: "networkidle" });
// スクリーンショット取得
const screenshot = await page.screenshot({ fullPage: true });
// HTML構造の簡略化抽出(トークン節約のため)
const htmlStructure = await page.evaluate(() => {
function summarize(el: Element, depth: number): string {
if (depth > 4) return "";
const tag = el.tagName.toLowerCase();
const id = el.id ? `#${el.id}` : "";
const cls = el.className
? `.${String(el.className).split(" ").slice(0, 2).join(".")}`
: "";
const text = el.textContent?.trim().slice(0, 50) || "";
const children = Array.from(el.children)
.map((c) => summarize(c, depth + 1))
.filter(Boolean)
.join("\n");
const indent = " ".repeat(depth);
return `${indent}<${tag}${id}${cls}>${text ? ` "${text}"` : ""}\n${children}`;
}
return summarize(document.body, 0);
});
// アクセシビリティツリー取得
const accessibilityTree = await this.getAccessibilityTree(page);
// インタラクティブ要素の一覧取得
const interactiveElements = await this.getInteractiveElements(page);
await context.close();
return {
url,
title: await page.title(),
screenshot,
htmlStructure,
accessibilityTree,
interactiveElements,
};
}
private async getAccessibilityTree(page: Page): Promise<string> {
const snapshot = await page.accessibility.snapshot();
return JSON.stringify(snapshot, null, 2).slice(0, 8000);
}
private async getInteractiveElements(
page: Page
): Promise<InteractiveElement[]> {
return page.evaluate(() => {
const selectors = [
"button",
"a[href]",
'input:not([type="hidden"])',
"select",
"textarea",
'[role="button"]',
'[role="link"]',
'[role="tab"]',
];
const elements: InteractiveElement[] = [];
for (const selector of selectors) {
document.querySelectorAll(selector).forEach((el) => {
const htmlEl = el as HTMLElement;
elements.push({
role: el.getAttribute("role") || el.tagName.toLowerCase(),
name:
el.getAttribute("aria-label") ||
htmlEl.innerText?.trim().slice(0, 60) ||
"",
selector: generateSelector(el),
type: el.getAttribute("type") || undefined,
});
});
}
function generateSelector(el: Element): string {
if (el.id) return `#${el.id}`;
const testId = el.getAttribute("data-testid");
if (testId) return `[data-testid="${testId}"]`;
const ariaLabel = el.getAttribute("aria-label");
if (ariaLabel)
return `${el.tagName.toLowerCase()}[aria-label="${ariaLabel}"]`;
return `${el.tagName.toLowerCase()}:text("${(el as HTMLElement).innerText?.trim().slice(0, 30)}")`;
}
return elements.slice(0, 50);
});
}
async close(): Promise<void> {
await this.browser?.close();
}
}このモジュールのポイントは、Gemini に渡す情報を3種類用意している点です。スクリーンショット(視覚情報)、簡略化したHTML構造(構造情報)、アクセシビリティツリー(セマンティック情報)を組み合わせることで、Gemini がページの意図を正確に理解できるようになります。
Gemini によるテストコード生成
次に、収集したページ情報を Gemini API に送信し、テストコードを生成するモジュールを実装します。
// src/test-generator.ts
import { GoogleGenerativeAI } from "@google/generative-ai";
import type { PageAnalysis } from "./page-analyzer";
const genAI = new GoogleGenerativeAI(process.env.GEMINI_API_KEY!);
const SYSTEM_PROMPT = `あなたはPlaywrightのE2Eテスト専門家です。
与えられたWebページの情報(スクリーンショット、HTML構造、アクセシビリティツリー、
インタラクティブ要素一覧)から、包括的なE2Eテストコードを生成してください。
## テストコード生成ルール
1. テストはPlaywright + TypeScript で記述する
2. セレクターは以下の優先順位で選択する:
- data-testid 属性(最優先)
- aria-label / role ベースのセレクター
- テキストコンテンツベースのセレクター
- CSS セレクター(最終手段)
3. 各テストには明確な説明(describe/it)を付ける
4. アサーションは具体的に: toBeVisible, toHaveText, toHaveURL 等を使い分ける
5. ページ遷移を伴うテストには適切な waitForURL / waitForLoadState を含める
6. ハッピーパス + エッジケース(空入力、不正値)の両方をカバーする
7. テストは独立して実行可能にする(テスト間の依存を作らない)
## 出力形式
TypeScript のコードブロックのみを出力してください。説明文は不要です。
ファイル名のコメントを先頭に含めてください(例: // tests/login.spec.ts)。`;
export async function generateTests(
analysis: PageAnalysis
): Promise<string> {
const model = genAI.getGenerativeModel({
model: "gemini-2.5-pro",
systemInstruction: SYSTEM_PROMPT,
});
const prompt = `以下のWebページに対するE2Eテストを生成してください。
## ページ情報
- URL: ${analysis.url}
- タイトル: ${analysis.title}
## HTML構造(簡略化)
${analysis.htmlStructure.slice(0, 6000)}
## アクセシビリティツリー
${analysis.accessibilityTree.slice(0, 4000)}
## インタラクティブ要素一覧
${JSON.stringify(analysis.interactiveElements, null, 2)}
上記の情報とスクリーンショットを総合的に分析し、
このページの主要なユーザーフローを網羅するテストコードを生成してください。`;
const result = await model.generateContent([
prompt,
{
inlineData: {
mimeType: "image/png",
data: analysis.screenshot.toString("base64"),
},
},
]);
const text = result.response.text();
// コードブロックからTypeScriptコードを抽出
const codeMatch = text.match(/```typescript\n([\s\S]*?)```/);
return codeMatch ? codeMatch[1].trim() : text;
}ここで重要なのは、システムプロンプトでセレクターの優先順位を明示している点です。data-testid を最優先にすることで、UIの見た目が変わってもテストが壊れにくくなります。また、スクリーンショットをマルチモーダル入力として渡すことで、HTML構造だけでは判断できないビジュアルなレイアウトやコンテンツの意図を Gemini が把握できます。
セルフヒーリング(自動修復)機能の実装
E2Eテストが最もコストを消費するのは保守フェーズです。UIが少し変わるだけでテストが壊れ、手動で修正する必要があります。ここでは、テスト失敗時に Gemini が自動的にテストコードを修復するセルフヒーリング機能を実装します。
// src/self-healer.ts
import { GoogleGenerativeAI } from "@google/generative-ai";
import { PageAnalyzer } from "./page-analyzer";
import * as fs from "fs/promises";
const genAI = new GoogleGenerativeAI(process.env.GEMINI_API_KEY!);
interface TestFailure {
testFile: string;
errorMessage: string;
failedLine: number;
pageUrl: string;
}
export class SelfHealer {
private analyzer = new PageAnalyzer();
private maxRetries = 3;
async heal(failure: TestFailure): Promise<string | null> {
await this.analyzer.init();
try {
// 現在のページ状態を再取得
const currentState = await this.analyzer.analyze(failure.pageUrl);
// 失敗したテストコードを読み込み
const testCode = await fs.readFile(failure.testFile, "utf-8");
const model = genAI.getGenerativeModel({
model: "gemini-2.5-pro",
});
const prompt = `以下のPlaywrightテストが失敗しました。
ページの最新状態を分析し、テストコードを修正してください。
## 失敗情報
- エラー: ${failure.errorMessage}
- 失敗行: ${failure.failedLine}
## 現在のテストコード
\`\`\`typescript
${testCode}
\`\`\`
## 最新のページ状態
- URL: ${currentState.url}
- インタラクティブ要素:
${JSON.stringify(currentState.interactiveElements, null, 2)}
## アクセシビリティツリー(最新)
${currentState.accessibilityTree.slice(0, 4000)}
## 修正ルール
1. セレクターの変更のみで修正できる場合はセレクターだけ変更する
2. ページ構造が大幅に変わっている場合はテストロジックも更新する
3. 要素が完全に削除されている場合は該当テストをスキップ(test.skip)にする
4. 修正後のコード全体を出力すること
修正後のTypeScriptコードのみを出力してください。`;
const result = await model.generateContent([
prompt,
{
inlineData: {
mimeType: "image/png",
data: currentState.screenshot.toString("base64"),
},
},
]);
const text = result.response.text();
const codeMatch = text.match(/```typescript\n([\s\S]*?)```/);
return codeMatch ? codeMatch[1].trim() : null;
} finally {
await this.analyzer.close();
}
}
async healAndRetry(failure: TestFailure): Promise<boolean> {
for (let attempt = 1; attempt <= this.maxRetries; attempt++) {
console.log(
`🔧 修復試行 ${attempt}/${this.maxRetries}: ${failure.testFile}`
);
const healed = await this.heal(failure);
if (!healed) {
console.log("❌ 修復コードを生成できませんでした");
continue;
}
// 修復コードを書き込み
await fs.writeFile(failure.testFile, healed, "utf-8");
console.log("📝 テストコードを更新しました");
// 再実行して成功するか確認
const success = await this.runTest(failure.testFile);
if (success) {
console.log("✅ セルフヒーリング成功!");
return true;
}
}
console.log("❌ セルフヒーリング失敗 — 手動修正が必要です");
return false;
}
private async runTest(testFile: string): Promise<boolean> {
const { execSync } = await import("child_process");
try {
execSync(`npx playwright test ${testFile} --reporter=list`, {
stdio: "pipe",
timeout: 60000,
});
return true;
} catch {
return false;
}
}
}セルフヒーリングのポイントは、失敗時に最新のページ状態を再取得してから修復を試みる点です。単にエラーメッセージだけを見るのではなく、ページの現在の構造をマルチモーダルに解析することで、UIリファクタリングやデザイン変更にも対応できます。
メインオーケストレーター
各モジュールを統合するメインスクリプトを作成します。
// src/index.ts
import { PageAnalyzer } from "./page-analyzer";
import { generateTests } from "./test-generator";
import { SelfHealer } from "./self-healer";
import * as fs from "fs/promises";
import * as path from "path";
interface GeneratorConfig {
urls: string[];
outputDir: string;
selfHeal: boolean;
}
async function main(config: GeneratorConfig): Promise<void> {
const analyzer = new PageAnalyzer();
await analyzer.init();
// 出力ディレクトリ作成
await fs.mkdir(config.outputDir, { recursive: true });
for (const url of config.urls) {
console.log(`\n🔍 ページ解析中: ${url}`);
try {
// Step 1: ページ解析
const analysis = await analyzer.analyze(url);
console.log(
` 📊 検出した要素: ${analysis.interactiveElements.length}個`
);
// Step 2: テストコード生成
console.log(" 🤖 Gemini でテストコード生成中...");
const testCode = await generateTests(analysis);
// Step 3: ファイル出力
const slug = new URL(url).pathname
.replace(/\//g, "-")
.replace(/^-|-$/g, "")
|| "index";
const testFile = path.join(
config.outputDir,
`${slug}.spec.ts`
);
await fs.writeFile(testFile, testCode, "utf-8");
console.log(` 💾 テストファイル生成: ${testFile}`);
// Step 4: テスト実行
console.log(" ▶️ テスト実行中...");
const { execSync } = await import("child_process");
try {
execSync(
`npx playwright test ${testFile} --reporter=list`,
{ stdio: "inherit", timeout: 120000 }
);
console.log(" ✅ 全テストパス");
} catch {
if (config.selfHeal) {
console.log(" 🔧 セルフヒーリング開始...");
const healer = new SelfHealer();
await healer.healAndRetry({
testFile,
errorMessage: "テスト実行失敗",
failedLine: 0,
pageUrl: url,
});
}
}
} catch (error) {
console.error(` ❌ エラー: ${error}`);
}
}
await analyzer.close();
console.log("\n🏁 全ページの処理が完了しました");
}
// 実行
main({
urls: [
"https://example.com",
"https://example.com/login",
"https://example.com/dashboard",
],
outputDir: "./tests/generated",
selfHeal: true,
});テスト生成の品質を高めるプロンプトテクニック
Gemini が生成するテストの品質を安定させるために、いくつかの実践的なテクニックがあります。
ページタイプ別のプロンプト分岐
ページの種類(ログインフォーム、一覧ページ、詳細ページなど)を事前に分類し、それぞれに最適化されたプロンプトテンプレートを使い分けます。
// src/prompt-templates.ts
export const PROMPT_TEMPLATES: Record<string, string> = {
form: `このページにはフォームが含まれています。以下のテストを生成してください:
- 正常系: 全フィールドに有効な値を入力して送信
- バリデーション: 必須フィールドを空にして送信、エラーメッセージの確認
- 境界値: 最大文字数、特殊文字の入力テスト
- アクセシビリティ: Tab キーでのフォーカス移動順序の確認`,
list: `このページはリスト/一覧ページです。以下のテストを生成してください:
- ページネーション: 次ページ/前ページへの遷移
- ソート: 各カラムでのソート切り替え
- フィルター: フィルター適用と解除
- 空状態: データがない場合の表示確認
- 項目クリック: 各アイテムの詳細ページへの遷移`,
auth: `このページは認証ページです。以下のテストを生成してください:
- 正常ログイン: 有効な認証情報での認証フロー
- エラーケース: 無効なパスワード、存在しないユーザー
- バリデーション: メールアドレス形式チェック
- パスワードリセット: リセットフローの導線確認
- セキュリティ: XSS入力に対する防御確認`,
};
export function detectPageType(
analysis: PageAnalysis
): string {
const { interactiveElements, htmlStructure } = analysis;
const formCount = interactiveElements.filter(
(e) => e.role === "input" || e.role === "textarea"
).length;
if (htmlStructure.includes("login") ||
htmlStructure.includes("sign-in")) {
return "auth";
}
if (formCount >= 3) return "form";
if (htmlStructure.includes("pagination") ||
interactiveElements.length > 15) {
return "list";
}
return "general";
}Function Calling によるテスト構造の制御
Gemini の Function Calling 機能を使って、テストの構造を JSON スキーマで厳密に制御することもできます。
// src/structured-generator.ts
import { GoogleGenerativeAI, type FunctionDeclaration } from "@google/generative-ai";
const testSuiteSchema: FunctionDeclaration = {
name: "create_test_suite",
description: "Playwright E2E テストスイートを構造化形式で生成する",
parameters: {
type: "object",
properties: {
fileName: {
type: "string",
description: "テストファイル名(例: login.spec.ts)",
},
imports: {
type: "array",
items: { type: "string" },
description: "必要なインポート文",
},
testSuites: {
type: "array",
items: {
type: "object",
properties: {
description: {
type: "string",
description: "describe ブロックの説明",
},
beforeEach: {
type: "string",
description: "beforeEach のコード(任意)",
},
tests: {
type: "array",
items: {
type: "object",
properties: {
name: { type: "string" },
priority: {
type: "string",
enum: ["critical", "high", "medium", "low"],
},
steps: {
type: "array",
items: { type: "string" },
},
},
required: ["name", "priority", "steps"],
},
},
},
required: ["description", "tests"],
},
},
},
required: ["fileName", "imports", "testSuites"],
},
};この方法では、Gemini に自由形式でコードを生成させるのではなく、テストの構造を事前に定義したスキーマに沿って出力させます。これにより、出力の安定性が大幅に向上し、後処理でのコード組み立てが容易になります。
CI/CD パイプラインへの統合
本番運用では、GitHub Actions からテスト生成と実行を自動化するのが理想的です。
# .github/workflows/e2e-ai-tests.yml
name: AI-Generated E2E Tests
on:
pull_request:
branches: [main]
schedule:
- cron: "0 6 * * 1" # 毎週月曜 6:00 UTC に定期実行
env:
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
jobs:
generate-and-run:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
- name: Install dependencies
run: npm ci
- name: Install Playwright browsers
run: npx playwright install --with-deps chromium
- name: Start application
run: |
npm run build
npm start &
npx wait-on http://localhost:3000 --timeout 30000
- name: Generate E2E tests with Gemini
run: npx tsx src/index.ts
- name: Run generated tests
run: npx playwright test tests/generated/ --reporter=html
- name: Upload test report
if: always()
uses: actions/upload-artifact@v4
with:
name: e2e-report
path: playwright-report/
- name: Comment PR with results
if: github.event_name == 'pull_request'
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const report = fs.existsSync('test-results.json')
? JSON.parse(fs.readFileSync('test-results.json', 'utf8'))
: { passed: 0, failed: 0 };
const emoji = report.failed === 0 ? '✅' : '⚠️';
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: `${emoji} **AI-Generated E2E Test Results**\n\n` +
`- Passed: ${report.passed}\n` +
`- Failed: ${report.failed}\n\n` +
`Full report is available in the workflow artifacts.`
});PRが作成されるたびに、変更されたページに対するE2Eテストを自動生成・実行し、結果をPRコメントとして報告する仕組みです。週次の定期実行では、全ページに対するリグレッションテストを実施します。
コスト最適化戦略
Gemini API の利用コストを抑えるために、以下の3つの戦略を組み合わせます。
1. キャッシュの活用
ページ構造が変わっていない場合は、テストの再生成をスキップします。ページのHTML構造のハッシュ値を保存しておき、変更があった場合のみ Gemini を呼び出します。
import * as crypto from "crypto";
function computePageHash(analysis: PageAnalysis): string {
const content = analysis.htmlStructure +
JSON.stringify(analysis.interactiveElements);
return crypto.createHash("sha256").update(content).digest("hex");
}
// 前回のハッシュと比較して変更があった場合のみ再生成
const currentHash = computePageHash(analysis);
const cachedHash = await loadCachedHash(url);
if (currentHash === cachedHash) {
console.log("ページに変更なし — テスト再生成をスキップ");
return;
}2. モデルの使い分け
初回のテスト生成には Gemini 2.5 Pro を使い、セルフヒーリング(セレクター修正程度の軽微な変更)には Gemini 2.5 Flash を使うことで、コストを大幅に削減できます。
3. バッチ処理
複数ページのテスト生成を一度にまとめてリクエストすることで、APIコールの回数を抑えます。Gemini の Batch Processing API を活用すれば、さらに50%のコスト削減が可能です。
実践的なテスト生成例
実際にログインページを解析した場合の生成例を見てみましょう。
// tests/generated/login.spec.ts
// Gemini API によって自動生成されたテスト
import { test, expect } from "@playwright/test";
test.describe("Login Page", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/login");
});
test("should display login form with all required elements", async ({
page,
}) => {
await expect(
page.getByRole("heading", { name: /ログイン|Sign In/i })
).toBeVisible();
await expect(page.getByLabel(/メール|Email/i)).toBeVisible();
await expect(page.getByLabel(/パスワード|Password/i)).toBeVisible();
await expect(
page.getByRole("button", { name: /ログイン|Sign In/i })
).toBeVisible();
});
test("should show validation errors for empty submission", async ({
page,
}) => {
await page.getByRole("button", { name: /ログイン|Sign In/i }).click();
await expect(
page.getByText(/必須|required/i)
).toBeVisible();
});
test("should show error for invalid credentials", async ({ page }) => {
await page.getByLabel(/メール|Email/i).fill("test@example.com");
await page.getByLabel(/パスワード|Password/i).fill("wrongpassword");
await page.getByRole("button", { name: /ログイン|Sign In/i }).click();
await expect(
page.getByText(/認証に失敗|invalid|incorrect/i)
).toBeVisible();
});
test("should navigate to password reset page", async ({ page }) => {
await page
.getByRole("link", { name: /パスワードを忘れた|Forgot/i })
.click();
await expect(page).toHaveURL(/reset|forgot/);
});
});Gemini は、ページの言語設定に応じて日本語・英語の両方に対応したセレクターを自動的に生成します。正規表現パターンを使うことで、多言語サイトでも安定して動作するテストになっています。
まとめ
Gemini API のマルチモーダル解析能力と Playwright のブラウザ自動操作を組み合わせることで、E2Eテストの作成と保守を大幅に自動化できます。ページ解析 → テストコード生成 → 実行 → セルフヒーリングというサイクルを CI/CD に組み込むことで、開発チームはテストの記述ではなく、プロダクトの品質向上そのものに集中できるようになります。
本記事のコードは TypeScript で実装していますが、Python 版の Playwright と Gemini SDK でも同じアーキテクチャを再現できます。まずは小さなプロジェクトで試してみて、生成されるテストの品質を確認しながら、徐々に適用範囲を広げていくのがおすすめです。
AI駆動テストの技術的基盤