こんにちは、株式会社ファストコーディングのフルスタックエンジニア、独身貴族Fireです。
最近、ギターの練習で録音した自分の演奏を聴き返す習慣をつけているんです。弾いているときは「いい感じ」と思っていても、録音を聴くと「ここのリズム、微妙にズレてるな」「このコード、ミュートが甘い」と気づくことばかり。客観的なチェックって大事です。
コードも同じですよね。自分で書いたコードは「動いてるから大丈夫」と思いがちですが、テストを書いてみると想定外の挙動に気づきます。
テストの重要性は誰もが認めるところですが、実際に書く時間が取れないのが現場の本音ではないでしょうか。「機能の実装だけで手一杯で、テストまで手が回らない」「テストを書き始めたけど、何をテストすべきかの設計で悩んで手が止まる」——こういう声は本当によく聞きます。
ここでAI駆動開発の出番です。AIにReactコンポーネントのコードを渡して「テストを書いて」と頼めば、テストコードが一瞬で出てきます。ただし、AIが書いたテストには特有の問題があります。カバレッジは高いが、テストの意図が薄いのです。
今回は、AIにテストを書かせる2つのアプローチを比較します。「コードを渡して丸投げ」と「テスト設計を人間がした上でAIに実装させる」の違いを、実際のコードで見ていきます。
テスト対象のコンポーネント
テスト対象は、前回の記事で作成したお問い合わせフォームのバリデーションフックです。
// テスト対象:useFormValidation.ts(簡略版)
import { useState, useCallback, useRef } from 'react';
type ValidationRule = {
required?: boolean;
minLength?: number;
pattern?: RegExp;
message?: string;
};
type FieldConfig = Record<string, ValidationRule>;
export function useFormValidation(
config: FieldConfig,
initialValues: Record<string, string> = {}
) {
const [values, setValues] = useState(initialValues);
const [errors, setErrors] = useState<Record<string, string>>({});
const [touched, setTouched] = useState<Record<string, boolean>>({});
const configRef = useRef(config);
configRef.current = config;
const validate = useCallback((name: string, value: string) => {
const rules = configRef.current[name];
if (!rules) return null;
if (rules.required && !value.trim()) return rules.message || '必須項目です';
if (rules.minLength && value.length < rules.minLength)
return `${rules.minLength}文字以上で入力してください`;
if (rules.pattern && !rules.pattern.test(value))
return rules.message || '形式が正しくありません';
return null;
}, []);
const handleChange = useCallback((name: string, value: string) => {
setValues(prev => ({ ...prev, [name]: value }));
setTouched(prev => ({ ...prev, [name]: true }));
const error = validate(name, value);
setErrors(prev => ({ ...prev, [name]: error || '' }));
}, [validate]);
const handleBlur = useCallback((name: string) => {
setTouched(prev => ({ ...prev, [name]: true }));
const error = validate(name, values[name] || '');
setErrors(prev => ({ ...prev, [name]: error || '' }));
}, [validate, values]);
const handleSubmit = useCallback((onSubmit: (v: Record<string, string>) => void) => {
const allTouched: Record<string, boolean> = {};
const newErrors: Record<string, string> = {};
Object.keys(configRef.current).forEach(name => {
allTouched[name] = true;
const error = validate(name, values[name] || '');
if (error) newErrors[name] = error;
});
setTouched(allTouched);
setErrors(newErrors);
if (Object.keys(newErrors).length === 0) onSubmit(values);
}, [validate, values]);
const reset = useCallback(() => {
setValues(initialValues);
setErrors({});
setTouched({});
}, [initialValues]);
const getFieldError = useCallback((name: string) => {
return touched[name] ? errors[name] || '' : '';
}, [touched, errors]);
return { values, errors, touched, handleChange, handleBlur, handleSubmit, reset, getFieldError };
}アプローチ1:AIに丸投げ
まず、このコードをClaudeにそのまま渡して「React Testing Libraryでテストを書いてください」とだけ聞いてみます。
AIが出したテスト(丸投げ版)
// AIが出した丸投げ版テスト
import { renderHook, act } from '@testing-library/react';
import { useFormValidation } from './useFormValidation';
describe('useFormValidation', () => {
const config = {
name: { required: true, minLength: 2 },
email: { required: true, pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/ },
};
test('初期値が設定される', () => {
const { result } = renderHook(() =>
useFormValidation(config, { name: 'テスト', email: '' })
);
expect(result.current.values.name).toBe('テスト');
});
test('handleChangeで値が更新される', () => {
const { result } = renderHook(() => useFormValidation(config));
act(() => { result.current.handleChange('name', '山田'); });
expect(result.current.values.name).toBe('山田');
});
test('handleChangeでtouchedがtrueになる', () => {
const { result } = renderHook(() => useFormValidation(config));
act(() => { result.current.handleChange('name', '山田'); });
expect(result.current.touched.name).toBe(true);
});
test('必須バリデーションが動作する', () => {
const { result } = renderHook(() => useFormValidation(config));
act(() => { result.current.handleChange('name', ''); });
expect(result.current.errors.name).toBe('必須項目です');
});
test('minLengthバリデーションが動作する', () => {
const { result } = renderHook(() => useFormValidation(config));
act(() => { result.current.handleChange('name', 'あ'); });
expect(result.current.errors.name).toBe('2文字以上で入力してください');
});
test('patternバリデーションが動作する', () => {
const { result } = renderHook(() => useFormValidation(config));
act(() => { result.current.handleChange('email', 'invalid'); });
expect(result.current.errors.email).toBe('形式が正しくありません');
});
test('正しい値でエラーがクリアされる', () => {
const { result } = renderHook(() => useFormValidation(config));
act(() => { result.current.handleChange('name', ''); });
expect(result.current.errors.name).toBe('必須項目です');
act(() => { result.current.handleChange('name', '山田太郎'); });
expect(result.current.errors.name).toBe('');
});
test('handleSubmitでバリデーションエラーがあればコールバックが呼ばれない', () => {
const { result } = renderHook(() => useFormValidation(config));
const onSubmit = jest.fn();
act(() => { result.current.handleSubmit(onSubmit); });
expect(onSubmit).not.toHaveBeenCalled();
});
test('handleSubmitで全フィールドがtouchedになる', () => {
const { result } = renderHook(() => useFormValidation(config));
act(() => { result.current.handleSubmit(() => {}); });
expect(result.current.touched.name).toBe(true);
expect(result.current.touched.email).toBe(true);
});
test('resetで初期状態に戻る', () => {
const { result } = renderHook(() =>
useFormValidation(config, { name: '', email: '' })
);
act(() => { result.current.handleChange('name', '山田'); });
act(() => { result.current.reset(); });
expect(result.current.values.name).toBe('');
expect(result.current.touched.name).toBeUndefined();
});
test('getFieldErrorは未タッチフィールドでは空文字を返す', () => {
const { result } = renderHook(() => useFormValidation(config));
expect(result.current.getFieldError('name')).toBe('');
});
});丸投げ版の問題
テストは11個あり、全部パスします。カバレッジも高い。ではこのテスト、何が問題でしょうか。
1. テストが「実装の確認」になっているhandleChangeで値が更新される、handleChangeでtouchedがtrueになる——これらは内部実装の動作確認であって、「ユーザーにとって何が起きるか」のテストではありません。実装を変更したらテストも壊れる、もろいテストです。
2. シナリオが断片的
個々のメソッドを単体テストしていますが、「ユーザーがフォームに入力して送信する」という一連の流れをテストしていません。バイクの整備点検で言えば、「エンジンは回る」「タイヤは回る」「ブレーキは効く」と個別に確認しているのに、「実際に走ってみる」テストがない状態です。
3. エッジケースの考慮が浅い
空白のみの入力(" ")、configに存在しないフィールドへの操作、handleBlurの動作など、実際のフォームで起きそうなケースが抜けています。
アプローチ2:テスト設計を人間がしてからAIに実装させる
次に、テストの設計(何をテストするか)は人間が決め、実装(テストコード)はAIに書かせるアプローチを試します。
テスト設計書(人間が作成)
Claudeに以下のテスト設計書を渡しました。
以下のテスト設計書に基づいて、React Testing Libraryでテストを実装してください。
【テスト対象】useFormValidation カスタムフック
【テストグループ1:ユーザーの入力フロー】
- ユーザーが名前を入力し、フォーカスを外すとバリデーションが実行される
- ユーザーがメール欄に不正な値を入力すると即座にエラーが表示される
- ユーザーがエラーを修正すると、エラーメッセージが消える
【テストグループ2:送信フロー】
- 全フィールドが正しい状態で送信するとコールバックが呼ばれる
- 1つでもエラーがある状態で送信するとコールバックは呼ばれず、全エラーが表示される
- 送信時、未タッチのフィールドにもエラーが表示される(全フィールドがtouchedになる)
【テストグループ3:エッジケース】
- 空白のみの入力は必須バリデーションに引っかかる
- configに存在しないフィールド名で操作してもエラーにならない
- リセット後に再入力→再送信が正常に動作する
- 初期値付きで初期化した場合、その値がvaluesに反映されるAIが出したテスト(設計書ベース版)
// 設計書ベース版テスト
import { renderHook, act } from '@testing-library/react';
import { useFormValidation } from './useFormValidation';
const config = {
name: { required: true, minLength: 2 },
email: {
required: true,
pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
message: 'メールアドレスの形式が正しくありません',
},
};
describe('useFormValidation', () => {
describe('ユーザーの入力フロー', () => {
test('名前を入力してフォーカスを外すとバリデーションが実行される', () => {
const { result } = renderHook(() => useFormValidation(config));
// フォーカスを外す(blur)
act(() => { result.current.handleBlur('name'); });
// 未入力なのでエラーが表示される
expect(result.current.getFieldError('name')).toBe('必須項目です');
});
test('不正なメールアドレスを入力すると即座にエラーが表示される', () => {
const { result } = renderHook(() => useFormValidation(config));
act(() => { result.current.handleChange('email', 'not-an-email'); });
expect(result.current.getFieldError('email'))
.toBe('メールアドレスの形式が正しくありません');
});
test('エラーを修正するとエラーメッセージが消える', () => {
const { result } = renderHook(() => useFormValidation(config));
// エラーを出す
act(() => { result.current.handleChange('email', 'bad'); });
expect(result.current.getFieldError('email')).not.toBe('');
// 正しい値に修正
act(() => { result.current.handleChange('email', 'test@example.com'); });
expect(result.current.getFieldError('email')).toBe('');
});
});
describe('送信フロー', () => {
test('全フィールドが正しい状態で送信するとコールバックが呼ばれる', () => {
const { result } = renderHook(() => useFormValidation(config));
const onSubmit = jest.fn();
act(() => { result.current.handleChange('name', '山田太郎'); });
act(() => { result.current.handleChange('email', 'yamada@example.com'); });
act(() => { result.current.handleSubmit(onSubmit); });
expect(onSubmit).toHaveBeenCalledWith({
name: '山田太郎',
email: 'yamada@example.com',
});
});
test('エラーがある状態で送信するとコールバックは呼ばれず全エラーが表示される', () => {
const { result } = renderHook(() => useFormValidation(config));
const onSubmit = jest.fn();
act(() => { result.current.handleChange('name', '山田太郎'); });
// emailは未入力のまま
act(() => { result.current.handleSubmit(onSubmit); });
expect(onSubmit).not.toHaveBeenCalled();
expect(result.current.getFieldError('email')).toBe('メールアドレスの形式が正しくありません');
});
test('送信時に未タッチのフィールドにもエラーが表示される', () => {
const { result } = renderHook(() => useFormValidation(config));
// 何も入力せずに送信
act(() => { result.current.handleSubmit(() => {}); });
// 両フィールドともtouchedになり、エラーが表示される
expect(result.current.getFieldError('name')).toBe('必須項目です');
expect(result.current.getFieldError('email')).toBe('メールアドレスの形式が正しくありません');
});
});
describe('エッジケース', () => {
test('空白のみの入力は必須バリデーションに引っかかる', () => {
const { result } = renderHook(() => useFormValidation(config));
act(() => { result.current.handleChange('name', ' '); });
expect(result.current.getFieldError('name')).toBe('必須項目です');
});
test('configに存在しないフィールド名で操作してもエラーにならない', () => {
const { result } = renderHook(() => useFormValidation(config));
act(() => { result.current.handleChange('unknown', 'value'); });
expect(result.current.values.unknown).toBe('value');
expect(result.current.getFieldError('unknown')).toBe('');
});
test('リセット後に再入力→再送信が正常に動作する', () => {
const { result } = renderHook(() =>
useFormValidation(config, { name: '', email: '' })
);
const onSubmit = jest.fn();
// 入力→送信
act(() => { result.current.handleChange('name', '山田太郎'); });
act(() => { result.current.handleChange('email', 'test@example.com'); });
act(() => { result.current.handleSubmit(onSubmit); });
expect(onSubmit).toHaveBeenCalledTimes(1);
// リセット
act(() => { result.current.reset(); });
expect(result.current.values.name).toBe('');
// 再入力→再送信
act(() => { result.current.handleChange('name', '鈴木花子'); });
act(() => { result.current.handleChange('email', 'suzuki@example.com'); });
act(() => { result.current.handleSubmit(onSubmit); });
expect(onSubmit).toHaveBeenCalledTimes(2);
expect(onSubmit).toHaveBeenLastCalledWith({
name: '鈴木花子',
email: 'suzuki@example.com',
});
});
test('初期値付きで初期化した場合、その値がvaluesに反映される', () => {
const { result } = renderHook(() =>
useFormValidation(config, { name: '初期値', email: 'init@example.com' })
);
expect(result.current.values.name).toBe('初期値');
expect(result.current.values.email).toBe('init@example.com');
});
});
});2つのアプローチの違いを比較する
| 観点 | 丸投げ版 | 設計書ベース版 |
|---|---|---|
| テスト数 | 11 | 10 |
| テストの単位 | メソッド単位 | ユーザー行動単位 |
| テスト名 | 実装用語(handleChange, touched) | ユーザー行動(入力→エラー表示) |
| シナリオ | 断片的 | 一連の流れ |
| エッジケース | 少ない | 空白入力、未知フィールド、リセット後再送信 |
| 壊れやすさ | 実装変更で壊れる | インターフェースが変わらなければ壊れない |
数だけ見ると大差ありませんが、テストの「質」がまったく違います。
丸投げ版は「このメソッドを呼んだらこの状態になる」という確認です。実装のリファクタリングをするとテストも書き直しになります。設計書ベース版は「ユーザーがこう操作したらこうなる」という確認です。内部実装が変わっても、外部の振る舞いが同じならテストは通り続けます。
テストのメンテナンスコストって、書いた本人以外にはわかりにくいんですよね。「テストがあるから安心」と思っていたら、リファクタリングのたびにテスト修正で半日潰れる、みたいな。
AI駆動テスト生成のワークフロー
半年間の実践から、以下のワークフローが最も効率的だとわかりました。
ステップ1:テスト設計は人間がやる
「何をテストするか」はAIに任せない。ユーザーの操作フロー、エッジケース、ビジネスロジックの境界値を人間が洗い出す。
ステップ2:テスト設計書をAIに渡して実装させる
テストの「構造」と「期待値」を明記した設計書を作り、AIにテストコードを書かせる。これがもっともAIの得意な部分です。
ステップ3:AIが書いたテストを実行して修正する
テストが通るかどうかだけでなく、「テスト名を読んだだけで何を検証しているかわかるか」を確認する。わかりにくいテスト名はAIに書き直させる。
バイクの整備に例えると、何を点検するかのチェックリストは整備士(人間)が作り、実際のボルト締めや油差しは工具(AI)が効率よくやるという分担です。工具にチェックリストを作らせると、「ネジが全部ついているか確認」のような表面的なリストになってしまう。
まとめ
今回は、React Testing LibraryのテストをAI駆動開発で書く2つのアプローチを比較しました。
AIにテストを任せるとき、「何をテストするか」の設計を人間が行うかどうかで、テストの質は大きく変わります。押さえておきたいポイントは以下の4つです。
- AIにコードを丸投げするとカバレッジは高いが「テストの意図が薄い」テストが生成される
- テスト設計(何をテストするか)を人間が行い、実装(テストコード)をAIに任せるのが最も効率的
- テストはメソッド単位ではなく、ユーザー行動単位で書く。「handleChangeが動く」ではなく「入力するとエラーが表示される」
- エッジケース(空白入力、未知フィールド、リセット後再送信)は人間が設計書に明記しないとAIは出さない
AI駆動開発では、テストも「AIと人間の協働」です。設計は人間、実装はAI。この分担が最もテストの品質と開発速度のバランスが取れます。
株式会社ファストコーディングでは、React/Next.jsの開発と、AI駆動開発を取り入れたテスト戦略の策定をお手伝いしています。「テストの書き方に悩んでいる」「AI駆動開発を導入したい」という方は、お問い合わせフォームからお気軽にご連絡ください。

