🧪

Next.js App Router時代のAI-driven TDD:実践的な最小ループと具体的な実装パターン

に公開

はじめに

AI時代のテスト駆動開発は、従来のTDDとは違ったアプローチが求められます。

AIは優秀なペアプログラマーですが、文脈の理解意図の継続に課題があります。だからこそ、テストファーストの思想がより重要になります。テストに残された意図が、AIとの対話品質を決定的に左右するのです。

本記事では、Next.js App Routerを前提としたAI-driven TDD(AITDD)の実践的な手法を、実際に動くコード例とともに解説します。大切なのは小さく確実なサイクルを回すこと。まずは10分で完結する最小ループから始めましょう。


1. AITDD の基本原則と最小ループ

1-1. Red(失敗テスト): 意図を明確に記述する

AIとの協働では、テストが仕様書の役割を果たします。曖昧な要求ではなく、具体的な期待値を含むテストを先に書きます。

原則:

  • 1機能につき1テストから開始(複雑化を避ける)
  • 明確な失敗理由を確認(実装の指針となる)
  • AIへの依頼は具体的に(対象・前提・期待値を明示)
// ❌ 曖昧なテスト
expect(formatPrice(1000)).toBeTruthy();

// ✅ 明確なテスト
expect(formatPrice(1000)).toBe('¥1,000');
expect(formatPrice(-500)).toBe('-¥500');

1-2. Green(最小実装): AIと協働で最短パスを見つける

テストを通すための最小限のコードを実装します。この段階では完璧さより速度を重視。

原則:

  • 過度な抽象化は避ける(YAGNI原則の厳守)
  • ハードコードも辞さない(リファクタで改善)
  • テスト実行で緑確認は必須

1-3. Refactor(継続的改善): 次の変更を楽にする

機能追加や修正が楽になる設計に整えます。AIに複数の改善案を提案してもらい、トレードオフを比較検討します。

原則:

  • テストは常に緑を維持
  • 1回に1つの改善(複数同時は混乱の元)
  • 命名・分割・依存関係の見直し

2. Next.js App Router 専用環境のセットアップ

最小構成: 実際のプロジェクトで即座に始められる設定

// package.json(関連部分のみ)
{
  "scripts": {
    "test": "jest --watchAll=false",
    "test:watch": "jest --watch",
    "test:e2e": "playwright test"
  },
  "devDependencies": {
    "@testing-library/react": "^14.0.0",
    "@testing-library/jest-dom": "^6.1.0",
    "@testing-library/user-event": "^14.5.0",
    "jest": "^29.7.0",
    "jest-environment-jsdom": "^29.7.0",
    "@playwright/test": "^1.40.0"
  }
}
// jest.config.js
const nextJest = require('next/jest')

const createJestConfig = nextJest({
  dir: './',
})

const customJestConfig = {
  setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
  testEnvironment: 'jsdom',
  moduleNameMapper: {
    '^@/(.*)$': '<rootDir>/$1',
  },
}

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',
  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',
  },
  projects: [
    { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
  ],
  webServer: {
    command: 'npm run dev',
    url: 'http://localhost:3000',
    reuseExistingServer: !process.env.CI,
  },
});

3. 実践例1: ユニットテスト(formatCurrency 関数)

ユースケース: App Routerで多言語対応ECサイトの価格表示機能

Step 1: Red - 失敗するテストを作成

// __tests__/utils/formatCurrency.test.ts
import { formatCurrency } from '@/app/lib/formatCurrency'

describe('formatCurrency', () => {
  test('日本円を正しく表示する', () => {
    expect(formatCurrency(1000, 'JPY', 'ja')).toBe('¥1,000')
  })
  
  test('米ドルを正しく表示する', () => {
    expect(formatCurrency(1000, 'USD', 'en')).toBe('$1,000.00')
  })
  
  test('負の金額を正しく処理する', () => {
    expect(formatCurrency(-500, 'JPY', 'ja')).toBe('-¥500')
  })
  
  test('小数点を含む金額を適切に処理する', () => {
    expect(formatCurrency(1234.56, 'USD', 'en')).toBe('$1,234.56')
  })
})

AIプロンプト例:

Next.js App Routerで多言語ECサイトを開発中です。
以下のテスト仕様を満たす formatCurrency 関数の最小実装を提案してください。

