Testing Strategist について
Testing Strategist は、ソフトウェア開発者向けのテスト戦略スキルです。テストピラミッドに基づき、ユニット、統合、エンドツーエンドテストの選定・作成・保守を支援します。
主な機能
- 01テストピラミッドに沿った配分
- 02ユニットテストと統合テストの指針
- 03主要なユーザーフローの E2E テスト計画
- 04TDD のレッド・グリーン・リファクタリング
- 05モック、カバレッジ、テスト構成の管理
活用シーン
- 01新規プロジェクトのテスト方針策定
- 02機能に適したテストレベルの選択
- 03複雑なロジックへの TDD 適用
- 04カバレッジ改善と不安定なテストへの対処
インストール
コマンドをコピーしてターミナルで実行します。
$
npx skills add https:/ / github.com/ daffy0208/ ai-dev-standards --skill testing-strategistインストール後、Claude Code で/testing-strategistを入力して読み込まれたか確認します。
同梱ファイルmanifest.yamlREADME.md
詳細
テスト戦略
適切なレベルで適切な対象をテストし、リリースに自信を持てるテストを書きましょう。
基本原則
テストピラミッド: ユニットテスト 70%、インテグレーションテスト 20%、E2E テスト 10%です。
テストには次の特性が必要です。
- 高速 - ミリ秒単位(ユニット)から秒単位(インテグレーション)、分単位(E2E)で実行されます
- 独立 - 一度に1つの対象をテストします
- 再現可能 - 同じ入力なら同じ出力になります
- 自己検証型 - 手動確認なしで自動的に合否を判定します
- タイムリー - コードと同時に(またはTDDで先に)書きます
テストピラミッド
/\
/ \ E2E Tests (10%)
/----\ - Slow, brittle, expensive
/ \ - Test critical user journeys
/--------\ - Example: "User can complete checkout"
/ \
/------------\ Integration Tests (20%)
/ \ - Medium speed, test components together
/----------------\ - Example: "API endpoint returns correct data"
/ \
/--------------------\ Unit Tests (70%)
/______________________\ - Fast, isolated, test functions/components
- Example: "calculateTotal returns sum"
なぜこの比率なのか?
- ユニットテスト: 迅速にフィードバックを得られ、バグを正確に特定でき、保守も容易です
- インテグレーションテスト: コンポーネントが連携して動作することを確認し、インターフェースの問題を検出します
- E2E テスト: 実際のユーザーフローを検証し、UI のバグを検出しますが、実行が遅く、壊れやすいテストです
レベル1:ユニットテスト(70%)
テスト対象
個々の関数、コンポーネント、クラスを分離してテストします。
適した対象:
- ✅ ビジネスロジック関数(計算、検証、変換)
- ✅ ユーティリティ関数(formatDate、parseUrl など)
- ✅ React コンポーネント(レンダリング、props、state)
- ✅ フック(カスタム React フック)
- ✅ 純粋関数(同じ入力なら同じ出力)
対象外:
- ❌ サードパーティライブラリ(正常に動作すると想定します)
- ❌ フレームワークの内部実装(React、Next.js)
- ❌ ロジックのない単純な getter/setter
ユニットテストの例
ビジネスロジックのテスト(Jest + TypeScript)
// src/lib/pricing.ts
export function calculateTotal(items: { price: number; quantity: number }[]) {
return items.reduce((sum, item) => sum + item.price * item.quantity, 0)
}
export function applyDiscount(total: number, discountPercent: number) {
if (discountPercent < 0 || discountPercent > 100) {
throw new Error('Invalid discount percentage')
}
return total * (1 - discountPercent / 100)
}
// src/lib/pricing.test.ts
import { calculateTotal, applyDiscount } from './pricing'
describe('calculateTotal', () => {
it('calculates total for single item', () => {
const items = [{ price: 10, quantity: 2 }]
expect(calculateTotal(items)).toBe(20)
})
it('calculates total for multiple items', () => {
const items = [
{ price: 10, quantity: 2 },
{ price: 5, quantity: 3 }
]
expect(calculateTotal(items)).toBe(35)
})
it('returns 0 for empty array', () => {
expect(calculateTotal([])).toBe(0)
})
})
describe('applyDiscount', () => {
it('applies discount correctly', () => {
expect(applyDiscount(100, 20)).toBe(80)
})
it('throws error for invalid discount', () => {
expect(() => applyDiscount(100, -10)).toThrow('Invalid discount')
expect(() => applyDiscount(100, 150)).toThrow('Invalid discount')
})
})
React コンポーネントのテスト(Jest + React Testing Library)
// src/components/Button.tsx
export function Button({
children,
variant = 'primary',
onClick
}: {
children: React.ReactNode
variant?: 'primary' | 'secondary'
onClick?: () => void
}) {
return (
<button
className={`btn btn-${variant}`}
onClick={onClick}
>
{children}
</button>
)
}
// src/components/Button.test.tsx
import { render, screen, fireEvent } from '@testing-library/react'
import { Button } from './Button'
describe('Button', () => {
it('renders with correct text', () => {
render(<Button>Click me</Button>)
expect(screen.getByText('Click me')).toBeInTheDocument()
})
it('applies primary variant by default', () => {
render(<Button>Click me</Button>)
expect(screen.getByRole('button')).toHaveClass('btn-primary')
})
it('applies secondary variant when specified', () => {
render(<Button variant="secondary">Click me</Button>)
expect(screen.getByRole('button')).toHaveClass('btn-secondary')
})
it('calls onClick when clicked', () => {
const handleClick = jest.fn()
render(<Button onClick={handleClick}>Click me</Button>)
fireEvent.click(screen.getByRole('button'))
expect(handleClick).toHaveBeenCalledTimes(1)
})
})
カスタムフックのテスト
// src/hooks/useCounter.ts
import { useState } from 'react'
export function useCounter(initialValue = 0) {
const [count, setCount] = useState(initialValue)
const increment = () => setCount(c => c + 1)
const decrement = () => setCount(c => c - 1)
const reset = () => setCount(initialValue)
return { count, increment, decrement, reset }
}
// src/hooks/useCounter.test.ts
import { renderHook, act } from '@testing-library/react'
import { useCounter } from './useCounter'
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 to initial value', () => {
const { result } = renderHook(() => useCounter(10))
act(() => {
result.current.increment()
result.current.increment()
result.current.reset()
})
expect(result.current.count).toBe(10)
})
})
ユニットテストのベストプラクティス
✅ 推奨事項:
- 実装ではなく振る舞いをテストします
- 説明的なテスト名を使います(「〜すべき」など)
- AAA パターンに従います:準備(Arrange)、実行(Act)、検証(Assert)
- エッジケースをテストします(空の配列、null、負の数)
- テストをシンプルで読みやすく保ちます
❌ 避けること:
- プライベートメソッドを直接テストする
- モックを多用する(テストが壊れやすくなります)
- フレームワークの内部実装をテストする
- 他のテストに依存するテストを書く
レベル2:インテグレーションテスト(20%)
テスト対象
複数のユニットを連携させてテストします。通常は API ルート、データベース操作、サービス連携が対象です。
適した対象:
- ✅ API エンドポイント(リクエスト → コントローラー → データベース → レスポンス)
- ✅ データベース操作(クエリ、トランザクション)
- ✅ サードパーティ連携(Stripe、SendGrid)
- ✅ 認証フロー
- ✅ ファイルのアップロード/ダウンロード
インテグレーションテストの例
API ルートのテスト(Next.js + Supertest)
// app/api/posts/route.ts
export async function GET() {
const posts = await db.post.findMany({
include: { author: true },
orderBy: { createdAt: 'desc' }
})
return Response.json(posts)
}
export async function POST(request: Request) {
const body = await request.json()
// Validate
const result = PostSchema.safeParse(body)
if (!result.success) {
return Response.json({ errors: result.error.issues }, { status: 400 })
}
// Create post
const post = await db.post.create({
data: {
title: result.data.title,
content: result.data.content,
authorId: request.user.id
}
})
return Response.json(post, { status: 201 })
}
// app/api/posts/route.test.ts
import { testClient } from '@/lib/test-utils'
describe('POST /api/posts', () => {
beforeEach(async () => {
// Clean database before each test
await db.post.deleteMany()
})
it('creates a new post', async () => {
const response = await testClient
.post('/api/posts')
.set('Authorization', `Bearer ${authToken}`)
.send({
title: 'Test Post',
content: 'This is a test post'
})
expect(response.status).toBe(201)
expect(response.body).toMatchObject({
title: 'Test Post',
content: 'This is a test post'
})
// Verify in database
const posts = await db.post.findMany()
expect(posts).toHaveLength(1)
expect(posts[0].title).toBe('Test Post')
})
it('returns 400 for invalid data', async () => {
const response = await testClient
.post('/api/posts')
.set('Authorization', `Bearer ${authToken}`)
.send({
title: '' // Invalid: empty title
})
expect(response.status).toBe(400)
expect(response.body.errors).toBeDefined()
})
it('returns 401 for unauthenticated request', async () => {
const response = await testClient.post('/api/posts').send({
title: 'Test',
content: 'Test'
})
expect(response.status).toBe(401)
})
})
describe('GET /api/posts', () => {
beforeEach(async () => {
await db.post.deleteMany()
// Seed test data
await db.post.createMany({
data: [
{ title: 'Post 1', content: 'Content 1', authorId: user.id },
{ title: 'Post 2', content: 'Content 2', authorId: user.id }
]
})
})
it('returns all posts', async () => {
const response = await testClient.get('/api/posts')
expect(response.status).toBe(200)
expect(response.body).toHaveLength(2)
expect(response.body[0].author).toBeDefined()
})
})
データベース操作のテスト
// src/lib/repositories/userRepository.test.ts
import { db } from '@/lib/db'
import { createUser, findUserByEmail, updateUser } from './userRepository'
describe('userRepository', () => {
beforeEach(async () => {
await db.user.deleteMany()
})
afterAll(async () => {
await db.$disconnect()
})
describe('createUser', () => {
it('creates user with hashed password', async () => {
const user = await createUser({
email: '[email protected]',
password: 'password123'
})
expect(user.email).toBe('[email protected]')
expect(user.password).not.toBe('password123') // Should be hashed
expect(user.password).toMatch(/^\$2[aby]/) // bcrypt hash format
})
it('throws error for duplicate email', async () => {
await createUser({ email: '[email protected]', password: 'pass' })
await expect(createUser({ email: '[email protected]', password: 'pass' })).rejects.toThrow()
})
})
describe('findUserByEmail', () => {
it('finds existing user', async () => {
await createUser({ email: '[email protected]', password: 'pass' })
const user = await findUserByEmail('[email protected]')
expect(user).toBeDefined()
expect(user?.email).toBe('[email protected]')
})
it('returns null for non-existent user', async () => {
const user = await findUserByEmail('[email protected]')
expect(user).toBeNull()
})
})
})
インテグレーションテストのベストプラクティス
✅ 推奨事項:
- テスト用データベースを使います(開発環境/本番環境とは別にします)
- テストデータをクリーンアップします(beforeEach/afterEach)
- 正常系とエラーケースをテストします
- 認証/認可をテストします
- テストデータにはファクトリー/フィクスチャを使います
❌ 避けること:
- 本番データベースに対してテストする
- テストデータを残したままにする
- データベースをモックする(インテグレーションテストの目的に反します)
- 外部サービスに依存する(外部 API はモックします)
レベル3:E2E テスト(10%)
テスト対象
実際の UI を通じた一連のユーザージャーニーをテストします。
適した対象:
- ✅ 重要なユーザーフロー(サインアップ、ログイン、購入手続き)
- ✅ 中核となるビジネスプロセス
- ✅ 複数ステップからなるワークフロー
対象外:
- ❌ 考えられるあらゆる UI 操作(実行が遅く、壊れやすくなります)
- ❌ エッジケース(ユニットテスト/インテグレーションテストでカバーします)
E2E テストの例(Playwright)
// tests/e2e/auth.spec.ts
import { test, expect } from '@playwright/test'
test.describe('Authentication', () => {
test('user can sign up and log in', async ({ page }) => {
// Navigate to signup
await page.goto('/signup')
// Fill signup form
await page.fill('input[name="email"]', '[email protected]')
await page.fill('input[name="password"]', 'SecurePass123!')
await page.fill('input[name="confirmPassword"]', 'SecurePass123!')
// Submit form
await page.click('button[type="submit"]')
// Should redirect to dashboard
await expect(page).toHaveURL('/dashboard')
await expect(page.locator('h1')).toContainText('Welcome')
// Logout
await page.click('[data-testid="logout-button"]')
// Should redirect to login
await expect(page).toHaveURL('/login')
// Login again
await page.fill('input[name="email"]', '[email protected]')
await page.fill('input[name="password"]', 'SecurePass123!')
await page.click('button[type="submit"]')
// Should be back at dashboard
await expect(page).toHaveURL('/dashboard')
})
test('shows 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"]')
// Should show error message
await expect(page.locator('[role="alert"]')).toContainText('Invalid credentials')
// Should stay on login page
await expect(page).toHaveURL('/login')
})
})
// tests/e2e/checkout.spec.ts
test.describe('Checkout Flow', () => {
test('user can complete purchase', async ({ page }) => {
// Login first
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"]')
// Add product to cart
await page.goto('/products')
await page.click('[data-testid="product-1"] button:text("Add to Cart")')
// Verify cart badge
await expect(page.locator('[data-testid="cart-badge"]')).toContainText('1')
// Go to checkout
await page.click('[data-testid="cart-button"]')
await page.click('button:text("Checkout")')
// Fill shipping info
await page.fill('input[name="address"]', '123 Main St')
await page.fill('input[name="city"]', 'San Francisco')
await page.fill('input[name="zip"]', '94103')
// Fill payment info (test mode)
await page.fill('input[name="cardNumber"]', '4242424242424242')
await page.fill('input[name="expiry"]', '12/25')
await page.fill('input[name="cvc"]', '123')
// Submit order
await page.click('button:text("Place Order")')
// Should see confirmation
await expect(page).toHaveURL(/\/orders\/\d+/)
await expect(page.locator('h1')).toContainText('Order Confirmed')
})
})
E2E テストのベストプラクティス
✅ 推奨事項:
- 重要なパスだけをテストします(20件未満)
- data-testid 属性を使います(安定したセレクター)
- CI/CD パイプラインで実行します
- 複数のブラウザーでテストします(Chrome、Firefox、Safari)
- 失敗時にスクリーンショットを撮ります
❌ 避けること:
- UI のあらゆるバリエーションをテストする
- 不安定なセレクターを使う(テキストコンテンツ、nth-child)
- コミットのたびに E2E テストを実行する(実行が遅すぎます)
- 不安定なテストを放置する(修正するか削除します)
テスト駆動開発(TDD)
レッド・グリーン・リファクターのサイクル
- レッド: 失敗するテストを書く
- グリーン: テストを通すための最小限のコードを書く
- リファクター: テストを成功状態に保ちながらコードを改善する
TDD の例
// 1. RED: Write failing test first
describe('formatCurrency', () => {
it('formats number as USD currency', () => {
expect(formatCurrency(1234.56)).toBe('$1,234.56')
})
})
// Run test: FAILS (formatCurrency doesn't exist)
// 2. GREEN: Write minimal implementation
export function formatCurrency(amount: number): string {
return `$${amount.toLocaleString('en-US', { minimumFractionDigits: 2 })}`
}
// Run test: PASSES
// 3. REFACTOR: Improve implementation
export function formatCurrency(
amount: number,
currency: string = 'USD',
locale: string = 'en-US'
): string {
return new Intl.NumberFormat(locale, {
style: 'currency',
currency
}).format(amount)
}
// Add more tests
it('formats EUR currency', () => {
expect(formatCurrency(1234.56, 'EUR', 'de-DE')).toBe('1.234,56 €')
})
it('handles negative amounts', () => {
expect(formatCurrency(-100)).toBe('-$100.00')
})
TDD を使う場面
適しています:
- ✅ 複雑なビジネスロジック
- ✅ バグ修正(まずバグを再現するテストを書きます)
- ✅ 要件が明確に定義されている場合
- ✅ 重要なアルゴリズム
省略してよい場合:
- ❌ 探索的なコーディング(まだ要件が分からない場合)
- ❌ 使い捨てのプロトタイプ
- ❌ 単純な CRUD 操作
モック戦略
モックを使う場面
- ✅ 外部 API(遅く、不安定で、費用がかかる)
- ✅ 時刻/乱数(テストを決定論的にする)
- ✅ ファイルシステムの操作
- ✅ データベース(ユニットテストの場合のみ)
モックの例(Jest)
// Mock external API
import { fetchUserData } from '@/lib/api'
jest.mock('@/lib/api')
const mockFetchUserData = fetchUserData as jest.MockedFunction<typeof fetchUserData>
it('displays user data', async () => {
mockFetchUserData.mockResolvedValue({
id: '1',
name: 'John Doe',
email: '[email protected]'
})
render(<UserProfile userId="1" />)
await waitFor(() => {
expect(screen.getByText('John Doe')).toBeInTheDocument()
})
})
// Mock Date
beforeAll(() => {
jest.useFakeTimers()
jest.setSystemTime(new Date('2024-01-01'))
})
afterAll(() => {
jest.useRealTimers()
})
it('shows correct date', () => {
expect(getCurrentDate()).toBe('2024-01-01')
})
// Mock Math.random
const mockRandom = jest.spyOn(Math, 'random')
mockRandom.mockReturnValue(0.5)
expect(generateRandomId()).toBe('expected-id-with-0.5-random')
mockRandom.mockRestore()
モックのベストプラクティス
✅ 推奨:
- 境界部分(API、ファイルシステム)でモックを使います
- テスト後にモックを元に戻します
- 現実に即したモックを作成します(実際のデータと同じ構造にします)
❌ 避けること:
- モックを使いすぎないようにします(テストが壊れやすくなります)
- 自分のコードをモックしないようにします(実際の動作をテストします)
- 所有していないものはモックしないようにします(外部のものは除きます)
コードカバレッジ
カバレッジの目標
- 最低 70% - これを下回ると、重要なテストが不足しています
- 80% が目安 - 重要な経路をしっかりカバーできます
- 90% 以上は効果が逓減します - 100% を目指すことが割に合わない場合も多くあります
優先してテストする項目
優先度:高(90% 以上のカバレッジが必要):
- ビジネスロジック
- 認証/認可
- 決済処理
- データ検証
優先度:中(70% 以上を目標):
- API ルート
- データベースクエリ
- ユーティリティ関数
優先度:低(省略しても問題ありません):
- UI コンポーネント(実装ではなく動作をテストします)
- 設定ファイル
- 型定義
- サードパーティとの連携(統合テストの方が適しています)
カバレッジの確認
# Jest
npm test -- --coverage
# View HTML report
open coverage/lcov-report/index.html
カバレッジの設定(jest.config.js)
module.exports = {
collectCoverageFrom: [
'src/**/*.{ts,tsx}',
'!src/**/*.d.ts',
'!src/**/*.stories.tsx',
'!src/types/**'
],
coverageThresholds: {
global: {
branches: 70,
functions: 70,
lines: 70,
statements: 70
},
// Critical paths need higher coverage
'./src/lib/auth/**': {
branches: 90,
functions: 90,
lines: 90
}
}
}
フレームワーク別のテスト戦略
Next.js(React)
- ユニットテスト:Jest + React Testing Library
- 統合テスト:Supertest(API ルート)
- E2E テスト:Playwright
Express API
- ユニットテスト:Jest
- 統合テスト:Supertest
- E2E テスト:Playwright(UI がある場合)
FastAPI(Python)
- ユニットテスト:pytest
- 統合テスト:pytest + TestClient
- E2E テスト:Playwright
よく使われるテストパターン
非同期コードのテスト
// Using async/await
it('fetches user data', async () => {
const user = await fetchUser('123')
expect(user.name).toBe('John')
})
// Using waitFor (React Testing Library)
it('shows loading then data', async () => {
render(<UserProfile userId="123" />)
expect(screen.getByText('Loading...')).toBeInTheDocument()
await waitFor(() => {
expect(screen.getByText('John Doe')).toBeInTheDocument()
})
})
エラーハンドリングのテスト
it('handles errors gracefully', async () => {
mockFetchUser.mockRejectedValue(new Error('Network error'))
render(<UserProfile userId="123" />)
await waitFor(() => {
expect(screen.getByText(/error/i)).toBeInTheDocument()
})
})
フォームのテスト
it('submits form with valid data', async () => {
const handleSubmit = jest.fn()
render(<LoginForm onSubmit={handleSubmit} />)
await userEvent.type(screen.getByLabelText('Email'), '[email protected]')
await userEvent.type(screen.getByLabelText('Password'), 'password123')
await userEvent.click(screen.getByRole('button', { name: 'Login' }))
await waitFor(() => {
expect(handleSubmit).toHaveBeenCalledWith({
email: '[email protected]',
password: 'password123'
})
})
})
テストの構成
ファイル構成
src/
├── components/
│ ├── Button.tsx
│ └── Button.test.tsx # Co-located with component
├── lib/
│ ├── utils.ts
│ └── utils.test.ts # Co-located with module
└── __tests__/
├── integration/ # Integration tests
│ └── api.test.ts
└── e2e/ # E2E tests
└── checkout.spec.ts
命名規則
- ユニット/統合テスト:
*.test.tsまたは*.spec.ts - E2E テスト:
*.e2e.tsまたは*.spec.ts(tests/e2e/ 内) - テスト名:
it('should do X when Y')またはit('does X')
このスキルを使う場面
次のような場合は testing-strategist スキルを使います:
- ✅ 新しいプロジェクトのテストを設定するとき
- ✅ テストフレームワークを選ぶとき
- ✅ 何をどのレベルでテストするか決めるとき
- ✅ TDD を実践するとき
- ✅ コードカバレッジを改善するとき
- ✅ 不安定なテストを修正するとき
関連リソース
スキル:
security-engineer- セキュリティテストapi-designer- API テスト戦略frontend-builder- React のテストパターン
パターン:
/STANDARDS/best-practices/testing-best-practices.md/TEMPLATES/testing/jest-nextjs-setup.md/TEMPLATES/testing/playwright-e2e-setup.md
外部リソース:
優れたテストがあれば、自信を持ってリリースできます。 ✅
コメント
Markdown 対応