mcpField
T
Testing Strategist

Testing Strategist

github.com/daffy0208テスト・品質38173 回インストール44 週間前に更新
リポジトリ

Testing Strategist について

Testing Strategist は、ソフトウェア開発者向けのテスト戦略スキルです。テストピラミッドに基づき、ユニット、統合、エンドツーエンドテストの選定・作成・保守を支援します。

主な機能

  1. 01テストピラミッドに沿った配分
  2. 02ユニットテストと統合テストの指針
  3. 03主要なユーザーフローの E2E テスト計画
  4. 04TDD のレッド・グリーン・リファクタリング
  5. 05モック、カバレッジ、テスト構成の管理

活用シーン

  1. 01新規プロジェクトのテスト方針策定
  2. 02機能に適したテストレベルの選択
  3. 03複雑なロジックへの TDD 適用
  4. 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)

レッド・グリーン・リファクターのサイクル
  1. レッド: 失敗するテストを書く
  2. グリーン: テストを通すための最小限のコードを書く
  3. リファクター: テストを成功状態に保ちながらコードを改善する
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

外部リソース:


優れたテストがあれば、自信を持ってリリースできます。 ✅

コメント