テスト要件:
- 日本円: formatCurrency(1000, 'JPY', 'ja') → '¥1,000'
- 米ドル: formatCurrency(1000, 'USD', 'en') → '$1,000.00'
- 負数対応: formatCurrency(-500, 'JPY', 'ja') → '-¥500'

まず失敗する最小実装から始めて、その後正しい実装を提案してください。

Step 2: Green - 最小実装

// app/lib/formatCurrency.ts(最初の失敗実装)
export const formatCurrency = (
  value: number, 
  currency: string, 
  locale: string
): string => {
  // まず意図的に失敗させる
  return value.toString()
}

テスト実行: npm run test formatCurrency.test.ts

# 予想される失敗結果
FAIL __tests__/utils/formatCurrency.test.ts
✕ 日本円を正しく表示する (2 ms)
✕ 米ドルを正しく表示する (1 ms)
...

Step 3: Green - 正しい最小実装

// app/lib/formatCurrency.ts(正しい実装)
export const formatCurrency = (
  value: number, 
  currency: string, 
  locale: string
): string => {
  return new Intl.NumberFormat(locale, {
    style: 'currency',
    currency: currency,
  }).format(value)
}

Step 4: Refactor - App Router特有の改善

// app/lib/formatCurrency.ts(App Router最適化版)
export const formatCurrency = (
  value: number, 
  currency: string, 
  locale: string
): string => {
  // 入力検証の追加
  if (typeof value !== 'number' || !isFinite(value)) {
    throw new Error('Invalid number value')
  }
  
  return new Intl.NumberFormat(locale, {
    style: 'currency',
    currency: currency,
  }).format(value)
}

// Client Component用の型安全なラッパー(必要に応じて)
export const formatCurrencyClient = formatCurrency

4. 実践例2: App Router コンポーネントテスト(SearchBox)

ユースケース: Next.js App Routerの商品検索機能

Step 1: Red - Client Componentのテスト設計

// __tests__/components/SearchBox.test.tsx
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useRouter, useSearchParams } from 'next/navigation'
import SearchBox from '@/app/components/SearchBox'

// Mock useRouter for App Router
jest.mock('next/navigation', () => ({
  useRouter: jest.fn(),
  useSearchParams: jest.fn(),
}))

const mockPush = jest.fn()
const mockReplace = jest.fn()

describe('SearchBox', () => {
  beforeEach(() => {
    jest.useFakeTimers()
    ;(useRouter as jest.Mock).mockReturnValue({
      push: mockPush,
      replace: mockReplace,
    })
    ;(useSearchParams as jest.Mock).mockReturnValue(new URLSearchParams())
  })
  
  afterEach(() => {
    jest.useRealTimers()
    jest.clearAllMocks()
  })

  test('検索語入力から400ms後にURLが更新される', async () => {
    const user = userEvent.setup({ 
      advanceTimers: jest.advanceTimersByTime 
    })
    
    render(<SearchBox />)
    
    const input = screen.getByPlaceholderText('商品を検索...')
    await user.type(input, 'iPhone')
    
    // 400ms経過前は呼ばれない
    expect(mockReplace).not.toHaveBeenCalled()
    
    jest.advanceTimersByTime(400)
    
    await waitFor(() => {
      expect(mockReplace).toHaveBeenCalledWith('/search?q=iPhone')
    })
  })

  test('空文字検索は実行されない', async () => {
    const user = userEvent.setup({ 
      advanceTimers: jest.advanceTimersByTime 
    })
    
    render(<SearchBox />)
    
    const input = screen.getByPlaceholderText('商品を検索...')
    await user.type(input, '   ')
    
    jest.advanceTimersByTime(400)
    
    expect(mockReplace).not.toHaveBeenCalled()
  })

  test('連続入力時は最後の値のみでURL更新される', async () => {
    const user = userEvent.setup({ 
      advanceTimers: jest.advanceTimersByTime 
    })
    
    render(<SearchBox />)
    
    const input = screen.getByPlaceholderText('商品を検索...')
    
    await user.type(input, 'iP')
    jest.advanceTimersByTime(200)
    
    await user.type(input, 'hone')
    jest.advanceTimersByTime(400)
    
    await waitFor(() => {
      expect(mockReplace).toHaveBeenCalledTimes(1)
      expect(mockReplace).toHaveBeenCalledWith('/search?q=iPhone')
    })
  })
})

