AIにテストコードを自動作成してもらった結果

始めに

私たちはフロントテストを行う上で効果的なAIの活用法を見つけるべく調査、検証を行った結果、私たちが得られた知見を共有します。

この記事で分かること

テストコードをAIを使って作る際に気をつけたいこと

使用ツール

  • AWS Bedrock
  • VScode
  • Continue(VScodeの拡張機能)

やったこと

Vite+TypeScriptのプロジェクトのページコンポーネントに対してのテストコードをAIを使ってどうすれば効率よくコーディングできるかどうか、3通りのやり方を試しました。

1.3つの画面のテストコード作成を一気に依頼

プロンプト
@PostDetail.tsx @PostList.tsx @CreatePost.tsx @Vitest このアプリのフロントエンドのテストをページごとに作成してください
はい、Vitestを使ってPostDetail.tsx、PostList.tsx、CreatePost.tsxのテストを作成しましょう。各コンポーネントに対して別々のテストファイルを作成します。

PostDetail.test.tsx
import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import PostDetail from './PostDetail'
import { MemoryRouter, Route, Routes } from 'react-router-dom'

// Mock the AWS Amplify API client
vi.mock('aws-amplify/api', () => ({
  generateClient: vi.fn(() => ({
    models: {
      PostMessage: {
        get: vi.fn(),
        list: vi.fn(),
        create: vi.fn(),
        update: vi.fn(),
      },
    },
  })),
}))

describe('PostDetail', () => {
  it('renders loading state', () => {
    render(
      <MemoryRouter initialEntries={['/post/123']}>
        <Routes>
          <Route path="/post/:postId" element={<PostDetail />} />
        </Routes>
      </MemoryRouter>
    )
    expect(screen.getByText('Loading...')).toBeDefined()
  })

  it('renders post not found', async () => {
    const client = (await import('aws-amplify/api')).generateClient()
    client.models.PostMessage.get.mockResolvedValue({ data: null })

    render(
      <MemoryRouter initialEntries={['/post/123']}>
        <Routes>
          <Route path="/post/:postId" element={<PostDetail />} />
        </Routes>
      </MemoryRouter>
    )

    expect(await screen.findByText('Post not found')).toBeDefined()
  })

  it('renders post details', async () => {
    const mockPost = {
      id: '123',
      userName: 'TestUser',
      message: 'Test message',
      createdAt: '2023-01-01T00:00:00Z',
      updatedAt: '2023-01-01T00:00:00Z',
      good: 5,
    }

    const client = (await import('aws-amplify/api')).generateClient()
    client.models.PostMessage.get.mockResolvedValue({ data: mockPost })
    client.models.PostMessage.list.mockResolvedValue({ data: [] })

    render(
      <MemoryRouter initialEntries={['/post/123']}>
        <Routes>
          <Route path="/post/:postId" element={<PostDetail />} />
        </Routes>
      </MemoryRouter>
    )

    expect(await screen.findByText('TestUser')).toBeDefined()
    expect(screen.getByText('Test message')).toBeDefined()
    expect(screen.getByText('👍 5')).toBeDefined()
  })

  // 他のテストケース(返信機能、いいね機能など)も追加できます
})
PostList.test.tsx
import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import PostList from './PostList'
import { MemoryRouter } from 'react-router-dom'

// Mock the AWS Amplify API client
vi.mock('aws-amplify/api', () => ({
  generateClient: vi.fn(() => ({
    models: {
      PostMessage: {
        list: vi.fn(),
        update: vi.fn(),
      },
    },
  })),
}))

