目次
テスト駆動開発(TDD)は、高品質なコードを書くための効果的な手法です。この記事では、Reactコンポーネントの開発にTDDを適用する実践的な方法を、実際の検索コンポーネント開発を例に詳しく解説します。
TDDとは
TDD(Test-Driven Development)は、以下の3つのステップを繰り返す開発手法です:
- Red: 失敗するテストを書く
- Green: テストが通る最小限のコードを書く
- Refactor: コードを改善する
この手法により、テスト可能で保守性の高いコードが自然と書けるようになります。
開発環境のセットアップ
必要なツールのインストール
# Jest(単体テスト)
npm install --save-dev jest @testing-library/react @testing-library/jest-dom
# Playwright(E2Eテスト)
npm install --save-dev @playwright/test
npx playwright install
設定ファイルの準備
jest.config.js:
const nextJest = require('next/jest')
const createJestConfig = nextJest({
dir: './',
})
const customJestConfig = {
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
testEnvironment: 'jest-environment-jsdom',
}
module.exports = createJestConfig(customJestConfig)
jest.setup.js:
import '@testing-library/jest-dom'
playwright.config.ts:
import { defineConfig, devices } from '@playwright/test'
export default defineConfig({
testDir: './e2e',
use: {
baseURL: 'http://localhost:3000',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
})
TDDサイクルの実践
Step 1: Red - 失敗するE2Eテストを書く
まずはユーザーの視点からE2Eテストを書きます:
// e2e/search.spec.ts
import { test, expect } from '@playwright/test'
test('検索機能のテスト', async ({ page }) => {
await page.goto('/')
// 検索バーが表示されていることを確認
const searchInput = page.getByTestId('search-input')
await expect(searchInput).toBeVisible()
// 検索クエリを入力
await searchInput.fill('テスト記事')
// 検索結果が表示されることを確認
const searchResults = page.getByTestId('search-results')
await expect(searchResults).toBeVisible()
// 検索結果にリンクが含まれていることを確認
const resultLinks = page.getByTestId('search-result-link')
await expect(resultLinks.first()).toBeVisible()
})
Step 2: Red - 失敗する単体テストを書く
次に、コンポーネントレベルでの単体テストを書きます:
// __tests__/components/search.test.tsx
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { SearchComponent } from '../../components/search'
// モックの設定
const mockPagefind = {
search: jest.fn(),
init: jest.fn(),
}
beforeEach(() => {
Object.defineProperty(window, 'pagefind', {
value: mockPagefind,
configurable: true,
})
})
describe('SearchComponent', () => {
test('検索入力フィールドが表示される', () => {
render(<SearchComponent />)
const searchInput = screen.getByTestId('search-input')
expect(searchInput).toBeInTheDocument()
expect(searchInput).toHaveAttribute('placeholder', '記事を検索...')
})
test('検索クエリの入力で検索が実行される', async () => {
const mockResults = {
results: [
{
data: () => Promise.resolve({
url: '/posts/test-article.html',
title: 'テスト記事',
excerpt: 'これはテスト記事です',
}),
},
],
}
mockPagefind.search.mockResolvedValue(mockResults)
render(<SearchComponent />)
const searchInput = screen.getByTestId('search-input')
fireEvent.change(searchInput, { target: { value: 'テスト' } })
await waitFor(() => {
expect(mockPagefind.search).toHaveBeenCalledWith('テスト')
})
})
test('検索結果が表示される', async () => {
const mockResults = {
results: [
{
data: () => Promise.resolve({
url: '/posts/test-article.html',
title: 'テスト記事',
excerpt: 'これはテスト記事です',
}),
},
],
}
mockPagefind.search.mockResolvedValue(mockResults)
render(<SearchComponent />)
const searchInput = screen.getByTestId('search-input')
fireEvent.change(searchInput, { target: { value: 'テスト' } })
await waitFor(() => {
expect(screen.getByTestId('search-results')).toBeInTheDocument()
expect(screen.getByText('テスト記事')).toBeInTheDocument()
})
})
})
Step 3: Green - テストが通る最小限のコードを書く
テストが通るように、必要最小限の機能を実装します:
// components/search.tsx
import { useState, useEffect } from 'react'
export const SearchComponent = () => {
const [searchQuery, setSearchQuery] = useState('')
const [searchResults, setSearchResults] = useState<any[]>([])
const [isSearchOpen, setIsSearchOpen] = useState(false)
const handleSearch = async (query: string) => {
if (!query.trim()) {
setSearchResults([])
return
}
if (window.pagefind) {
try {
const results = await window.pagefind.search(query)
const processedResults = await Promise.all(
results.results.map(async (result: any) => {
const data = await result.data()
return {
url: data.url.replace('.html', ''),
title: data.title,
excerpt: data.excerpt,
}
})
)
setSearchResults(processedResults)
setIsSearchOpen(true)
} catch (error) {
console.error('Search error:', error)
}
}
}
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const query = e.target.value
setSearchQuery(query)
handleSearch(query)
}
return (
<div>
<input
data-testid="search-input"
type="text"
value={searchQuery}
onChange={handleInputChange}
placeholder="記事を検索..."
/>
{isSearchOpen && searchResults.length > 0 && (
<div data-testid="search-results">
{searchResults.map((result, index) => (
<a
key={index}
data-testid="search-result-link"
href={result.url}
>
<h3>{result.title}</h3>
<p>{result.excerpt}</p>
</a>
))}
</div>
)}
</div>
)
}
Step 4: Refactor - コードを改善する
テストが通ったら、コードの品質を向上させます:
// components/search.tsx(改善版)
import { useState, useEffect, useRef, useCallback } from 'react'
interface SearchResult {
url: string
title: string
excerpt: string
}
export const SearchComponent = () => {
const [searchQuery, setSearchQuery] = useState('')
const [searchResults, setSearchResults] = useState<SearchResult[]>([])
const [isSearchOpen, setIsSearchOpen] = useState(false)
const [isLoading, setIsLoading] = useState(false)
const [isComposing, setIsComposing] = useState(false)
const searchRef = useRef<HTMLDivElement>(null)
const timeoutRef = useRef<NodeJS.Timeout>()
// デバウンス処理を含む検索ハンドラー
const handleSearch = useCallback(async (query: string) => {
if (!query.trim()) {
setSearchResults([])
setIsSearchOpen(false)
return
}
if (!window.pagefind) {
console.warn('Pagefind not initialized')
return
}
setIsLoading(true)
try {
const results = await window.pagefind.search(query)
const processedResults = await Promise.all(
results.results.slice(0, 10).map(async (result: any) => {
const data = await result.data()
return {
url: data.url.endsWith('.html')
? data.url.slice(0, -5)
: data.url,
title: data.meta?.title || data.title || 'Untitled',
excerpt: data.excerpt || '',
}
})
)
setSearchResults(processedResults)
setIsSearchOpen(true)
} catch (error) {
console.error('Search error:', error)
setSearchResults([])
} finally {
setIsLoading(false)
}
}, [])
// 入力ハンドラー(デバウンス付き)
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const query = e.target.value
setSearchQuery(query)
if (!isComposing) {
clearTimeout(timeoutRef.current)
timeoutRef.current = setTimeout(() => {
handleSearch(query)
}, 300)
}
}
// IME対応
const handleCompositionStart = () => setIsComposing(true)
const handleCompositionEnd = (e: React.CompositionEvent<HTMLInputElement>) => {
setIsComposing(false)
handleSearch((e.target as HTMLInputElement).value)
}
return (
<div className="relative" ref={searchRef}>
<input
data-testid="search-input"
type="text"
value={searchQuery}
onChange={handleInputChange}
onCompositionStart={handleCompositionStart}
onCompositionEnd={handleCompositionEnd}
placeholder="記事を検索..."
className="w-full px-4 py-2 border rounded-lg focus:outline-none focus:ring-2"
/>
{isLoading && (
<div data-testid="search-loading">検索中...</div>
)}
{isSearchOpen && searchResults.length > 0 && (
<div data-testid="search-results" className="absolute z-50 w-full mt-2 bg-white border rounded-lg shadow-lg">
{searchResults.map((result, index) => (
<a
key={index}
data-testid="search-result-link"
href={result.url}
className="block p-4 hover:bg-gray-50 border-b last:border-b-0"
onClick={() => setIsSearchOpen(false)}
>
<h3 className="font-semibold text-gray-900">{result.title}</h3>
<div
className="text-sm text-gray-600"
dangerouslySetInnerHTML={{ __html: result.excerpt }}
/>
</a>
))}
</div>
)}
{isSearchOpen && searchQuery.length > 0 && searchResults.length === 0 && !isLoading && (
<div data-testid="search-no-results" className="absolute z-50 w-full mt-2 bg-white border rounded-lg p-4">
検索結果が見つかりませんでした
</div>
)}
</div>
)
}
テスト戦略のベストプラクティス
1. data-testid属性の活用
UIの変更に影響されにくいテストを書くため、data-testid
属性を使用します:
<input data-testid="search-input" />
<div data-testid="search-results">
<a data-testid="search-result-link">
2. モックの適切な使用
外部依存(Pagefind API)はモックを使用してテストします:
const mockPagefind = {
search: jest.fn(),
init: jest.fn(),
}
beforeEach(() => {
window.pagefind = mockPagefind
})
3. 非同期処理のテスト
waitFor
を使用して非同期処理の完了を待ちます:
await waitFor(() => {
expect(screen.getByTestId('search-results')).toBeInTheDocument()
})
4. エラーケースのテスト
正常系だけでなく、エラーケースもテストします:
test('検索APIエラー時の処理', async () => {
mockPagefind.search.mockRejectedValue(new Error('API Error'))
render(<SearchComponent />)
const searchInput = screen.getByTestId('search-input')
fireEvent.change(searchInput, { target: { value: 'test' } })
await waitFor(() => {
expect(screen.queryByTestId('search-results')).not.toBeInTheDocument()
})
})
TDDのメリット
- 設計の改善: テストを先に書くことで、APIの設計が自然と改善される
- バグの早期発見: 開発中にバグを発見しやすい
- リファクタリングの安全性: テストがあることで安心してコードを改善できる
- ドキュメント効果: テストコードが仕様書の役割を果たす
まとめ
TDDを使ったReactコンポーネント開発では、以下の流れを意識することが重要です:
- E2Eテストでユーザー体験を定義
- 単体テストでコンポーネントの振る舞いを定義
- 最小限の実装でテストを通す
- 品質向上のためのリファクタリング
この手法により、保守性が高く、バグの少ないReactアプリケーションを効率的に開発できるでしょう。