Step 2: Green - Client Component実装

// app/components/SearchBox.tsx
'use client'

import { useState, useEffect, useCallback } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'

interface SearchBoxProps {
  placeholder?: string
  className?: string
}

export default function SearchBox({ 
  placeholder = '商品を検索...',
  className = ''
}: SearchBoxProps) {
  const [query, setQuery] = useState('')
  const router = useRouter()
  const searchParams = useSearchParams()

  // デバウンス処理
  useEffect(() => {
    const timeoutId = setTimeout(() => {
      const trimmedQuery = query.trim()
      if (trimmedQuery) {
        // URLを更新してServer Componentでの検索をトリガー
        const params = new URLSearchParams(searchParams)
        params.set('q', trimmedQuery)
        router.replace(`/search?${params.toString()}`)
      }
    }, 400)

    return () => clearTimeout(timeoutId)
  }, [query, router, searchParams])

  // URL同期(App Router対応)
  useEffect(() => {
    const currentQuery = searchParams.get('q') || ''
    if (currentQuery !== query) {
      setQuery(currentQuery)
    }
  }, [searchParams])

  const handleSubmit = useCallback((e: React.FormEvent) => {
    e.preventDefault()
    const trimmedQuery = query.trim()
    if (trimmedQuery) {
      // URLを更新(App Router)
      const params = new URLSearchParams(searchParams)
      params.set('q', trimmedQuery)
      router.replace(`?${params.toString()}`)
    }
  }, [query, router, searchParams])

  return (
    <form onSubmit={handleSubmit} className={className}>
      <input
        type="text"
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder={placeholder}
        className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
        aria-label="検索"
      />
    </form>
  )
}

Step 3: Refactor - Server Component統合

// app/search/page.tsx(Server Component側)
import { Suspense } from 'react'
import SearchBox from '@/app/components/SearchBox'
import SearchResults from '@/app/components/SearchResults'

interface SearchPageProps {
  searchParams: { q?: string }
}

export default async function SearchPage({ searchParams }: SearchPageProps) {
  const query = searchParams.q || ''

  return (
    <div className="container mx-auto px-4 py-8">
      <h1 className="text-2xl font-bold mb-6">商品検索</h1>
      
      <SearchBox 
        placeholder="商品を検索..."
        className="mb-8"
      />
      
      <Suspense fallback={<div>検索中...</div>}>
        <SearchResults query={query} />
      </Suspense>
    </div>
  )
}

Server Actions連携例:

// app/actions/search.ts
'use server'

export async function searchProducts(query: string) {
  // データベース検索やAPI呼び出し
  return {
    products: [
      { id: 1, name: `${query}関連商品1`, price: 1000 },
      { id: 2, name: `${query}関連商品2`, price: 2000 },
    ],
    total: 2
  }
}

// app/components/SearchResults.tsx
import { searchProducts } from '@/app/actions/search'

interface SearchResultsProps {
  query: string
}

export default async function SearchResults({ query }: SearchResultsProps) {
  if (!query) return <div>検索キーワードを入力してください</div>
  
  const results = await searchProducts(query)
  
  return (
    <div data-testid="search-results">
      <p data-testid="result-count">{results.total}件の商品が見つかりました</p>
      <div className="grid gap-4">
        {results.products.map(product => (
          <div key={product.id} data-testid="product-card" className="border p-4">
            <h3 data-testid="product-title">{product.name}</h3>
            <p data-testid="product-price">¥{product.price}</p>
          </div>
        ))}
      </div>
    </div>
  )
}

5. 実践例3: App Router E2Eテスト(Playwright)

ユースケース: 商品検索から詳細画面への遷移フロー

Step 1: E2Eテストの設計

// e2e/product-search.spec.ts
import { test, expect } from '@playwright/test'

