testing-strategy について
アプリケーションのテストを追加・改善する開発者向けのスキルです。ユニット、コンポーネント、統合、E2Eテストを扱い、主要フレームワークの例とカバレッジ、CI、モック、フレーキーテストの対策を示します。
主な機能
- 01ユニット・コンポーネントテストのパターン
- 02統合・E2Eテストの案内
- 03Vitest、Jest、Playwrightの例
- 04MSWによるAPIモック
- 05カバレッジとCIの実践
- 06フレーキーテストの調査
活用シーン
- 01テスト基盤のセットアップ
- 02Reactコンポーネントやフックのテスト追加
- 03APIルートとデータベース操作のテスト
- 04カバレッジ不足や不安定なテストの発見
インストール
コマンドをコピーしてターミナルで実行します。
$
npx skills add https:/ / github.com/ mgd34msu/ goodvibes-plugin --skill testing-strategyインストール後、Claude Code で/testing-strategyを入力して読み込まれたか確認します。
同梱ファイルreferences/testing-patterns.mdscripts/validate-tests.sh
詳細
リソース
scripts/
validate-tests.sh
references/
testing-patterns.md
テスト戦略
このスキルでは、最新のテストフレームワークと GoodVibes の高精度ツールを使用して、包括的なテスト戦略を実装する方法を説明します。既存のコードにテストを追加する場合、テスト基盤を設定する場合、またはカバレッジ目標を達成する場合に、このワークフローを使用してください。
このスキルを使用する場面
- テスト基盤の設定(Vitest、Jest、Playwright)
- 関数、フック、ユーティリティの単体テストの作成
- React Testing Library を使ったコンポーネントテストの作成
- Playwright を使った E2E テストの実装
- MSW を使った API モックの追加
- テストカバレッジの向上
- CI テストパイプラインの設定
- 不安定なテストのデバッグ
- テストフィクスチャとファクトリーの作成
テストの構成
ファイルの命名規則
一貫した命名パターンに従います。
// Co-located pattern (recommended)
src/
components/
Button.tsx
Button.test.tsx // Component tests
utils/
formatDate.ts
formatDate.test.ts // Unit tests
api/
users.ts
users.integration.test.ts // Integration tests
// Centralized pattern (alternative)
__tests__/
components/
Button.test.tsx
utils/
formatDate.test.ts
テストスイートの構成
明確な describe ブロックを使ってテストを整理します。
import { describe, it, expect, beforeEach } from 'vitest';
import { formatDate } from './formatDate';
describe('formatDate', () => {
describe('with valid dates', () => {
it('formats ISO dates to MM/DD/YYYY', () => {
expect(formatDate('2024-01-15')).toBe('01/15/2024');
});
it('handles Date objects', () => {
const date = new Date('2024-01-15');
expect(formatDate(date)).toBe('01/15/2024');
});
});
describe('with invalid dates', () => {
it('throws for invalid strings', () => {
expect(() => formatDate('invalid')).toThrow('Invalid date');
});
it('throws for null', () => {
expect(() => formatDate(null)).toThrow('Invalid date');
});
});
});
検出:テストファイルの検索
高精度ツールを使って、既存のテストパターンを検出します。
# Find all test files and analyze patterns
discover:
queries:
- id: test-files
type: glob
patterns:
- "**/*.test.{ts,tsx,js,jsx}"
- "**/*.spec.{ts,tsx,js,jsx}"
- "e2e/**/*.spec.ts"
exclude:
- "**/node_modules/**"
- "**/dist/**"
- id: test-config
type: glob
patterns:
- "vitest.config.ts"
- "jest.config.js"
- "playwright.config.ts"
- id: skipped-tests
type: grep
pattern: "\\.skip|it\\.only|describe\\.only"
glob: "**/*.test.{ts,tsx}"
output_mode: files_only
単体テスト
Vitest のセットアップ(推奨)
// vitest.config.ts
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
import path from 'path';
export default defineConfig({
plugins: [react()],
test: {
globals: true,
environment: 'jsdom',
setupFiles: ['./src/test/setup.ts'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: [
'node_modules/',
'src/test/',
'**/*.d.ts',
'**/*.config.*',
'**/dist/**',
],
all: true,
lines: 80,
functions: 80,
branches: 80,
statements: 80,
},
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
});
純粋関数のテスト
import { describe, it, expect } from 'vitest';
import { calculateTotal, discountPrice } from './pricing';
describe('pricing utilities', () => {
describe('calculateTotal', () => {
it('sums item prices', () => {
const items = [
{ price: 10.00, quantity: 2 },
{ price: 5.50, quantity: 1 },
];
expect(calculateTotal(items)).toBe(25.50);
});
it('returns 0 for empty array', () => {
expect(calculateTotal([])).toBe(0);
});
it('handles quantity multipliers', () => {
const items = [{ price: 10, quantity: 3 }];
expect(calculateTotal(items)).toBe(30);
});
});
describe('discountPrice', () => {
it('applies percentage discount', () => {
expect(discountPrice(100, 0.2)).toBe(80);
});
it('rounds to 2 decimal places', () => {
expect(discountPrice(10.99, 0.15)).toBe(9.34);
});
it('throws for invalid discounts', () => {
expect(() => discountPrice(100, -0.1)).toThrow('Invalid discount');
expect(() => discountPrice(100, 1.5)).toThrow('Invalid discount');
});
});
});
React フックのテスト
import { renderHook, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { useAuth } from './useAuth';
// Mock the auth context
vi.mock('@/contexts/AuthContext', () => ({
useAuthContext: vi.fn(),
}));
describe('useAuth', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('returns user when authenticated', () => {
vi.mocked(useAuthContext).mockReturnValue({
user: { id: '1', email: '[email protected]' },
isLoading: false,
});
const { result } = renderHook(() => useAuth());
expect(result.current.user).toEqual({
id: '1',
email: '[email protected]',
});
expect(result.current.isAuthenticated).toBe(true);
});
it('handles loading state', () => {
vi.mocked(useAuthContext).mockReturnValue({
user: null,
isLoading: true,
});
const { result } = renderHook(() => useAuth());
expect(result.current.isLoading).toBe(true);
expect(result.current.isAuthenticated).toBe(false);
});
it('refetches user on login', async () => {
const refetch = vi.fn().mockResolvedValue({ id: '1' });
vi.mocked(useAuthContext).mockReturnValue({
user: null,
refetch,
});
const { result } = renderHook(() => useAuth());
await result.current.login('[email protected]', 'password');
expect(refetch).toHaveBeenCalledOnce();
});
});
コンポーネントテスト
React Testing Library のパターン
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect, vi } from 'vitest';
import { Button } from './Button';
import { SearchInput } from './SearchInput';
describe('Button', () => {
it('renders with text', () => {
render(<Button>Click me</Button>);
expect(screen.getByRole('button', { name: /click me/i })).toBeInTheDocument();
});
it('calls onClick when clicked', async () => {
const handleClick = vi.fn();
const user = userEvent.setup();
render(<Button onClick={handleClick}>Click</Button>);
await user.click(screen.getByRole('button'));
expect(handleClick).toHaveBeenCalledOnce();
});
it('disables button when loading', () => {
render(<Button isLoading>Submit</Button>);
expect(screen.getByRole('button')).toBeDisabled();
});
it('shows spinner when loading', () => {
render(<Button isLoading>Submit</Button>);
expect(screen.getByRole('button')).toHaveAttribute('aria-busy', 'true');
});
it('applies variant styles', () => {
const { container } = render(<Button variant="primary">Primary</Button>);
expect(container.firstChild).toHaveClass('bg-blue-600');
});
});
describe('SearchInput', () => {
it('debounces search input', async () => {
const onSearch = vi.fn();
const user = userEvent.setup();
render(<SearchInput onSearch={onSearch} debounce={300} />);
const input = screen.getByRole('searchbox');
await user.type(input, 'test query');
// Should not call immediately
expect(onSearch).not.toHaveBeenCalled();
// Should call after debounce
await waitFor(
() => expect(onSearch).toHaveBeenCalledWith('test query'),
{ timeout: 500 }
);
});
it('clears input on clear button click', async () => {
const user = userEvent.setup();
render(<SearchInput />);
const input = screen.getByRole('searchbox') as HTMLInputElement;
await user.type(input, 'test');
expect(input.value).toBe('test');
await user.click(screen.getByRole('button', { name: /clear/i }));
expect(input.value).toBe('');
});
});
非同期コンポーネントのテスト
import { render, screen, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { UserProfile } from './UserProfile';
import * as api from '@/api/users';
vi.mock('@/api/users');
describe('UserProfile', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('shows loading state initially', () => {
vi.mocked(api.getUser).mockReturnValue(new Promise(() => {}));
render(<UserProfile userId="1" />);
expect(screen.getByText(/loading/i)).toBeInTheDocument();
});
it('displays user data when loaded', async () => {
vi.mocked(api.getUser).mockResolvedValue({
id: '1',
name: 'John Doe',
email: '[email protected]',
});
render(<UserProfile userId="1" />);
await waitFor(() => {
expect(screen.getByText('John Doe')).toBeInTheDocument();
});
expect(screen.getByText('[email protected]')).toBeInTheDocument();
});
it('shows error message on fetch failure', async () => {
vi.mocked(api.getUser).mockRejectedValue(new Error('Failed to fetch'));
render(<UserProfile userId="1" />);
await waitFor(() => {
expect(screen.getByText(/failed to load/i)).toBeInTheDocument();
});
});
});
統合テスト
API ルートのテスト
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { createMocks } from 'node-mocks-http';
import { POST } from '@/app/api/posts/route';
import { prisma } from '@/lib/prisma';
import { createTestUser, cleanupDatabase } from '@/test/helpers';
describe('POST /api/posts', () => {
let testUser: { id: string; email: string };
beforeAll(async () => {
testUser = await createTestUser();
});
afterAll(async () => {
await cleanupDatabase();
});
it('creates a new post', async () => {
const { req } = createMocks({
method: 'POST',
body: {
title: 'Test Post',
content: 'Test content',
},
headers: {
authorization: `Bearer ${testUser.token}`,
},
});
const response = await POST(req);
const data = await response.json();
expect(response.status).toBe(201);
expect(data).toMatchObject({
title: 'Test Post',
content: 'Test content',
authorId: testUser.id,
});
// Verify in database
const post = await prisma.post.findUnique({
where: { id: data.id },
});
expect(post).toBeTruthy();
});
it('validates required fields', async () => {
const { req } = createMocks({
method: 'POST',
body: { title: '' }, // Missing content
headers: { authorization: `Bearer ${testUser.token}` },
});
const response = await POST(req);
const data = await response.json();
expect(response.status).toBe(400);
expect(data.error).toMatchObject({
fieldErrors: {
title: expect.arrayContaining([expect.any(String)]),
content: expect.arrayContaining([expect.any(String)]),
},
});
});
it('requires authentication', async () => {
const { req } = createMocks({
method: 'POST',
body: { title: 'Test', content: 'Test' },
// No authorization header
});
const response = await POST(req);
expect(response.status).toBe(401);
});
});
フィクスチャを使ったデータベーステスト
// test/helpers.ts
import { PrismaClient } from '@prisma/client';
import { hash } from 'bcrypt';
export const prisma = new PrismaClient();
export async function createTestUser(overrides = {}) {
const hashedPassword = await hash('password123', 10);
return prisma.user.create({
data: {
email: `test-${Date.now()}@example.com`,
name: 'Test User',
password: hashedPassword,
...overrides,
},
});
}
export async function createTestPost(userId: string, overrides = {}) {
return prisma.post.create({
data: {
title: 'Test Post',
content: 'Test content',
authorId: userId,
published: false,
...overrides,
},
});
}
export async function cleanupDatabase() {
// Delete in correct order to respect foreign keys
await prisma.comment.deleteMany();
await prisma.post.deleteMany();
await prisma.user.deleteMany();
}
E2E テスト
Playwright のセットアップ
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: 'html',
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
},
],
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
});
ページオブジェクトパターン
// e2e/pages/LoginPage.ts
import { Page, Locator } from '@playwright/test';
export class LoginPage {
readonly page: Page;
readonly emailInput: Locator;
readonly passwordInput: Locator;
readonly submitButton: Locator;
readonly errorMessage: Locator;
constructor(page: Page) {
this.page = page;
this.emailInput = page.getByLabel('Email');
this.passwordInput = page.getByLabel('Password');
this.submitButton = page.getByRole('button', { name: 'Sign in' });
this.errorMessage = page.getByRole('alert');
}
async goto() {
await this.page.goto('/login');
}
async login(email: string, password: string) {
await this.emailInput.fill(email);
await this.passwordInput.fill(password);
await this.submitButton.click();
}
async expectError(message: string) {
// Note: expect is imported from @playwright/test
await expect(this.errorMessage).toContainText(message);
}
}
// e2e/auth.spec.ts
import { test, expect } from '@playwright/test';
import { LoginPage } from './pages/LoginPage';
test.describe('Authentication', () => {
test('successful login redirects to dashboard', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login('[email protected]', 'password123');
await expect(page).toHaveURL('/dashboard');
await expect(page.getByText('Welcome back')).toBeVisible();
});
test('invalid credentials show error', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login('[email protected]', 'wrong');
await loginPage.expectError('Invalid email or password');
});
test('preserves redirect after login', async ({ page }) => {
await page.goto('/dashboard');
await expect(page).toHaveURL(/\/login\?redirect=%2Fdashboard/);
const loginPage = new LoginPage(page);
await loginPage.login('[email protected]', 'password123');
await expect(page).toHaveURL('/dashboard');
});
});
モックのパターン
API モックに MSW を使用する
// src/test/mocks/handlers.ts
import { http, HttpResponse } from 'msw';
export const handlers = [
http.get('/api/user', () => {
return HttpResponse.json({
id: '1',
email: '[email protected]',
name: 'Test User',
});
}),
http.post('/api/posts', async ({ request }) => {
const body = await request.json();
return HttpResponse.json(
{
id: '1',
...body,
createdAt: new Date().toISOString(),
},
{ status: 201 }
);
}),
http.get('/api/posts/:id', ({ params }) => {
const { id } = params;
if (id === '404') {
return new HttpResponse(null, { status: 404 });
}
return HttpResponse.json({
id,
title: 'Test Post',
content: 'Test content',
});
}),
];
// src/test/setup.ts
import { beforeAll, afterEach, afterAll } from 'vitest';
import { setupServer } from 'msw/node';
import { handlers } from './mocks/handlers';
const server = setupServer(...handlers);
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
モジュールのモック
import { vi } from 'vitest';
import type * as PrismaModule from '@/lib/prisma';
// Mock entire module
vi.mock('@/lib/prisma', () => ({
prisma: {
user: {
findUnique: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
},
post: {
findMany: vi.fn(),
create: vi.fn(),
},
},
}));
// Mock with implementation
vi.mock('@/lib/auth', () => ({
hashPassword: vi.fn((password: string) => `hashed_${password}`),
verifyPassword: vi.fn((password: string, hash: string) => {
return hash === `hashed_${password}`;
}),
}));
// Partial mock
vi.mock('@/lib/email', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/email')>();
return {
...actual,
sendEmail: vi.fn(), // Mock only sendEmail
};
});
カバレッジ戦略
目標しきい値
新しいコードでは、高いカバレッジを目指します。
- 新しいコードは 100%(CI で強制)
- 既存のコードは 80% 以上(段階的に改善)
- 重要なパスは 100%(認証、決済、データ整合性)
カバレッジの設定
// vitest.config.ts
export default defineConfig({
test: {
coverage: {
provider: 'v8',
reporter: ['text', 'json-summary', 'html'],
exclude: [
'node_modules/',
'src/test/',
'**/*.d.ts',
'**/*.config.*',
'**/dist/**',
'**/*.stories.tsx', // Storybook
'**/types/**',
],
all: true,
lines: 80,
functions: 80,
branches: 80,
statements: 80,
// Fail CI if coverage drops
thresholds: {
lines: 80,
functions: 80,
branches: 80,
statements: 80,
},
},
},
});
カバレッジの不足箇所を特定する
高精度ツールを使って、テストされていないコードを見つけます。
# Find files missing tests
precision_exec:
commands:
- cmd: "npm run test:coverage -- --reporter=json-summary"
expect:
exit_code: 0
# Parse coverage report
precision_read:
files:
- path: "coverage/coverage-summary.json"
extract: content
verbosity: minimal
# Find source files without corresponding tests
discover:
queries:
- id: source-files
type: glob
patterns: ["src/**/*.{ts,tsx}"]
exclude: ["**/*.test.*", "**/*.spec.*"]
- id: test-files
type: glob
patterns: ["**/*.test.{ts,tsx}"]
output_mode: files_only
CI との統合
GitHub Actions の例
# .github/workflows/test.yml
name: Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- name: Run unit tests
run: npm run test:coverage
- name: Upload coverage
uses: codecov/codecov-action@v4
with:
files: ./coverage/coverage-final.json
- name: Run E2E tests
run: npx playwright test
- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: playwright-report/
retention-days: 30
テストの並列実行
// vitest.config.ts - parallel by default
export default defineConfig({
test: {
pool: 'threads',
poolOptions: {
threads: {
singleThread: false,
maxThreads: 8,
},
},
},
});
// playwright.config.ts
export default defineConfig({
workers: process.env.CI ? 1 : 4, // Limit parallelism in CI
fullyParallel: true,
});
高精度ツールとの統合
期待値を指定してテストを実行する
# Run tests and validate output
precision_exec:
commands:
- cmd: "npm run test -- --run"
expect:
exit_code: 0
- cmd: "npm run typecheck"
expect:
exit_code: 0
- cmd: "npm run test:coverage -- --run"
expect:
exit_code: 0
stdout_contains: "All files"
verbosity: minimal
テストの一括検証
batch:
id: validate-tests
operations:
query:
- id: find-skipped
type: grep
pattern: "\\.skip|it\\.only|describe\\.only"
glob: "**/*.test.ts"
output:
format: count_only
exec:
- id: run-tests
type: command
commands:
- cmd: "npm run test -- --run"
expect: { exit_code: 0 }
- id: check-coverage
type: command
commands:
- cmd: "npm run test:coverage -- --run"
expect:
exit_code: 0
stdout_contains: "All files"
config:
execution:
mode: sequential
不安定なテストのデバッグ
よくある原因
- 競合状態:非同期処理には
waitForを使用します - 時間依存のテスト:
vi.useFakeTimers()でタイマーをモックします - テストの分離:テスト間で状態が共有されないようにします
- ネットワークリクエスト:実際の API に依存せず、MSW でモックします
- ランダムデータ:決定的なテストデータを使用するか、ランダム生成器にシード値を設定します
不安定なテストのパターン
import { vi, beforeEach, afterEach } from 'vitest';
// Mock timers for time-dependent code
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.restoreAllMocks();
});
it('debounces function calls', async () => {
const callback = vi.fn();
const debounced = debounce(callback, 1000);
debounced('test');
expect(callback).not.toHaveBeenCalled();
vi.advanceTimersByTime(1000);
expect(callback).toHaveBeenCalledWith('test');
});
// Ensure test isolation
beforeEach(async () => {
await cleanupDatabase();
vi.clearAllMocks();
});
// Use deterministic data
it('sorts users by creation date', () => {
const users = [
{ id: '1', createdAt: new Date('2024-01-01') },
{ id: '2', createdAt: new Date('2024-01-02') },
];
const sorted = sortByDate(users);
expect(sorted[0].id).toBe('2');
});
ベストプラクティス
- 新機能では、まずテストを書く(TDD)
- 実装ではなく振る舞いをテストする — 方法ではなく、何をするかに焦点を当てます
- 明確さを保つため、可能であれば テストごとにアサーションを 1 つにする
- わかりやすいテスト名を付ける — 「Y のときに X する」
- あらゆるものをモックしない — 統合テストでは実際の連携先が必要です
- テストを高速に保つ — 単体テストは 100ms 未満、統合テストは 1 秒未満
- エッジケースをテストする — null、undefined、空の配列、境界値
- フレームワークのコードをテストしない — React、Vue などではなく、自分のロジックをテストします
- テストフィクスチャを維持する — テストデータを現実的で最新の状態に保ちます
- テストカバレッジを確認する — カバレッジ 100% でもバグがないとは限りません
よくあるアンチパターン
[X] 実装の詳細をテストする
// BAD - tests internal state
it('sets loading to true', () => {
const { result } = renderHook(() => useUsers());
expect(result.current.loading).toBe(true);
});
// GOOD - tests user-visible behavior
it('shows loading spinner while fetching', () => {
render(<UserList />);
expect(screen.getByRole('status')).toBeInTheDocument();
});
[X] 過剰なモック
// BAD - mocks everything, tests nothing
vi.mock('./api');
vi.mock('./utils');
vi.mock('./hooks');
// GOOD - mock only external dependencies
vi.mock('axios'); // Mock HTTP client
// Let your code run for real
[X] 壊れやすいセレクター
// BAD - breaks when styling changes
const button = container.querySelector('.btn-primary');
// GOOD - uses accessible queries
const button = screen.getByRole('button', { name: /submit/i });
まとめ
効果的なテストには、次の要素が必要です。
- 整理されたテストファイル:ソースコードと同じ場所に配置
- 包括的な単体テスト:ビジネスロジックとユーティリティを対象
- ユーザー中心のコンポーネントテスト:React Testing Library を使用
- 現実的な統合テスト:API ルートとデータベース操作を対象
- エンドツーエンドテスト:重要なユーザーフローを対象
- API モック:決定的なテストのために MSW を使用
- 高いカバレッジ目標:新しいコードは 100%、全体で 80% 以上
- CI との統合:並列実行とカバレッジレポートに対応
- 不安定なテストの防止:適切なモックと分離によって実現
高精度ツールを使ってテストの不足箇所を見つけ、効率的にテストを実行し、実装の品質を検証してください。
コメント
Markdown 対応