先週 に続きAIネタです。仕事でReactコンポーネントのテストコードを生成してもらったらけっこう良かったのです。このブログでお馴染みのジャンケンアプリのテストを書いてもらいました。
Bing Image Creatorが生成した画像を使っています
テスト対象のジャンケンアプリ
最近Next.jsばかり使っていたので、久しぶりにViteを使ってみました。最新のViteで使ったのですが出来たReactアプリはReact18.3を使っていました、しかしpackage.json
のreact
, react-dom
, @types/react
等を19に書き変えてnpmインストールしたところReact19になりました。
その後、Tailwind CSSをインストールしたところTailwind4.0がインストールされました。コードは以下になります、1つのコンポーネントで実装しました。
import { useState } from 'react';
const Te = {
Guu: 0,
Choki: 1,
Paa: 2
} as const;
const Judgment = {
Draw: 0,
Win: 1,
Lose: 2
} as const;
type Te = (typeof Te)[keyof typeof Te];
type Judgment = (typeof Judgment)[keyof typeof Judgment];
type Score = {
human: Te;
computer: Te;
judgment: Judgment;
};
function App() {
const [scores, setScores] = useState<Score[]>([]);
const pon = (human: Te) => {
const computer = Math.floor(Math.random() * 3) as Te;
const judgment = (computer - human + 3) % 3 as Judgment;
const score: Score = {human, computer, judgment};
setScores([score, ...scores]);
}
const teString = ["グー","チョキ", "パー"];
const judgmentString = ["引き分け","勝ち", "負け"];
const buttonClass = `text-white text-center text-sm rounded w-16 px-2 py-2
bg-blue-600 hover:bg-blue-700 shadow shadow-gray-800/50`;
const JudgmentColor = ["text-[#000]", "text-[#2979ff]", "text-[#ff1744]"];
return (
<div className="mx-2 md:mx-8 md:w-1/2">
<h1 className="my-6 text-center text-3xl font-bold">じゃんけん ポン!</h1>
<div className="w-[230px] mx-auto flex justify-between mb-10">
<button onClick={() => pon(Te.Guu)} className={buttonClass}>グー</button>
<button onClick={() => pon(Te.Choki)} className={buttonClass}>チョキ</button>
<button onClick={() => pon(Te.Paa)} className={buttonClass}>パー</button>
</div>
<table className="w-full text-sm text-left text-gray-500">
<thead className="bg-slate-100 border">
<tr>
<th className="px-6 py-3">あなた</th>
<th className="px-6 py-3">コンピュター</th>
<th className="px-6 py-3">勝敗</th>
</tr>
</thead>
<tbody className="bg-white border">
{scores.map((score, ix) =>
<tr key={ix} className="bg-white border-b">
<td className={`px-2 md:px-6 py-4 ${JudgmentColor[score.judgment]}`}>{teString[score.human]}</td>
<td className={`px-2 md:px-6 py-4 ${JudgmentColor[score.judgment]}`}>{teString[score.computer]}</td>
<td className={`px-2 md:px-6 py-4 ${JudgmentColor[score.judgment]}`}>{judgmentString[score.judgment]}</td>
</tr>
)}
</tbody>
</table>
</div>
);
}
export default App
テスト環境
テスト環境はVitest + React Testing Libraryにしました。インストールしたnpmは以下です
$ npm install -D vitest jsdom @testing-library/react @testing-library/jest-dom
$ npm install -D @testing-library/user-event
設定ファイル vite.config.ts
は以下です
/// <reference types="vitest" />
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react-swc'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [
react(),
tailwindcss(),
],
test: {
globals: true,
environment: "jsdom",
watch: false,
},
})
テストコードの生成
AI(Cline + claude-3-5-sonnet-20241022)には以下のように依頼しました。
このこのコンポーネントテストのコードを作ってください。
- ツールは
- Vitest
- React Testing Library
- テスト項目
- ジャンケンが正しく行われているかのテスト
- コンピュターの手はMath.randomで発生しているので、ここはVitestのモックを使ってください
AIはテストコードの前に以下のいうなジャンケン・コンポーネントの仕様とテストの実装方針を表示してくれました。素晴らしい!
このApp.tsxファイルは、Reactで実装されたじゃんけんゲームのメインコンポーネントです。主な機能は以下の通りです:
- ゲームの基本構造:
- グー(0)、チョキ(1)、パー(2)の3つの手を定義
- 勝敗判定は引き分け(0)、勝ち(1)、負け(2)で表現
- ゲームの動作:
- ユーザーが選んだ手に対して、コンピューターがランダムに手を選択
- 勝敗を自動的に判定し、結果を記録
- 過去の対戦結果をテーブル形式で表示
- UI要素:
- 「じゃんけん ポン!」というタイトル
- グー、チョキ、パーの3つの選択ボタン
- 対戦履歴を表示するテーブル(あなた、コンピューター、勝敗の3列)
- 勝敗に応じて結果の文字色が変化(黒:引き分け、青:勝ち、赤:負け)
このアプリケーションはTypeScriptとTailwind CSSを使用して実装されており、レスポンシブデザインにも対応しています。
テストの実装方針:
- React Testing Libraryのrenderとscreenを使用してコンポーネントをレンダリング
- fireEventを使用してボタンクリックをシミュレート
- Vitestのvi.spyOnを使用してMath.randomをモック化
- expectと各種マッチャーを使用してアサーションを実装
この計画でテストを実装してよろしいでしょうか?実装を開始する場合は、ACTモードに切り替えていただければと思います。
1. 生成されたコード
さて、生成されたコードが以下です、一見良さそうですね。テスト項目は
- 初期表示の確認
- ジャンケンの勝敗判定 - コンピューターがグーの場合
- ジャンケンの履歴が正しく表示される
- 複数回のジャンケンで履歴が正しく積み重なる
乱数によるコンピューターの手の発生部分はモックを使っています。vi.spyOn(Math, 'random').mockImplementation(() => 0);
しかし、①のtoBeInTheDocument()
メソッドがVS Code上で未定義になっています!
import { describe, test, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import App from './App';
describe('じゃんけんアプリ', () => {
beforeEach(() => {
// Math.randomのモックをリセット
vi.restoreAllMocks();
});
test('初期表示の確認', () => {
render(<App />);
// タイトルの確認
expect(screen.getByText('じゃんけん ポン!')).toBeInTheDocument(); // ← ①
// ボタンの確認
expect(screen.getByText('グー')).toBeInTheDocument();
expect(screen.getByText('チョキ')).toBeInTheDocument();
expect(screen.getByText('パー')).toBeInTheDocument();
// テーブルヘッダーの確認
expect(screen.getByText('あなた')).toBeInTheDocument();
expect(screen.getByText('コンピュター')).toBeInTheDocument();
expect(screen.getByText('勝敗')).toBeInTheDocument();
});
test('ジャンケンの勝敗判定 - コンピューターがグーの場合', () => {
// コンピューターが常にグー(0)を出すようにモック
vi.spyOn(Math, 'random').mockImplementation(() => 0);
render(<App />);
// グーを出す(引き分け)
fireEvent.click(screen.getByText('グー'));
expect(screen.getByText('引き分け')).toBeInTheDocument();
// チョキを出す(負け)
fireEvent.click(screen.getByText('チョキ'));
const loseResults = screen.getAllByText('負け');
expect(loseResults).toHaveLength(1);
// パーを出す(勝ち)
fireEvent.click(screen.getByText('パー'));
const winResults = screen.getAllByText('勝ち');
expect(winResults).toHaveLength(1);
});
test('ジャンケンの履歴が正しく表示される', () => {
// コンピューターが常にグー(0)を出すようにモック
vi.spyOn(Math, 'random').mockImplementation(() => 0);
render(<App />);
// パーを出す(勝ち)
fireEvent.click(screen.getByText('パー'));
// 結果の確認
const rows = screen.getAllByRole('row');
// ヘッダー行を除いて最初の結果行を取得
const firstResultRow = rows[1];
// 結果行の内容を確認
expect(firstResultRow).toHaveTextContent('パー');
expect(firstResultRow).toHaveTextContent('グー');
expect(firstResultRow).toHaveTextContent('勝ち');
});
test('複数回のジャンケンで履歴が正しく積み重なる', () => {
// コンピューターが常にグー(0)を出すようにモック
vi.spyOn(Math, 'random').mockImplementation(() => 0);
render(<App />);
// 3回ジャンケンを行う
fireEvent.click(screen.getByText('パー')); // 勝ち
fireEvent.click(screen.getByText('チョキ')); // 負け
fireEvent.click(screen.getByText('グー')); // 引き分け
// 結果行の取得(ヘッダーを除く)
const rows = screen.getAllByRole('row').slice(1);
// 最新の結果が一番上に表示されることを確認
expect(rows[0]).toHaveTextContent('グー');
expect(rows[0]).toHaveTextContent('引き分け');
expect(rows[1]).toHaveTextContent('チョキ');
expect(rows[1]).toHaveTextContent('負け');
expect(rows[2]).toHaveTextContent('パー');
expect(rows[2]).toHaveTextContent('勝ち');
});
});
2. インポートの追加
調べてみると、Vitestでは@testing-library/jest-dom/vitest
をインポートしないといけないようです。
import { describe, test, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom/vitest';
import App from './App';
これでコンパイルエラーが無くなったのでテストを実行してみました
3. テスト結果
最後のテストが落ちていました。
エラーの原因はfireEvent.click(screen.getByText('グー'));
の部分で、画面上のグー
という文字がボタンだけでなく、対戦結果にグーという文字列があるからです。
❯ src/App.test.tsx (4 tests | 1 failed) 55ms
✓ じゃんけんアプリ > 初期表示の確認
✓ じゃんけんアプリ > ジャンケンの勝敗判定 - コンピューターがグーの場合
✓ じゃんけんアプリ > ジャンケンの履歴が正しく表示される
× じゃんけんアプリ > 複数回のジャンケンで履歴が正しく積み重なる 7ms
→ Found multiple elements with the text: グー
そこで、ボタンクリックのコードを fireEvent.click(screen.getByRole('button', {name:'グー'}));
に書き変えたところテストが通るようになりました(他のボタンクリックも同様に書き変えました)!
4. テスト完成
$ npx vitest
RUN v3.0.4
✓ src/App.test.tsx (4 tests) 82ms
✓ じゃんけんアプリ > 初期表示の確認
✓ じゃんけんアプリ > ジャンケンの勝敗判定 - コンピューターがグーの場合
✓ じゃんけんアプリ > ジャンケンの履歴が正しく表示される
✓ じゃんけんアプリ > 複数回のジャンケンで履歴が正しく積み重なる
Test Files 1 passed (1)
Tests 4 passed (4)
Start at 16:00:25
Duration 743ms (transform 72ms, setup 0ms, collect 219ms, tests 82ms,
environment 254ms, prepare 43ms)
まとめ
生成されたテストコードには少し間違いがありましたが、テストコードの仕様はとても良いと思いました。
以前からテストコードを書かない理由として「テストを書く時間が無い!」でしたが、AIに作ってもらうなら瞬時です完成します! しかもコストは約10円でした!
今回生成されたコードは完璧ではなく、修正に時間がかかりましたが30分くらいでした。もちろん修正にはReact Tesiting LibraryやVitestの基本知識は必要ですが、AIの進歩は目覚ましいので生成されるコードの精度は年々上がって行くと思います。