EY-Office ブログ

AIにテストE2E書いてと言ったらかなり良いテストを書いてくれた

先週、AIが作ったジャンケンを行えるReactアプリにE2Eテストを書いてもらいました。

AIが作ったアプリにテストコードが要るのか? と思う方もいるかと思いますが、現状ではAIが作成するコードは完璧ではありません、またAIが作ったアプリの一部を人間が修正するかもしれません。その時のはテストコードがあった方が良いのではと私は思います。

AI-code-generate Bing Image Creatorが生成した画像を使っています

E2Eテスト

テストコードにはいくつかの種類があります、たとえばジャンケンアプリではジャンケンの勝敗を判定する関数がありますが、この関数が全てのジャンケンの組み合わせで正しい勝敗判定を行っているのかをテストしたくなります。このような関数のテストには関数を実行し結果を検証するユニットテストが使われます。

それに対しReactアプリのような画面表示や入力欄、ボタンなどがある場合には、コンポーネント単位の機能をテストするTesting LibraryのようなUIテスト。または人間が画面を操作したり画面を確認するテストを、ソフトウェアで制御できるウェブブラウザー相当のツールを使うend-to-end (E2E) testingがあります。

E2Eテストは人間がブラウザーを使ってアプリをテストするのと同様の確認が出来るの優れたテストですが、人間がテストコードを書く手間が大変な事、さらに画面の文言やデザインの変更の影響を受けやすい事から、あまり使われてきませんでした。

今週のプロンプト

E2Eテストを書いてもらうためのプロンプトですが、まずはAIにお任せで依頼しました。

# このアプリのE2Eテストを書いてください
- テスティング・フレームワークはplaywriteを使ってください

今回の生成はツールは、Roo Code + Claude 3.7 Sonnetです(Gemini 2.5 Proで生成してもらったテストは期待外れでした)。

AIの動作

E2Eテストの作成を依頼すると・・・

まずはジャンケンアプリのファイルを読み込み、コードを理解

一部は省略しましたが、ジャンケンアプリのファイルを読み込みコードを理解しています。結果は概ねあっていますね。

Φ 初期チェックポイント


まず、このタスクを理解しましょう。ユーザーは、このアプリケーションのE2Eテスト(エンドツーエンドテスト)を
書くことを求めています。テスティングフレームワークとしてPlaywrightを使用するよう指定されています。

・・・・

📄 Rooはこのファイルを読みたい:

src/app/scores/page.tsx ファイルを確認しました。これはNext.jsのサーバーコンポーネントで、
対戦結果を表示するページのようです。このページでは以下のことが行われています:

getScores 関数を使って対戦結果を取得
JankenButtons コンポーネントを表示
ScoresTable コンポーネントに対戦結果を渡して表示

・・・・

📄 Rooはこのファイルを読みたい:

package.json ファイルを確認しました。このプロジェクトはNext.js 15.3.1を使用しており、
React 19.0.0、Prisma 6.6.0などの依存関係があります。しかし、Playwrightに関する依存関係は見当たりません。

・・・・

📄 Rooはこのファイルを読みたい:

src/components/JankenButtons.tsx ファイルを確認しました。このファイルはジャンケンの手を選択するボタンを
表示するクライアントコンポーネントです。ユーザーがボタンをクリックすると、playJanken サーバーアクションが
呼び出され、選択した手に基づいてジャンケンが実行されます。

・・・・

📄 Rooはこのファイルを読みたい:

src/components/StatusTable.tsx ファイルを確認しました。このファイルは対戦成績を表示するテーブルコンポーネントです。
status プロパティとして対戦成績のオブジェクトを受け取り、勝ち、負け、引き分けの回数をテーブル形式で表示します。

・・・・

📄 Rooはこのファイルを読みたい:

src/actions/janken.ts ファイルを確認しました。このファイルはサーバーアクションを定義しています。以下の内容が含まれています:

playJanken 関数:ジャンケンを実行し、結果をデータベースに保存するサーバーアクション
getScores 関数:全ての対戦結果を取得するサーバーアクション
getStatus 関数:対戦成績(勝ち、負け、引き分けの数)を取得するサーバーアクション

・・・・

E2Eテスト関連のファイルを作成

