testing-qa について
testing-qaは、Next.jsアプリ向けのテストスキルです。テスト作成、失敗のデバッグ、テスト基盤の整備に使え、PlaywrightのE2Eテスト、Jestのユニットテスト、React Testing Libraryによるコンポーネントテストを扱います。
主な機能
- 01PlaywrightによるE2Eテスト
- 02Jestによるユニットテスト
- 03React Testing Libraryのコンポーネントテスト
- 04依存関係と外部APIのモック
- 05テストのデバッグとアクセシビリティ確認
活用シーン
- 01Next.jsのユーザーフローのE2Eテスト作成
- 02コンポーネントの操作と非同期状態の確認
- 03APIルートとユーティリティ関数のテスト
- 04テスト失敗のデバッグと依存関係のモック
インストール
コマンドをコピーしてターミナルで実行します。
$
npx skills add https:/ / github.com/ jmsktm/ claude-settings --skill testing-qaインストール後、Claude Code で/testing-qaを入力して読み込まれたか確認します。
詳細
テストと QA スキル
概要
このスキルでは、E2E テストに Playwright、単体テストに Jest、コンポーネントテストに React Testing Library を使用して、Next.js アプリケーションの包括的なテストを作成できます。
テストの考え方
テストピラミッド
- E2E テスト(10%):重要なユーザージャーニー
- 統合テスト(30%):コンポーネント間のやり取り
- 単体テスト(60%):個々の関数とユーティリティ
テスト対象
- 実施する:実装ではなく動作をテストする
- 実施する:ユーザーの操作とその結果をテストする
- 実施する:エラー状態とエッジケースをテストする
- 実施する:アクセシビリティをテストする
- 実施しない:内部実装の詳細をテストする
- 実施しない:サードパーティライブラリをテストする
- 実施しない:単純なプレゼンテーションコンポーネントを過剰にテストする
Playwright E2E テスト
セットアップ
// 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: 'Mobile Chrome',
use: { ...devices['Pixel 5'] },
},
],
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
})
基本的な E2E テスト
// e2e/auth.spec.ts
import { test, expect } from '@playwright/test'
test.describe('Authentication', () => {
test('should sign up new user', async ({ page }) => {
await page.goto('/signup')
// Fill form
await page.fill('input[name="email"]', '[email protected]')
await page.fill('input[name="password"]', 'password123')
await page.fill('input[name="confirmPassword"]', 'password123')
// Submit
await page.click('button[type="submit"]')
// Verify redirect to dashboard
await expect(page).toHaveURL('/dashboard')
// Verify welcome message
await expect(page.getByText('Welcome')).toBeVisible()
})
test('should show error for invalid credentials', async ({ page }) => {
await page.goto('/login')
await page.fill('input[name="email"]', '[email protected]')
await page.fill('input[name="password"]', 'wrongpassword')
await page.click('button[type="submit"]')
// Verify error message
await expect(page.getByText('Invalid credentials')).toBeVisible()
})
})
Playwright の高度なパターン
// Page Object Model
// e2e/pages/login.page.ts
export class LoginPage {
constructor(private page: Page) {}
async goto() {
await this.page.goto('/login')
}
async login(email: string, password: string) {
await this.page.fill('input[name="email"]', email)
await this.page.fill('input[name="password"]', password)
await this.page.click('button[type="submit"]')
}
async getErrorMessage() {
return await this.page.locator('[role="alert"]').textContent()
}
}
// Usage
test('login with page object', async ({ page }) => {
const loginPage = new LoginPage(page)
await loginPage.goto()
await loginPage.login('[email protected]', 'password')
await expect(page).toHaveURL('/dashboard')
})
// Fixtures for authenticated state
// e2e/fixtures.ts
export const test = base.extend<{ authenticatedPage: Page }>({
authenticatedPage: async ({ page }, use) => {
// Login
await page.goto('/login')
await page.fill('input[name="email"]', '[email protected]')
await page.fill('input[name="password"]', 'password')
await page.click('button[type="submit"]')
await page.waitForURL('/dashboard')
await use(page)
},
})
// Usage
test('dashboard test', async ({ authenticatedPage }) => {
await authenticatedPage.goto('/dashboard')
// Test authenticated functionality
})
Playwright の一般的なパターン
// Wait for network
await page.waitForResponse(resp => resp.url().includes('/api/items'))
// Test file upload
await page.setInputFiles('input[type="file"]', 'path/to/file.jpg')
// Test download
const downloadPromise = page.waitForEvent('download')
await page.click('button:has-text("Download")')
const download = await downloadPromise
await download.saveAs('/path/to/save')
// Mock API responses
await page.route('**/api/items', route => {
route.fulfill({
status: 200,
body: JSON.stringify({ items: [] }),
})
})
// Screenshot for debugging
await page.screenshot({ path: 'debug.png', fullPage: true })
// Test responsive design
await page.setViewportSize({ width: 375, height: 667 }) // iPhone size
// Test accessibility
const accessibilityScanResults = await new AxeBuilder({ page }).analyze()
expect(accessibilityScanResults.violations).toEqual([])
コンポーネントテスト
React Testing Library のセットアップ
// jest.config.js
module.exports = {
preset: 'ts-jest',
testEnvironment: 'jsdom',
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',
},
}
// jest.setup.js
import '@testing-library/jest-dom'
基本的なコンポーネントテスト
// components/button.test.tsx
import { render, screen, fireEvent } from '@testing-library/react'
import { Button } from './button'
describe('Button', () => {
it('renders with text', () => {
render(<Button>Click me</Button>)
expect(screen.getByText('Click me')).toBeInTheDocument()
})
it('calls onClick when clicked', () => {
const handleClick = jest.fn()
render(<Button onClick={handleClick}>Click me</Button>)
fireEvent.click(screen.getByText('Click me'))
expect(handleClick).toHaveBeenCalledTimes(1)
})
it('is disabled when disabled prop is true', () => {
render(<Button disabled>Click me</Button>)
expect(screen.getByText('Click me')).toBeDisabled()
})
it('applies variant styles', () => {
render(<Button variant="destructive">Delete</Button>)
const button = screen.getByText('Delete')
expect(button).toHaveClass('bg-red-600')
})
})
非同期コンポーネントのテスト
// components/user-profile.test.tsx
import { render, screen, waitFor } from '@testing-library/react'
import { UserProfile } from './user-profile'
// Mock fetch
global.fetch = jest.fn()
describe('UserProfile', () => {
beforeEach(() => {
jest.clearAllMocks()
})
it('displays loading state initially', () => {
(fetch as jest.Mock).mockImplementation(() =>
new Promise(() => {}) // Never resolves
)
render(<UserProfile userId="123" />)
expect(screen.getByText('Loading...')).toBeInTheDocument()
})
it('displays user data when loaded', async () => {
(fetch as jest.Mock).mockResolvedValueOnce({
ok: true,
json: async () => ({ name: 'John Doe', email: '[email protected]' }),
})
render(<UserProfile userId="123" />)
await waitFor(() => {
expect(screen.getByText('John Doe')).toBeInTheDocument()
expect(screen.getByText('[email protected]')).toBeInTheDocument()
})
})
it('displays error message when fetch fails', async () => {
(fetch as jest.Mock).mockRejectedValueOnce(new Error('Failed to fetch'))
render(<UserProfile userId="123" />)
await waitFor(() => {
expect(screen.getByText('Error loading user')).toBeInTheDocument()
})
})
})
フォームのテスト
// components/contact-form.test.tsx
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { ContactForm } from './contact-form'
describe('ContactForm', () => {
it('validates required fields', async () => {
render(<ContactForm />)
const submitButton = screen.getByText('Submit')
fireEvent.click(submitButton)
await waitFor(() => {
expect(screen.getByText('Email is required')).toBeInTheDocument()
expect(screen.getByText('Message is required')).toBeInTheDocument()
})
})
it('submits form with valid data', async () => {
const user = userEvent.setup()
const onSubmit = jest.fn()
render(<ContactForm onSubmit={onSubmit} />)
await user.type(screen.getByLabelText('Email'), '[email protected]')
await user.type(screen.getByLabelText('Message'), 'Test message')
await user.click(screen.getByText('Submit'))
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith({
email: '[email protected]',
message: 'Test message',
})
})
})
it('disables submit button while submitting', async () => {
const user = userEvent.setup()
render(<ContactForm />)
await user.type(screen.getByLabelText('Email'), '[email protected]')
await user.type(screen.getByLabelText('Message'), 'Test message')
const submitButton = screen.getByText('Submit')
await user.click(submitButton)
expect(submitButton).toBeDisabled()
})
})
フックのテスト
// hooks/use-counter.test.ts
import { renderHook, act } from '@testing-library/react'
import { useCounter } from './use-counter'
describe('useCounter', () => {
it('initializes with default value', () => {
const { result } = renderHook(() => useCounter())
expect(result.current.count).toBe(0)
})
it('initializes with custom value', () => {
const { result } = renderHook(() => useCounter(10))
expect(result.current.count).toBe(10)
})
it('increments count', () => {
const { result } = renderHook(() => useCounter())
act(() => {
result.current.increment()
})
expect(result.current.count).toBe(1)
})
it('decrements count', () => {
const { result } = renderHook(() => useCounter(5))
act(() => {
result.current.decrement()
})
expect(result.current.count).toBe(4)
})
it('resets count', () => {
const { result } = renderHook(() => useCounter(10))
act(() => {
result.current.reset()
})
expect(result.current.count).toBe(10)
})
})
単体テスト
ユーティリティのテスト
// lib/utils.test.ts
import { formatDate, slugify, truncate } from './utils'
describe('formatDate', () => {
it('formats date correctly', () => {
const date = new Date('2024-01-15')
expect(formatDate(date)).toBe('January 15, 2024')
})
it('handles invalid date', () => {
expect(formatDate(new Date('invalid'))).toBe('Invalid Date')
})
})
describe('slugify', () => {
it('converts string to slug', () => {
expect(slugify('Hello World')).toBe('hello-world')
expect(slugify('Next.js App!')).toBe('next-js-app')
})
it('removes special characters', () => {
expect(slugify('Test@#$%')).toBe('test')
})
})
describe('truncate', () => {
it('truncates long strings', () => {
const text = 'This is a very long text'
expect(truncate(text, 10)).toBe('This is a...')
})
it('does not truncate short strings', () => {
const text = 'Short'
expect(truncate(text, 10)).toBe('Short')
})
})
API ルートのテスト
// app/api/items/route.test.ts
import { GET, POST } from './route'
import { NextRequest } from 'next/server'
describe('/api/items', () => {
describe('GET', () => {
it('returns items', async () => {
const request = new NextRequest('http://localhost:3000/api/items')
const response = await GET(request)
const data = await response.json()
expect(response.status).toBe(200)
expect(data.items).toBeDefined()
expect(Array.isArray(data.items)).toBe(true)
})
})
describe('POST', () => {
it('creates new item', async () => {
const request = new NextRequest('http://localhost:3000/api/items', {
method: 'POST',
body: JSON.stringify({ title: 'Test Item' }),
})
const response = await POST(request)
const data = await response.json()
expect(response.status).toBe(201)
expect(data.item).toBeDefined()
expect(data.item.title).toBe('Test Item')
})
it('validates required fields', async () => {
const request = new NextRequest('http://localhost:3000/api/items', {
method: 'POST',
body: JSON.stringify({}),
})
const response = await POST(request)
expect(response.status).toBe(400)
})
})
})
モック
Supabase のモック
// __mocks__/supabase.ts
export const createClient = jest.fn(() => ({
from: jest.fn(() => ({
select: jest.fn().mockReturnThis(),
insert: jest.fn().mockReturnThis(),
update: jest.fn().mockReturnThis(),
delete: jest.fn().mockReturnThis(),
eq: jest.fn().mockReturnThis(),
single: jest.fn().mockResolvedValue({
data: { id: '1', title: 'Test' },
error: null,
}),
})),
auth: {
getUser: jest.fn().mockResolvedValue({
data: { user: { id: '123', email: '[email protected]' } },
error: null,
}),
},
}))
Next.js ルーターのモック
// Mock useRouter
jest.mock('next/navigation', () => ({
useRouter: () => ({
push: jest.fn(),
replace: jest.fn(),
back: jest.fn(),
forward: jest.fn(),
refresh: jest.fn(),
prefetch: jest.fn(),
}),
usePathname: () => '/test-path',
useSearchParams: () => new URLSearchParams(),
}))
外部 API のモック
// Use MSW (Mock Service Worker)
import { rest } from 'msw'
import { setupServer } from 'msw/node'
const server = setupServer(
rest.get('/api/items', (req, res, ctx) => {
return res(
ctx.json({
items: [
{ id: '1', title: 'Item 1' },
{ id: '2', title: 'Item 2' },
],
})
)
})
)
beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())
テストチェックリスト
E2E テスト
- 重要なユーザージャーニー(サインアップ、ログイン、チェックアウト)をテストする
- 複数のブラウザーでテストする
- モバイルのビューポートでテストする
- エラー状態をテストする
- 低速ネットワークでテストする
- JavaScript を無効にしてテストする(該当する場合)
- アクセシビリティをテストする
コンポーネントテスト
- レンダリングをテストする
- ユーザー操作をテストする
- props をテストする
- 条件付きレンダリングをテストする
- エラー状態をテストする
- 読み込み状態をテストする
- アクセシビリティをテストする
単体テスト
- 純粋関数をテストする
- エッジケースをテストする
- エラー処理をテストする
- さまざまな入力でテストする
- 境界条件をテストする
一般的なテストパターン
安定した要素選択のためのテスト ID
// Component
<button data-testid="submit-button">Submit</button>
// Test
const button = screen.getByTestId('submit-button')
アクセシブルなクエリ(推奨)
// By role (best)
screen.getByRole('button', { name: /submit/i })
// By label
screen.getByLabelText('Email')
// By text
screen.getByText('Welcome')
// By placeholder
screen.getByPlaceholderText('Enter email')
読み込み状態のテスト
it('shows loading then content', async () => {
render(<Component />)
// Loading state
expect(screen.getByText('Loading...')).toBeInTheDocument()
// Wait for content
await waitFor(() => {
expect(screen.getByText('Content')).toBeInTheDocument()
expect(screen.queryByText('Loading...')).not.toBeInTheDocument()
})
})
エラーバウンダリーのテスト
it('renders error boundary on error', () => {
const spy = jest.spyOn(console, 'error').mockImplementation()
render(
<ErrorBoundary>
<ComponentThatThrows />
</ErrorBoundary>
)
expect(screen.getByText('Something went wrong')).toBeInTheDocument()
spy.mockRestore()
})
テストのデバッグ
デバッグ出力
import { screen, render } from '@testing-library/react'
// Print component tree
render(<Component />)
screen.debug()
// Print specific element
screen.debug(screen.getByRole('button'))
Playwright のデバッグ
# Run in debug mode with browser
npx playwright test --debug
# Run specific test
npx playwright test auth.spec.ts --debug
# Run with headed browser
npx playwright test --headed
よくある問題
要素が見つからない:
- 要素が存在するか確認します:
screen.getByTextとscreen.queryByTextを使い分けます - 非同期要素には
findByを使用します:screen.findByText - アクセシビリティツリーを確認します:
screen.debug()
タイミングの問題:
- 非同期の更新には
waitForを使用します findByクエリ(組み込みの待機機能)を使用します- 必要に応じてタイムアウトを延長します
状態の更新が反映されない:
- 状態を手動で更新する場合は、
act()でラップします - より実際に近いイベントにするため、
fireEventの代わりにuserEventを使用します
パフォーマンステスト
// Lighthouse CI
// lighthouserc.js
module.exports = {
ci: {
collect: {
url: ['http://localhost:3000/'],
numberOfRuns: 3,
},
assert: {
assertions: {
'categories:performance': ['error', { minScore: 0.9 }],
'categories:accessibility': ['error', { minScore: 0.9 }],
'categories:best-practices': ['error', { minScore: 0.9 }],
'categories:seo': ['error', { minScore: 0.9 }],
},
},
},
}
このスキルを使う場面
次の場合にこのスキルを呼び出します:
- 新しいテストを作成するとき
- テストの失敗をデバッグするとき
- テスト基盤をセットアップするとき
- 特定のシナリオ(フォーム、非同期処理、認証)をテストするとき
- E2E テストを実装するとき
- アクセシビリティをテストするとき
- 依存関係をモックするとき
- テストカバレッジを向上させるとき
コメント
Markdown 対応