describe('PostList', () => {
  it('renders post list', async () => {
    const mockPosts = [
      { id: '1', userName: 'User1', message: 'Message 1', createdAt: '2023-01-01T00:00:00Z', good: 1 },
      { id: '2', userName: 'User2', message: 'Message 2', createdAt: '2023-01-02T00:00:00Z', good: 2 },
    ]

    const client = (await import('aws-amplify/api')).generateClient()
    client.models.PostMessage.list.mockResolvedValue({ data: mockPosts })

    render(
      <MemoryRouter>
        <PostList />
      </MemoryRouter>
    )

    expect(await screen.findByText('User1')).toBeDefined()
    expect(screen.getByText('Message 1')).toBeDefined()
    expect(screen.getByText('User2')).toBeDefined()
    expect(screen.getByText('Message 2')).toBeDefined()
  })

  it('handles like button click', async () => {
    const mockPosts = [
      { id: '1', userName: 'User1', message: 'Message 1', createdAt: '2023-01-01T00:00:00Z', good: 1 },
    ]

    const client = (await import('aws-amplify/api')).generateClient()
    client.models.PostMessage.list.mockResolvedValue({ data: mockPosts })
    client.models.PostMessage.update.mockResolvedValue({ data: { ...mockPosts[0], good: 2 } })

    render(
      <MemoryRouter>
        <PostList />
      </MemoryRouter>
    )

    const likeButton = await screen.findByText('👍 1')
    fireEvent.click(likeButton)

    expect(await screen.findByText('&am

CreatePost.test.tsx
import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import CreatePost from './CreatePost'
import { MemoryRouter } from 'react-router-dom'

// Mock the AWS Amplify API client
vi.mock('aws-amplify/api', () => ({
  generateClient: vi.fn(() => ({
    models: {
      PostMessage: {
        create: vi.fn(),
      },
    },
  })),
}))

// Mock the useNavigate hook
vi.mock('react-router-dom', async () => {
  const actual = await vi.importActual('react-router-dom')
  return {
    ...actual,
    useNavigate: () => vi.fn(),
  }
})

describe('CreatePost', () => {
  it('renders create post form', () => {
    render(
      <MemoryRouter>
        <CreatePost />
      </MemoryRouter>
    )

    expect(screen.getByText('新規投稿')).toBeDefined()
    expect(screen.getByLabelText('ユーザー名')).toBeDefined()
    expect(screen.getByLabelText('メッセージ')).toBeDefined()
    expect(screen.getByText('投稿する')).toBeDefined()
  })

  it('handles form submission', async () => {
    const client = (await import('aws-amplify/api')).generateClient()
    client.models.PostMessage.create.mockResolvedValue({ data: {} })

    render(
      <MemoryRouter>
        <CreatePost />
      </MemoryRouter>
    )

    fireEvent.change(screen.getByLabelText('ユーザー名'), { target: { value: 'TestUser' } })
    fireEvent.change(screen.getByLabelText('メッセージ'), { target: { value: 'Test message' } })
    fireEvent.click(screen.getByText('投稿する'))

    expect(client.models.PostMessage.create).toHaveBeenCalledWith({
      message: 'Test message',
      userName: 'TestUser',
      good: 0,
    })
  })

  // 他のテストケース(バリデーション、キャンセルボタンのクリックなど)も追加できます
})
これらのテストは、各コンポーネントの主要な機能をカバーしています。Vitestを使用してこれらのテストを実行するには、プロジェクトのルートディレクトリで以下のコマンドを実行します:

npm run test
または

yarn test
テストを正常に実行するためには、以下の追加の設定が必要かもしれません:

@testing-library/react と @testing-library/user-event をインストールする
Vitestの設定ファイル(vitest.config.ts)でReactのテスト環境を設定する
必要に応じて、モックやテストセットアップファイルを追加する
これらのテストは基本的な例であり、実際のアプリケーションの要件に合わせて調整が必要になる可能性があります。
実装した結果

全然動かないコードができた

2.画面を1つに絞ってテストコード作成を依頼

プロンプト
@CreatePost.tsx @Vitest このファイルに対して単体テストをVitestで書いてください。
import { describe, it, expect, vi } from 'vitest';
import { render, fireEvent } from '@testing-library/react';
import CreatePost from './CreatePost';
import { generateClient } from 'aws-amplify/api';

// モックの設定
vi.mock('aws-amplify/api', () => ({
  generateClient: vi.fn(),
}));

vi.mock('react-router-dom', () => ({
  useNavigate: () => vi.fn(),
}));

describe('CreatePost', () => {
  it('renders correctly', () => {
    const { getByText, getByLabelText } = render(<CreatePost />);
    expect(getByText('新規投稿')).toBeTruthy();
    expect(getByLabelText('ユーザー名')).toBeTruthy();
    expect(getByLabelText('メッセージ')).toBeTruthy();
  });

  it('updates input values', () => {
    const { getByLabelText } = render(<CreatePost />);
    const userNameInput = getByLabelText('ユーザー名') as HTMLInputElement;
    const messageInput = getByLabelText('メッセージ') as HTMLTextAreaElement;

    fireEvent.change(userNameInput, { target: { value: 'TestUser' } });
    fireEvent.change(messageInput, { target: { value: 'Test message' } });

    expect(userNameInput.value).toBe('TestUser');
    expect(messageInput.value).toBe('Test message');
  });

  it('submits form with valid data', async () => {
    const mockCreate = vi.fn().mockResolvedValue({});
    (generateClient as jest.Mock).mockReturnValue({
      models: {
        PostMessage: {
          create: mockCreate,
        },
      },
    });

    const { getByLabelText, getByText } = render(<CreatePost />);
    const userNameInput = getByLabelText('ユーザー名');
    const messageInput = getByLabelText('メッセージ');
    const submitButton = getByText('投稿する');

    fireEvent.change(userNameInput, { target: { value: 'TestUser' } });
    fireEvent.change(messageInput, { target: { value: 'Test message' } });
    fireEvent.click(submitButton);

    expect(mockCreate).toHaveBeenCalledWith({
      message: 'Test message',
      userName: 'TestUser',
      good: 0,
    });
  });
});
実装した結果

精度は多少上がったが、まだまだ改善の余地がありそう

3.最初にテストケースを書いてもらい、テストケース毎にスコープ絞って実装

a.テストケースの出力
プロンプト
@CreatePost.tsx @Vitest 投稿画面のコードを確認し、テストコードをまずはitでテストケースだけ作成してください。テストケースの説明は日本語でお願いします。
はい、承知しました。@CreatePost.tsxの投稿画面のコードを確認し、Vitestを使用してテストケースを作成します。まずは、itブロックでテストケースの概要を日本語で記述します。
import { describe, it, expect } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import CreatePost from './CreatePost';

describe('CreatePost', () => {
 it('投稿フォームが正しくレンダリングされること', () => {
    // テストコードをここに実装
  });

  it('タイトルの入力フィールドに正しく入力できること', () => {
    // テストコードをここに実装
  });

  it('本文の入力フィールドに正しく入力できること', () => {
    // テストコードをここに実装
  });

  it('投稿ボタンをクリックしたときに、正しくonSubmit関数が呼び出されること', () => {
    // テストコードをここに実装
  });

  it('タイトルが空の場合、エラーメッセージが表示されること', () => {
    // テストコードをここに実装
  });

  it('本文が空の場合、エラーメッセージが表示されること', () => {
    // テストコードをここに実装
  });

  it('投稿が成功した場合、成功メッセージが表示されること', () => {
    // テストコードをここに実装
  });

  it('投稿が失敗した場合、エラーメッセージが表示されること', () => {
    // テストコードをここに実装
  });
});
これらのテストケースは、CreatePost コンポーネントの主要な機能と動作をカバーしています。各 it ブロック内に具体的なテストコードを実装することで、コンポーネントの挙動を確認することができます。
実際のテストコードを実装する際は、render 関数でコンポーネントをレンダリングし、screen や fireEvent などを使用して要素の存在確認やユーザーアクションのシミュレーションを行います。また、必要に応じてモックやスパイを使用して、外部の依存関係(例:API呼び出し)をテストから分離することも検討してください。
b.テストケースを指定してコードを出力する
プロンプト
@Vitest @CreatePost.tsx を見て、「投稿フォームが正しくレンダリングされること」のテストコード書いてください。
はい、承知しました。@CreatePost.tsx の内容を踏まえて、「投稿フォームが正しくレンダリングされること」のテストコードを Vitest を使って書きます。

以下は、想定される CreatePost コンポーネントの基本的な構造に基づいたテストコードです:

import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import CreatePost from './CreatePost';

describe('CreatePost', () => {
  it('投稿フォームが正しくレンダリングされること', () => {
    render(<CreatePost />);

    // タイトル入力フィールドが存在することを確認
    expect(screen.getByLabelText('タイトル')).toBeInTheDocument();

    // 本文入力フィールドが存在することを確認
    expect(screen.getByLabelText('本文')).toBeInTheDocument();

    // 投稿ボタンが存在することを確認
    expect(screen.getByRole('button', { name: '投稿' })).toBeInTheDocument();

    // フォームのヘッダーが正しく表示されていることを確認
    expect(screen.getByText('新規投稿')).toBeInTheDocument();
  });
});
このテストコードでは以下のことを確認しています:

タイトル入力フィールドが存在すること
本文入力フィールドが存在すること
投稿ボタンが存在すること
フォームのヘッダー("新規投稿"というテキスト)が表示されていること
注意点:

このテストコードは、CreatePost コンポーネントが特定の構造を持っていることを前提としています。実際のコンポーネントの実装に合わせて、セレクタやテキストを適宜調整する必要があります。

@testing-library/react の render 関数と screen オブジェクトを使用しています。これらをインポートしていることを確認してください。

Vitest で Jest の DOM 拡張を使用するには、設定ファイル(vite.config.ts や vitest.config.ts)で @testing-library/jest-dom をセットアップする必要があります。

このテストを実行することで、CreatePost コンポーネントが期待通りにレンダリングされ、必要な要素が全て存在することを確認できます。
実装してどうだったか

未実装のテストケースができるなど、手で修正する部分はあったがそれなりに動く

分かったこと

  • スコープを絞ると精度が高くなった
  • モックが鬼門
  • ドキュメント参照だけでは動くテストコードができない

気をつけたいこと

  • 一気にテストコードを作るのではなく、スコープを絞りひとつずつ作っていく方がよい
  • AIにすべて任せるのではなく、AIと対話しながら作っていく必要がある

まとめ

3番目のやり方が一番筋がよさそうだと感じましたが、少なからず人間の手で修正する必要があり、AIにコーディングのすべてを任せることはできないことが分かりました。

今回試したChat形式のAIとコード補完のAI(Github copilotなど)の両方を使うことでもっと楽にコーディングできるのではないかという可能性も感じました。