E2Eテストのテストコードや設定ファイルを生成してくれました。またテストコマンドをpackage.jsonに追加したり、README.mdに手順を追加してくれました、気が利いていますね。😄

Φ チェックポイント

📄 Rooは新しいファイルを作成したい:

Playwrightの設定ファイル playwright.config.ts を作成しました。次に、E2Eテストファイルを作成します。このアプリケーションのE2Eテストでは、以下の機能をテストします:

ホームページが /scores にリダイレクトされることを確認
対戦結果ページが正しく表示されることを確認
ジャンケンの手を選択して対戦できることを確認
対戦成績ページが正しく表示されることを確認
これらのテストを tests/e2e/janken.spec.ts ファイルに作成します。

📄 Rooは新しいファイルを作成したい:

E2Eテストファイル tests/e2e/janken.spec.ts を作成しました。このファイルには以下のテストケースが含まれています:

ホームページが /scores にリダイレクトされることを確認するテスト
対戦結果ページが正しく表示されることを確認するテスト
ジャンケンの手を選択して対戦できることを確認するテスト
対戦成績ページが正しく表示されることを確認するテスト
ページ間のナビゲーションができることを確認するテスト

・・・・

Φ チェックポイント

これで、ジャンケンアプリのE2Eテストの設定と実装が完了しました。以下のファイルを作成しました:

playwright.config.ts: Playwrightの設定ファイル
tests/e2e/janken.spec.ts: E2Eテストファイル
tests/README.md: テストの実行方法と概要を説明するREADMEファイル
また、package.json ファイルにテスト実行用のスクリプトを追加しました。

これらのファイルにより、ジャンケンアプリの主要な機能をテストするE2Eテストが実装されました。テストは以下の機能をカバーしています:

ホームページのリダイレクト
対戦結果ページの表示
ジャンケンの対戦機能
対戦成績ページの表示
ページ間のナビゲーション
これらのテストを実行することで、アプリケーションの主要な機能が正しく動作していることを確認できます。

E2Eの作成が完了しました

最終結果のまとめです、良いですね。

ジャンケンアプリのE2Eテストを実装しました。以下のファイルを作成しました:

1. **playwright.config.ts**
   - Playwrightの設定ファイル
   - テスト実行環境の設定(ブラウザ、ベースURL、スクリーンショット設定など)
   - 開発サーバーの自動起動設定

2. **tests/e2e/janken.spec.ts**
   - 5つのテストケースを実装
     - ホームページのリダイレクト確認
     - 対戦結果ページの表示確認
     - ジャンケンの対戦機能確認
     - 対戦成績ページの表示確認
     - ページ間のナビゲーション確認

3. **tests/README.md**
   - テストの実行方法と概要を説明

また、`package.json`にテスト実行用のスクリプトを追加しました:
```json
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui"
```

テストを実行するには、まずPlaywrightをインストールする必要があります:
```bash
npm install -D @playwright/test
npx playwright install
```

その後、以下のコマンドでテストを実行できます:
```bash
npm run test:e2e
```

UIモードでテストを実行する場合は:
```bash
npm run test:e2e:ui
```

生成されたE2Eテストを実行

生成されたE2Eテストを実行すると、以下のように3つ失敗しています。

> playwright test

Running 5 tests using 5 workers

  ✘  1 [chromium] › tests/e2e/janken.spec.ts:16:7 › ジャンケンアプリのE2Eテスト › 対戦結果ページが正しく表示されること (584ms)
  ✓  2 [chromium] › tests/e2e/janken.spec.ts:5:7 › ジャンケンアプリのE2Eテスト › ホームページが/scoresにリダイレクトされること (756ms)
  ✘  3 [chromium] › tests/e2e/janken.spec.ts:116:7 › ジャンケンアプリのE2Eテスト › ページ間のナビゲーションができること (936ms)
  ✘  4 [chromium] › tests/e2e/janken.spec.ts:76:7 › ジャンケンアプリのE2Eテスト › 対戦成績ページが正しく表示されること (961ms)
  ✓  5 [chromium] › tests/e2e/janken.spec.ts:47:7 › ジャンケンアプリのE2Eテスト › ジャンケンの手を選択して対戦できること (820ms)

3 failed
2 passed (4.5s)

生成されたテストコードは以下のようになっていました。

