React, Vue.js
投稿日:

AI駆動テスト生成:React Testing Libraryのテストを自動で書く方法

こんにちは、株式会社ファストコーディングのフルスタックエンジニア、独身貴族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つのアプローチの違いを比較する

観点丸投げ版設計書ベース版
テスト数1110
テストの単位メソッド単位ユーザー行動単位
テスト名実装用語(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駆動開発を導入したい」という方は、お問い合わせフォームからお気軽にご連絡ください。