TDD手法でReactコンポーネントを開発する実践ガイド

公開日:
目次

テスト駆動開発(TDD)は、高品質なコードを書くための効果的な手法です。この記事では、Reactコンポーネントの開発にTDDを適用する実践的な方法を、実際の検索コンポーネント開発を例に詳しく解説します。

TDDとは

TDD(Test-Driven Development)は、以下の3つのステップを繰り返す開発手法です:

  1. Red: 失敗するテストを書く
  2. Green: テストが通る最小限のコードを書く
  3. 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のメリット

  1. 設計の改善: テストを先に書くことで、APIの設計が自然と改善される
  2. バグの早期発見: 開発中にバグを発見しやすい
  3. リファクタリングの安全性: テストがあることで安心してコードを改善できる
  4. ドキュメント効果: テストコードが仕様書の役割を果たす

まとめ

TDDを使ったReactコンポーネント開発では、以下の流れを意識することが重要です:

  1. E2Eテストでユーザー体験を定義
  2. 単体テストでコンポーネントの振る舞いを定義
  3. 最小限の実装でテストを通す
  4. 品質向上のためのリファクタリング

この手法により、保守性が高く、バグの少ないReactアプリケーションを効率的に開発できるでしょう。