test.describe('商品検索フロー', () => {
  test.beforeEach(async ({ page }) => {
    // App Routerのダイナミックルーティング対応
    await page.goto('/search')
    await expect(page).toHaveTitle(/商品検索/)
  })

  test('検索→結果表示→詳細画面遷移の一連フロー', async ({ page }) => {
    // 検索実行
    const searchInput = page.getByPlaceholder('商品を検索...')
    await searchInput.fill('iPhone')
    await searchInput.press('Enter')

    // URL更新確認(App Router)
    await expect(page).toHaveURL(/\/search\?q=iPhone/)

    // 検索結果の表示確認
    await expect(page.getByTestId('search-results')).toBeVisible()
    await expect(page.getByTestId('result-count')).toHaveText(/\d+件の商品が見つかりました/)

    // 商品カードの存在確認
    const firstProduct = page.getByTestId('product-card').first()
    await expect(firstProduct).toBeVisible()

    // 商品詳細への遷移
    await firstProduct.click()

    // 詳細ページの確認(App Routerのダイナミックルーティング)
    await expect(page).toHaveURL(/\/products\/\d+/)
    await expect(page.getByTestId('product-title')).toBeVisible()
    await expect(page.getByTestId('product-price')).toBeVisible()
  })

  test('検索結果が0件の場合の表示', async ({ page }) => {
    const searchInput = page.getByPlaceholder('商品を検索...')
    await searchInput.fill('存在しない商品XYZ123')
    await searchInput.press('Enter')

    await expect(page.getByTestId('no-results')).toBeVisible()
    await expect(page.getByTestId('no-results')).toHaveText(/該当する商品が見つかりませんでした/)
  })

  test('ページネーション機能', async ({ page }) => {
    const searchInput = page.getByPlaceholder('商品を検索...')
    await searchInput.fill('スマートフォン')
    await searchInput.press('Enter')

    // 複数ページある場合のテスト
    const paginationNext = page.getByTestId('pagination-next')
    if (await paginationNext.isVisible()) {
      await paginationNext.click()
      
      // URLクエリパラメータの確認
      await expect(page).toHaveURL(/page=2/)
      await expect(page.getByTestId('search-results')).toBeVisible()
    }
  })
})

test.describe('レスポンシブ対応', () => {
  test('モバイル表示での検索機能', async ({ page }) => {
    await page.setViewportSize({ width: 375, height: 667 })
    await page.goto('/search')

    const searchInput = page.getByPlaceholder('商品を検索...')
    await expect(searchInput).toBeVisible()
    
    await searchInput.fill('iPad')
    await searchInput.press('Enter')

    // モバイルレイアウトでの結果表示確認
    await expect(page.getByTestId('search-results')).toBeVisible()
    const productCards = page.getByTestId('product-card')
    
    // モバイルでは縦並び表示
    const firstCard = productCards.first()
    const secondCard = productCards.nth(1)
    
    if (await secondCard.isVisible()) {
      const firstBox = await firstCard.boundingBox()
      const secondBox = await secondCard.boundingBox()
      
      // Y座標を比較して縦並びを確認
      expect(secondBox?.y).toBeGreaterThan(firstBox?.y || 0)
    }
  })
})

Step 2: App Router対応のPage Objectパターン

// e2e/pages/SearchPage.ts
import { Page, Locator, expect } from '@playwright/test'

export class SearchPage {
  readonly page: Page
  readonly searchInput: Locator
  readonly searchResults: Locator
  readonly resultCount: Locator
  readonly noResults: Locator
  readonly productCards: Locator
  readonly pagination: Locator

  constructor(page: Page) {
    this.page = page
    this.searchInput = page.getByPlaceholder('商品を検索...')
    this.searchResults = page.getByTestId('search-results')
    this.resultCount = page.getByTestId('result-count')
    this.noResults = page.getByTestId('no-results')
    this.productCards = page.getByTestId('product-card')
    this.pagination = page.getByTestId('pagination')
  }

  async goto() {
    await this.page.goto('/search')
    await expect(this.page).toHaveTitle(/商品検索/)
  }

  async search(query: string) {
    await this.searchInput.fill(query)
    await this.searchInput.press('Enter')
    
    // App RouterのURL更新を待機
    await expect(this.page).toHaveURL(new RegExp(`q=${encodeURIComponent(query)}`))
  }

  async expectResultsVisible() {
    await expect(this.searchResults).toBeVisible()
  }

  async expectResultCount(pattern: RegExp) {
    await expect(this.resultCount).toHaveText(pattern)
  }

