テスト生成スキル
テストピラミッド戦略
| テストタイプ | 目安数/機能 | ツール | 目的 |
|---|---|---|---|
| Unit Tests | 5-15個 | Vitest/Jest | ビジネスロジック、エッジケース |
| Integration Tests | 3-8個 | RTL | API契約、コンポーネント連携 |
| E2E Tests | 2-5個 | Playwright | クリティカルユーザージャーニー |
カバレッジ目標
- •Statements: 80%以上
- •Branches: 75%以上
- •Functions: 80%以上
- •Lines: 80%以上
テスト命名規則
typescript
describe('対象モジュール', () => {
describe('対象メソッド/機能', () => {
it('〜の場合、〜となること', () => {
// Arrange(準備)
// Act(実行)
// Assert(検証)
});
});
});
実行手順
- •
対象コードの分析
- •関数のシグネチャ、入出力を把握
- •エッジケース(null、空配列、境界値)を特定
- •外部依存(API、DB)を確認
- •
テストファイル生成
- •配置:
__tests__/または*.test.ts - •命名:
[対象ファイル名].test.ts
- •配置:
- •
テスト実行
bash# ユニット/統合テスト npm run test -- --coverage # 特定ファイルのみ npm run test -- [ファイル名] # E2Eテスト npx playwright test
- •
結果確認と修正
- •失敗したテストがあれば原因を分析
- •実装コードまたはテストコードを修正
- •全テストがパスするまでループ
テストパターンテンプレート
ユニットテスト(純粋関数)
typescript
import { describe, it, expect, vi } from 'vitest';
import { calculateTotal, formatCurrency } from './utils';
describe('calculateTotal', () => {
// 正常系
it('数値配列の合計を正しく計算すること', () => {
expect(calculateTotal([100, 200, 300])).toBe(600);
});
// 境界値
it('空配列の場合0を返すこと', () => {
expect(calculateTotal([])).toBe(0);
});
it('1要素の配列でもその値を返すこと', () => {
expect(calculateTotal([42])).toBe(42);
});
// 異常系
it('不正な入力でエラーをスローすること', () => {
expect(() => calculateTotal(null as any)).toThrow('Invalid input');
});
it('負の数も正しく計算すること', () => {
expect(calculateTotal([-100, 50])).toBe(-50);
});
});
非同期関数テスト
typescript
import { describe, it, expect, vi } from 'vitest';
import { fetchUser } from './api';
describe('fetchUser', () => {
it('ユーザー情報を正しく取得すること', async () => {
const user = await fetchUser(1);
expect(user).toMatchObject({
id: 1,
name: expect.any(String),
email: expect.stringContaining('@'),
});
});
it('存在しないユーザーでエラーをスローすること', async () => {
await expect(fetchUser(99999)).rejects.toThrow('User not found');
});
});
コンポーネントテスト(React Testing Library)
typescript
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { vi } from 'vitest';
import { LoginForm } from './LoginForm';
describe('LoginForm', () => {
const mockOnSubmit = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
});
it('フォームが正しくレンダリングされること', () => {
render(<LoginForm onSubmit={mockOnSubmit} />);
expect(screen.getByLabelText('メールアドレス')).toBeInTheDocument();
expect(screen.getByLabelText('パスワード')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'ログイン' })).toBeInTheDocument();
});
it('有効な入力で送信できること', async () => {
const user = userEvent.setup();
render(<LoginForm onSubmit={mockOnSubmit} />);
await user.type(screen.getByLabelText('メールアドレス'), 'test@example.com');
await user.type(screen.getByLabelText('パスワード'), 'password123');
await user.click(screen.getByRole('button', { name: 'ログイン' }));
await waitFor(() => {
expect(mockOnSubmit).toHaveBeenCalledWith({
email: 'test@example.com',
password: 'password123',
});
});
});
it('無効なメールでエラーが表示されること', async () => {
const user = userEvent.setup();
render(<LoginForm onSubmit={mockOnSubmit} />);
await user.type(screen.getByLabelText('メールアドレス'), 'invalid-email');
await user.click(screen.getByRole('button', { name: 'ログイン' }));
expect(await screen.findByRole('alert')).toHaveTextContent('有効なメールアドレス');
expect(mockOnSubmit).not.toHaveBeenCalled();
});
it('送信中はボタンが無効化されること', async () => {
const user = userEvent.setup();
mockOnSubmit.mockImplementation(() => new Promise(resolve => setTimeout(resolve, 1000)));
render(<LoginForm onSubmit={mockOnSubmit} />);
await user.type(screen.getByLabelText('メールアドレス'), 'test@example.com');
await user.type(screen.getByLabelText('パスワード'), 'password123');
await user.click(screen.getByRole('button', { name: 'ログイン' }));
expect(screen.getByRole('button')).toBeDisabled();
expect(screen.getByRole('button')).toHaveTextContent('ログイン中');
});
});
APIルートテスト(Next.js)
typescript
import { createMocks } from 'node-mocks-http';
import { POST } from '@/app/api/users/route';
describe('POST /api/users', () => {
it('ユーザーを正常に作成できること', async () => {
const { req } = createMocks({
method: 'POST',
body: {
email: 'new@example.com',
name: 'New User',
password: 'securePassword123',
},
});
const response = await POST(req);
const data = await response.json();
expect(response.status).toBe(201);
expect(data.success).toBe(true);
expect(data.data).toMatchObject({
email: 'new@example.com',
name: 'New User',
});
});
it('メール重複で409エラーを返すこと', async () => {
const { req } = createMocks({
method: 'POST',
body: {
email: 'existing@example.com',
name: 'Test',
password: 'password123',
},
});
const response = await POST(req);
expect(response.status).toBe(409);
});
});
E2Eテスト(Playwright)
typescript
import { test, expect } from '@playwright/test';
test.describe('ログインフロー', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/login');
});
test('正常にログインできること', async ({ page }) => {
await page.getByLabel('メールアドレス').fill('user@example.com');
await page.getByLabel('パスワード').fill('password123');
await page.getByRole('button', { name: 'ログイン' }).click();
await expect(page).toHaveURL('/dashboard');
await expect(page.getByRole('heading', { name: 'ダッシュボード' })).toBeVisible();
});
test('誤ったパスワードでエラーが表示されること', async ({ page }) => {
await page.getByLabel('メールアドレス').fill('user@example.com');
await page.getByLabel('パスワード').fill('wrongpassword');
await page.getByRole('button', { name: 'ログイン' }).click();
await expect(page.getByText('メールアドレスまたはパスワードが正しくありません')).toBeVisible();
await expect(page).toHaveURL('/login');
});
test('未入力で送信するとバリデーションエラーが表示されること', async ({ page }) => {
await page.getByRole('button', { name: 'ログイン' }).click();
await expect(page.getByText('メールアドレスを入力してください')).toBeVisible();
await expect(page.getByText('パスワードを入力してください')).toBeVisible();
});
});
モック・スタブパターン
API呼び出しのモック
typescript
import { vi } from 'vitest';
// fetch全体をモック
global.fetch = vi.fn();
beforeEach(() => {
(fetch as any).mockResolvedValue({
ok: true,
json: async () => ({ data: 'mocked' }),
});
});
外部モジュールのモック
typescript
vi.mock('@/lib/auth', () => ({
getCurrentUser: vi.fn().mockResolvedValue({ id: 1, name: 'Test User' }),
signOut: vi.fn(),
}));
出力フォーマット
markdown
## テスト生成結果 ### 生成されたテストファイル - `src/utils/__tests__/calculateTotal.test.ts` - `src/components/__tests__/LoginForm.test.tsx` ### テスト実行結果 ✅ Test Suites: 2 passed, 2 total ✅ Tests: 12 passed, 12 total ⏱️ Time: 3.45s ### カバレッジ | ファイル | Stmts | Branch | Funcs | Lines | |---------|-------|--------|-------|-------| | utils.ts | 95% | 90% | 100% | 95% | | LoginForm.tsx | 88% | 75% | 85% | 88% |
生成前の確認事項
- •テストの種類(Unit/Integration/E2E)は?
- •モックが必要な外部依存は?
- •特に重視するエッジケースは?