import { test, expect } from '@playwright/test';
import { Hand } from '../../src/lib/janken';

test.describe('ジャンケンアプリのE2Eテスト', () => {
  test('ホームページが/scoresにリダイレクトされること', async ({ page }) => {
    // ホームページにアクセス
    await page.goto('/');

    // URLが/scoresになっていることを確認
    await expect(page).toHaveURL('/scores');

    // ページタイトルを確認
    await expect(page).toHaveTitle('対戦結果 | ジャンケンアプリ');
  });

  test('対戦結果ページが正しく表示されること', async ({ page }) => {
    // 対戦結果ページにアクセス
    await page.goto('/scores');

    // ページのヘッダーが表示されていることを確認
    const header = page.locator('h1');
    await expect(header).toBeVisible();                     // ← ①
    await expect(header).toHaveText('対戦結果');

    // ジャンケンボタンが表示されていることを確認
    const buttonSection = page.locator('h2');
    await expect(buttonSection).toBeVisible();
    await expect(buttonSection).toHaveText('手を選んでください:');

    // 3つのボタン(グー、チョキ、パー)が表示されていることを確認
    const buttons = page.locator('button');
    await expect(buttons).toHaveCount(3);

    // テーブルが表示されていることを確認
    const table = page.locator('table');
    await expect(table).toBeVisible();

    // テーブルのヘッダーが正しいことを確認
    const tableHeaders = page.locator('th');
    await expect(tableHeaders).toHaveCount(4);
    await expect(tableHeaders.nth(0)).toHaveText('日時');
    await expect(tableHeaders.nth(1)).toHaveText('あなたの手');
    await expect(tableHeaders.nth(2)).toHaveText('相手の手');
    await expect(tableHeaders.nth(3)).toHaveText('結果');
  });

  test('ジャンケンの手を選択して対戦できること', async ({ page }) => {
    // 対戦結果ページにアクセス
    await page.goto('/scores');

    // 対戦前のテーブルの行数を取得
    const rowsBeforePlay = await page.locator('tbody tr').count();

    // グーのボタンをクリック
    await page.locator('button', { hasText: Hand.ROCK }).click();

    // 処理中のメッセージが表示されることを確認
    const pendingMessage = page.locator('p', { hasText: '処理中...' });
    await expect(pendingMessage).toBeVisible();

    // 結果メッセージが表示されることを確認(タイミングによっては見えない場合があるので、オプショナル)
    const resultMessage = page.locator('p', { hasText: `${Hand.ROCK}」を出しました!結果を確認してください。` });
    await resultMessage.waitFor({ state: 'visible', timeout: 5000 }).catch(() => {
      // メッセージが表示されなくても続行
      console.log('結果メッセージが表示されませんでした(タイミングの問題かもしれません)');
    });

    // 対戦後のテーブルの行数が1増えていることを確認
    await expect(page.locator('tbody tr')).toHaveCount(rowsBeforePlay + 1);

    // 最新の対戦結果の「あなたの手」がグーであることを確認
    const firstRow = page.locator('tbody tr').first();
    await expect(firstRow.locator('td').nth(1)).toHaveText(Hand.ROCK);
  });

  test('対戦成績ページが正しく表示されること', async ({ page }) => {
    // 対戦成績ページにアクセス
    await page.goto('/status');

    // ページのヘッダーが表示されていることを確認
    const header = page.locator('h1');
    await expect(header).toBeVisible();                     // ← ②
    await expect(header).toHaveText('対戦成績');

    // ジャンケンボタンが表示されていることを確認
    const buttonSection = page.locator('h2');
    await expect(buttonSection).toBeVisible();
    await expect(buttonSection).toHaveText('手を選んでください:');

    // テーブルが表示されていることを確認
    const table = page.locator('table');
    await expect(table).toBeVisible();

    // テーブルのヘッダーが正しいことを確認
    const tableHeaders = page.locator('th');
    await expect(tableHeaders).toHaveCount(2);
    await expect(tableHeaders.nth(0)).toHaveText('項目');
    await expect(tableHeaders.nth(1)).toHaveText('回数');

    // テーブルの行が3行(勝ち、負け、引き分け)あることを確認
    const tableRows = page.locator('tbody tr');
    await expect(tableRows).toHaveCount(3);

    // 各行のラベルが正しいことを確認
    await expect(tableRows.nth(0).locator('td').first()).toHaveText('勝ち');
    await expect(tableRows.nth(1).locator('td').first()).toHaveText('負け');
    await expect(tableRows.nth(2).locator('td').first()).toHaveText('引き分け');

    // 各行の値が数値であることを確認(具体的な値はテスト実行ごとに異なるため、フォーマットのみ確認)
    for (let i = 0; i < 3; i++) {
      const valueText = await tableRows.nth(i).locator('td').nth(1).textContent();
      expect(valueText).toMatch(/^\d+ 回$/);
    }
  });

  test('ページ間のナビゲーションができること', async ({ page }) => {
    // 対戦結果ページから対戦成績ページに移動できることを確認
    await page.goto('/scores');
    await page.goto('/status');
    await expect(page).toHaveURL('/status');
    await expect(page.locator('h1')).toHaveText('対戦成績');   // ← ③

    // 対戦成績ページから対戦結果ページに移動できることを確認
    await page.goto('/status');
    await page.goto('/scores');
    await expect(page).toHaveURL('/scores');
    await expect(page.locator('h1')).toHaveText('対戦結果');
  });
});