  async expectNoResults() {
    await expect(this.noResults).toBeVisible()
  }

  async clickFirstProduct() {
    await this.productCards.first().click()
  }

  async goToPage(pageNumber: number) {
    await this.page.getByTestId(`pagination-page-${pageNumber}`).click()
    await expect(this.page).toHaveURL(new RegExp(`page=${pageNumber}`))
  }
}

Step 3: CI/CD環境での安定実行

// playwright.config.ts(CI最適化版)
import { defineConfig, devices } from '@playwright/test'

export default defineConfig({
  testDir: './e2e',
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 3 : 1, // CI環境では多めにリトライ
  workers: process.env.CI ? 2 : undefined,
  reporter: process.env.CI ? 'github' : 'html',
  
  use: {
    baseURL: process.env.BASE_URL || 'http://localhost:3000',
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
    video: 'retain-on-failure',
    // App Routerでのナビゲーション待機時間
    navigationTimeout: 30000,
    actionTimeout: 15000,
  },

  projects: [
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] },
    },
    {
      name: 'mobile-chrome',
      use: { ...devices['Pixel 5'] },
    },
  ],

  webServer: {
    command: process.env.CI ? 'npm run build && npm start' : 'npm run dev',
    url: 'http://localhost:3000',
    reuseExistingServer: !process.env.CI,
    timeout: process.env.CI ? 120000 : 60000,
  },
})

6. AIとの効果的な協働: 実践的プロンプトテンプレート集

6-1. ユニットテスト生成プロンプト

## 役割
Next.js App Router + TypeScript専門のTDDアシスタント

## 依頼内容
以下の関数仕様に対する完全なテストスイートを作成してください。

### 対象関数
`app/lib/formatCurrency.ts` の formatCurrency 関数

### 仕様
- 入力: (value: number, currency: 'JPY'|'USD', locale: 'ja'|'en')
- 出力: ロケールに応じた通貨表示文字列
- 例: formatCurrency(1000, 'JPY', 'ja') → '¥1,000'

### 出力要件
1. **テストファイル全文**(Jest + @testing-library)
2. **最初に失敗する実装**(1-2行のコメント付き)  
3. **テストを通す最小実装**
4. **エッジケース提案**(3つまで)
5. **App Router固有の考慮点**があれば1行で

### 制約
- TypeScript strict mode対応
- 1テストケース = 1つのexpect
- モック使用は最小限

6-2. Client Componentテスト生成プロンプト

## 役割  
React Testing Library + App Router専門のコンポーネントテスト設計者

## 対象コンポーネント
`'use client'` ディレクティブ付きの SearchBox コンポーネント

### 要件
- 機能: 400msデバウンス、空文字除外、Enter送信対応
- App Router: useRouter, useSearchParams使用
- URL統合: 検索クエリをURLパラメータとして管理

### 出力要件
1. **完全なテストファイル**(setup/teardown含む)
2. **Next.js 14 App Router対応のモック設定**
3. **非同期処理(デバウンス)の安定したテスト手法**
4. **アクセシビリティテスト**(aria-label等)
5. **URL更新の検証**方法

### 重視ポイント
- useFakeTimers の適切な使用
- userEvent の最新API活用  
- App Routerフック対応
- useRouter.replace の呼び出し検証

6-3. E2Eシナリオ生成プロンプト

## 役割
Playwright + Next.js App Router専門のE2Eテスト設計者

## シナリオ要求
商品検索アプリケーションの主要フローテスト

### 対象フロー
1. `/search` ページでの検索実行
2. 結果表示とページネーション  
3. 商品詳細画面 `/products/[id]` への遷移
4. モバイル表示での動作確認

### 出力要件
1. **メインシナリオテスト**(成功パス)
2. **異常系テスト**(0件検索、エラー処理)
3. **Page Objectパターン**の基本実装
4. **CI環境対応設定**(リトライ、タイムアウト)
5. **App Router特有の注意点**(ダイナミックルーティング等)

### 制約条件
- data-testid ベースのセレクタ
- レスポンシブ対応必須
- 実行時間5分以内

6-4. リファクタリング相談プロンプト

## 役割
Next.js App Router + Clean Architecture専門のリファクタリングアドバイザー

## 現在のコード
[対象コードを貼り付け]

