mcpField

testing-qa

github.com/eddiebe147テスト・品質59 回インストール
リポジトリ

testing-qa について

Next.js アプリのテストを扱うスキルで、Playwright による E2E テスト、Jest によるユニットテスト、React Testing Library によるコンポーネントテストを支援します。テスト作成、失敗の調査、環境構築に利用できます。

主な機能

  1. 01Playwright による E2E テスト
  2. 02Jest によるユニットテスト
  3. 03React Testing Library によるコンポーネントテスト
  4. 04Supabase、ルーター、外部 API のモック
  5. 05テストのデバッグと性能確認

活用シーン

  1. 01新機能のテスト作成
  2. 02非同期処理や操作テストの失敗調査
  3. 03E2E テスト環境の構築
  4. 04アクセシビリティと性能の確認

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/eddiebe147/claude-settings --skill testing-qa

インストール後、Claude Code で/testing-qaを入力して読み込まれたか確認します。

詳細

テストと QA スキル

概要

このスキルでは、E2E テストに Playwright、単体テストに Jest、コンポーネントテストに React Testing Library を使用して、Next.js アプリケーションの包括的なテストを作成できます。

テストの考え方

テストピラミッド
  1. E2E テスト(10%):重要なユーザージャーニー
  2. 統合テスト(30%):コンポーネント間のやり取り
  3. 単体テスト(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 テストを実装するとき
  • アクセシビリティをテストするとき
  • 依存関係をモックするとき
  • テストカバレッジを向上させるとき

コメント