失敗した所に ← ① 〜 ③ のマークを付けました。

テストコードの修正

エラーの箇所は、page.locator('h1');で画面上の<h1>タグを選択していますが、画面には<h1>タグが2箇所あるのでエラーになっていました。 これをpage.locator('h1').nth(1)に変更することでエラーは無くなりましたが、別のエラーが発生しました。

const buttons = page.locator('button');
await expect(buttons).toHaveCount(3);      // ← ④

画面にあるボタンはグー、チョキ、パーの3つのはずですが、なぜか④の箇所でボタンの数が4になっていました。謎の4つ目のボタンはNext.jsがデバッグ用に表示しているボタンでした。😅

ということで、以下のように修正しました。

const buttons = page.locator('button');
await expect(buttons.nth(0)).toHaveText('グー');
await expect(buttons.nth(1)).toHaveText('チョキ');
await expect(buttons.nth(2)).toHaveText('パー');

結果、以下のように全てのテストが通るようになりました❗

> playwright test

Running 5 tests using 5 workers

  ✓  1 [chromium] › tests/e2e/janken.spec.ts:5:7 › ジャンケンアプリのE2Eテスト › ホームページが/scoresにリダイレクトされること (821ms)
  ✓  2 [chromium] › tests/e2e/janken.spec.ts:50:7 › ジャンケンアプリのE2Eテスト › ジャンケンの手を選択して対戦できること (839ms)
  ✓  3 [chromium] › tests/e2e/janken.spec.ts:119:7 › ジャンケンアプリのE2Eテスト › ページ間のナビゲーションができること (1.2s)
  ✓  4 [chromium] › tests/e2e/janken.spec.ts:79:7 › ジャンケンアプリのE2Eテスト › 対戦成績ページが正しく表示されること (1.1s)
  ✓  5 [chromium] › tests/e2e/janken.spec.ts:16:7 › ジャンケンアプリのE2Eテスト › 対戦結果ページが正しく表示されること (730ms)

5 passed (5.5s)

まとめ

残念ながら、生成されたテストコードには間違いがあありました。しかし間違い初歩的なミスではなく人間でも間違いそうな問題でした、そもそも1つのページに<h1>タグが2つ在るのはページとして望ましくありませんね(このページもAIが生成したものですが😅)。

間違いを修正したらテストは通るようになりました。このテスト内容で良いのか?という検討項目があるかもしれませんが、私はそれなりのレベルだと思います。E2Eテストのところにも書きましたが、E2Eテストは優れたテストだが人間が書くにはコストが高く、しかも画面のちょっとした変更で直ぐテストが通らなくなるコスパの低さから敬遠されてきました。

しかし、今回のようにAIが70点くらいのE2Eテストを数分で書いてくれるならコスパ問題も解決されると思います。ちなみに今回はClaude 3.7 Sonnetを使ったので¥70円くらいのAPI使用料がかかりました、たった¥70円です❗

というわけで、AIにE2Eテストを書かせるのは効果が高いかもしれませんね。

- about -

EY-Office代表取締役
・プログラマー
吉田裕美の
開発者向けブログ