## リファクタリング要求
以下の観点で改善提案をお願いします:

### 評価軸
1. **App Router最適化**(Server/Client Components分離)
2. **型安全性向上**(TypeScript活用)
3. **テスタビリティ**(依存注入、モック容易性)
4. **パフォーマンス**(バンドルサイズ、レンダリング最適化)

### 出力形式
各改善案について:
- **変更内容**(1-2行要約)
- **メリット/デメリット**(トレードオフ明記)
- **実装優先度**(High/Medium/Low)
- **影響範囲**(テスト修正の有無)

### 制約
- 既存テストは全て通ること
- 1回の変更で1つの改善のみ
- App Routerの思想に沿った提案

6-5. デバッグ支援プロンプト

## 役割
Next.js App Routerのテスト失敗分析専門家

## 状況
以下のテストが失敗しています:

### 失敗テスト
[テストコードと実行結果を貼り付け]

### 実装コード  
[関連する実装コードを貼り付け]

### 分析依頼
1. **失敗原因の特定**(根本原因分析)
2. **App Router固有の問題**があるか確認
3. **最小修正案**(テストまたは実装)
4. **類似問題の予防策**(1-2行)

### 出力要件
- 修正箇所の明確な特定
- Before/After の差分表示
- 他のテストへの影響確認
- デバッグ手法のTips

### 注意点
- 過度なコード変更は避ける
- App Routerの制約を考慮
- TypeScript型エラーも確認

7. App Router対応CI/CD: GitHub Actions実装例

7-1. 完全なワークフロー設定

# .github/workflows/ci.yml
name: CI/CD Pipeline for Next.js App Router

on:
  push:
    branches: [ main, develop ]
  pull_request:
    branches: [ main ]

env:
  NODE_VERSION: '20'
  NEXT_TELEMETRY_DISABLED: 1

jobs:
  setup:
    runs-on: ubuntu-latest
    outputs:
      cache-key: ${{ steps.cache-keys.outputs.cache-key }}
    steps:
      - uses: actions/checkout@v4
      
      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: 'npm'
          
      - id: cache-keys
        run: echo "cache-key=node-modules-${{ hashFiles('package-lock.json') }}" >> $GITHUB_OUTPUT
        
      - name: Install dependencies
        run: npm ci --prefer-offline --no-audit

  lint-and-type-check:
    needs: setup
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: 'npm'
          
      - name: Install dependencies
        run: npm ci --prefer-offline --no-audit
        
      - name: Run ESLint
        run: npm run lint
        
      - name: TypeScript type check
        run: npm run type-check
        
      - name: Check Next.js build
        run: npm run build

  unit-and-component-tests:
    needs: setup
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: 'npm'
          
      - name: Install dependencies
        run: npm ci --prefer-offline --no-audit
        
      - name: Run unit and component tests
        run: npm run test -- --coverage --passWithNoTests
        env:
          CI: true
          
      - name: Upload coverage to Codecov
        uses: codecov/codecov-action@v3
        with:
          files: ./coverage/lcov.info
          fail_ci_if_error: false

  e2e-tests:
    needs: setup
    runs-on: ubuntu-latest
    if: github.event_name == 'pull_request' || github.ref == 'refs/heads/main'
    strategy:
      matrix:
        browser: [chromium, firefox]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: 'npm'
      - name: Install dependencies
        run: npm ci --prefer-offline --no-audit
      - name: Install Playwright browsers
        run: npx playwright install --with-deps ${{ matrix.browser }}
      - name: Download Next.js build artifact
        uses: actions/download-artifact@v3
        with:
          name: next-build
          path: .next
      - name: Run E2E tests
        run: npx playwright test --project=${{ matrix.browser }}
        env:
          CI: true
      - name: Upload Playwright report
        uses: actions/upload-artifact@v3
        if: failure()
        with:
          name: playwright-report-${{ matrix.browser }}
          path: playwright-report/
          retention-days: 7

  visual-regression:
    needs: setup
    runs-on: ubuntu-latest
    if: github.event_name == 'pull_request'
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: 'npm'
          
      - name: Install dependencies
        run: npm ci --prefer-offline --no-audit
        
      - name: Install Playwright
        run: npx playwright install chromium
        
      - name: Build application
        run: npm run build
        
      - name: Run visual regression tests
        run: npx playwright test visual/ --project=chromium
        
      - name: Upload visual diff artifacts
        uses: actions/upload-artifact@v3
        if: failure()
        with:
          name: visual-regression-diffs
          path: test-results/
          retention-days: 7

  deploy-preview:
    needs: [lint-and-type-check, unit-and-component-tests]
    runs-on: ubuntu-latest
    if: github.event_name == 'pull_request'
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: 'npm'
          
      - name: Install dependencies
        run: npm ci --prefer-offline --no-audit
        
      - name: Build for preview
        run: npm run build
        env:
          NEXT_PUBLIC_ENVIRONMENT: preview
          
      - name: Deploy to Vercel Preview
        uses: amondnet/vercel-action@v25
        with:
          vercel-token: ${{ secrets.VERCEL_TOKEN }}
          vercel-org-id: ${{ secrets.VERCEL_ORG_ID }}
          vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }}
          scope: ${{ secrets.VERCEL_ORG_ID }}

7-2. パッケージ.jsonスクリプト設定

{
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "next lint --max-warnings 0",
    "lint:fix": "next lint --fix",
    "type-check": "tsc --noEmit",
    "test": "jest",
    "test:watch": "jest --watch",
    "test:coverage": "jest --coverage",
    "test:e2e": "playwright test",
    "test:e2e:ui": "playwright test --ui",
    "test:visual": "playwright test visual/",
    "prepare": "husky install"
  }
}

7-3. 品質ゲート設定(Husky + lint-staged)

// package.json(追加設定)
{
  "lint-staged": {
    "*.{js,jsx,ts,tsx}": [
      "eslint --fix",
      "prettier --write"
    ],
    "*.{json,md,yml,yaml}": [
      "prettier --write"
    ],
    "*.{ts,tsx}": [
      "bash -c 'npm run type-check'"
    ]
  }
}
#!/bin/sh
# .husky/pre-commit
. "$(dirname "$0")/_/husky.sh"

# 型チェック
npm run type-check

# リント・フォーマット
npx lint-staged

# 変更されたファイルに関連するテストを実行
CHANGED_FILES=$(git diff --cached --name-only --diff-filter=ACM | grep -E '\.(ts|tsx)$' | tr '\n' ' ')
if [ -n "$CHANGED_FILES" ]; then
  npm run test -- --bail --findRelatedTests $CHANGED_FILES
fi

7-4. モニタリング・アラート設定

# .github/workflows/performance-monitoring.yml
name: Performance Monitoring

on:
  schedule:
    - cron: '0 2 * * *'  # 毎日午前2時実行
  workflow_dispatch:

jobs:
  lighthouse-ci:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
          
      - name: Install dependencies
        run: npm ci
        
      - name: Build application
        run: npm run build
        
      - name: Run Lighthouse CI
        run: |
          npm install -g @lhci/cli@0.12.x
          lhci autorun
        env:
          LHCI_GITHUB_APP_TOKEN: ${{ secrets.LHCI_GITHUB_APP_TOKEN }}
          
      - name: Comment PR with Lighthouse results
        if: github.event_name == 'pull_request'
        uses: foo-software/lighthouse-check-action@master
        with:
          accessToken: ${{ secrets.GITHUB_TOKEN }}
          gitHubApiUrl: https://api.github.com
          urls: 'https://your-preview-url.vercel.app'

8. よくあるつまずき

  • AI が先に実装を書き始める
    → プロンプトで「まずテスト。実装は赤確認の後」と明記。

  • 巨大な一括差分
    → 1 テスト = 1 変更。PR は ±300 行以内を目安に。

  • E2E が不安定
    data-testid を固定、遷移待ちは expect 側で吸収、retry を併用。

  • “正しさ”が曖昧
    → 期待値を 具体例 で渡す(入出力を 2〜3 個)。


おわりに

AI を“使う”だけでなく、育てる
テストとプロンプトは、そのための型だ。

  • 今日やる 3 手
    1. 10〜30 分で終わる小粒な機能を選ぶ
    2. テストを 1 本だけ書き、まず を出す
    3. 緑に通し、軽いリファクタを 1 つ

あとは繰り返し。ループが小さいほど、速くなる。


参考リンク

GitHubで編集を提案

Discussion