Skip to content

Latest commit

 

History

History
551 lines (433 loc) · 16.6 KB

File metadata and controls

551 lines (433 loc) · 16.6 KB

テスト戦略

walipay のテスト設計方針。AI 駆動開発 (AIDD) における実践的 TDD。


テスト哲学

核心原則

「実装の詳細ではなく、振る舞いをテストする」 「wagmi をテストしない。wagmi を使ったビジネスロジックをテストする」

AI 駆動開発では、AI がテストを自動生成しやすい。しかし「1ファイル = 1テスト」の機械的な適用は モックをテストするテスト を生み出し、維持コストだけが高い脆弱なテスト群になる。

テストは「本番で壊れる可能性があるもの」だけを検証する。


4層テストモデル

Layer 4: ブラウザ確認 (Playwright MCP — Claude Code が自律実行)
  └─ 実装直後の目視確認・デバッグ
     → E2E テスト作成前の探索的確認

Layer 3: E2E (Playwright)
  └─ 実ユーザーフロー + Sepolia Testnet
     → 少数・重要フローのみ (回帰テスト)

Layer 2: Integration (Vitest + RTL)
  └─ API route バリデーション境界
  └─ Component ユーザーインタラクション
     → 「何が起きるか」を検証

Layer 1: Unit (Vitest)
  └─ lib/* 純粋関数のみ
     → ビジネスロジックの核心

詳細: docs/browser-verification.md


何をテストするか / しないか

テストする

対象 理由 テストの種類
lib/calculate-settlement.ts ビジネスロジックの核心 Unit
lib/format-currency.ts 純粋関数、入出力が明確 Unit
lib/parse-web3-error.ts 純粋関数 Unit
lib/contracts.ts (ロジック部分) アドレス解決ロジック Unit
API route バリデーション 入力境界の検証 Integration
API route エラーハンドリング DB エラー時の挙動 Integration
Component のユーザー操作 ボタン → 状態変化 Integration (RTL)
グループ作成フロー 最重要ユーザーフロー E2E
費用追加 → 精算計算フロー 最重要ユーザーフロー E2E
トークン送金フロー Sepolia 実トランザクション E2E (Sepolia)

テストしない

対象 理由
hooks/use-token-balance.ts (wagmi ラッパー) useReadContract の返り値を返すだけ。wagmi が保証
hooks/use-gas-estimate.ts (viem ラッパー) useEstimateGas の薄いラッパー
hooks/use-token-transfer.ts (状態ミラー) isPending/isSuccess は wagmi が管理
コンポーネントの「何かが表示される」 render テストは脆弱で価値が低い
実装詳細 (内部 state、関数呼び出し回数) リファクタリングで壊れる

判断基準:

このテストが壊れたとき、ユーザーに影響があるか?
  → YES: テストする
  → NO:  テストしない

カバレッジ目標

対象 目標 補足
lib/ 90%+ 純粋関数は全ケース網羅
components/ 80%+ インタラクションテストで計測
hooks/ (ロジックあり) 85%+ use-group, use-settlement, use-expenses
hooks/ (wagmi ラッパー) 対象外 テスト削除 or 最小化
app/api/ 85%+ バリデーション + エラー境界
bun test --coverage

Layer 1: ユニットテスト

対象: lib/*.ts — 純粋関数のみ

// lib/calculate-settlement.test.ts
describe('calculateSettlement', () => {
  it('2人の場合に正しく分割する', () => {
    const members = [createMember('a', 'Alice'), createMember('b', 'Bob')];
    const expenses = [createExpense('e1', 'a', 100, ['a', 'b'])];

    const settlements = calculateSettlement(members, expenses);

    expect(settlements).toHaveLength(1);
    expect(settlements[0].from.id).toBe('b');
    expect(settlements[0].amount).toBe(50);
  });

  it('全員の収支がゼロなら空配列', () => {
    // ...
  });

  it('送金回数を最小化する', () => {
    // ...
  });
});

ルール:

  • 1関数につき: 正常系 + 境界値 + 異常系
  • モックなし(外部依存がない純粋関数のみここに置く)
  • エッジケースを優先(AI が見落としがちなもの)

Layer 2: インテグレーションテスト

2a: API ルートテスト

モックは Supabase client の入口だけ。内部チェーンはモックしない。

// app/api/groups/route.test.ts
vi.mock('@supabase/supabase-js', () => ({
  createClient: vi.fn(() => mockSupabaseClient),
}));

describe('POST /api/groups', () => {
  // バリデーション境界
  it('name が空なら 400', async () => {
    const req = new NextRequest('http://localhost/api/groups', {
      method: 'POST',
      body: JSON.stringify({ currency: 'JPY', members: ['Alice'] }),
    });
    const res = await POST(req);
    expect(res.status).toBe(400);
  });

  // 正常系
  it('正常なリクエストで 201 + グループデータを返す', async () => {
    mockSupabaseClient.from.mockImplementation(createGroupMock());
    const res = await POST(validRequest);
    expect(res.status).toBe(201);
    expect((await res.json()).name).toBe('Trip');
  });

  // DB エラー境界
  it('DB エラー時は 500', async () => {
    mockSupabaseClient.from.mockImplementation(createErrorMock());
    const res = await POST(validRequest);
    expect(res.status).toBe(500);
  });
});

ルール:

  • モックのチェーンが 3 段以上 → 設計を見直す
  • テスト対象: バリデーション境界 + DB エラー + 正常系のみ
  • 実装詳細(from が何回呼ばれたか等)はテストしない

2b: コンポーネントテスト (React Testing Library)

「何が render されるか」ではなく「ユーザーが操作したら何が起きるか」を検証。

// components/group/GroupForm.test.tsx
describe('GroupForm', () => {
  it('名前が空でsubmitするとエラーメッセージを表示', async () => {
    const user = userEvent.setup();
    render(<GroupForm onSubmit={mockSubmit} />);

    await user.click(screen.getByRole('button', { name: '作成' }));

    expect(screen.getByText('グループ名は必須です')).toBeInTheDocument();
    expect(mockSubmit).not.toHaveBeenCalled();
  });

  it('正常な入力で onSubmit が呼ばれる', async () => {
    const user = userEvent.setup();
    render(<GroupForm onSubmit={mockSubmit} />);

    await user.type(screen.getByLabelText('グループ名'), '沖縄旅行');
    await user.click(screen.getByRole('button', { name: '作成' }));

    expect(mockSubmit).toHaveBeenCalledWith(
      expect.objectContaining({ name: '沖縄旅行' })
    );
  });
});

ルール:

  • render(<Component />) してスナップショット比較 → しない
  • fireEvent より userEvent(実際のユーザー操作に近い)
  • getByRole / getByLabelText を使う(getByTestId 乱用禁止)

Layer 3: E2E テスト

3a: ユーザーフロー (Playwright)

対象: 3〜5 本の重要フローのみ

test/e2e/
├── create-group.spec.ts       # グループ作成 → メンバー追加
├── add-expense.spec.ts        # 費用追加 → 精算計算確認
├── settlement-flow.spec.ts    # 精算リスト → 支払いマーク
└── blockchain/
    └── token-transfer.spec.ts # ウォレット接続 → 送金 (Sepolia)
// test/e2e/create-group.spec.ts
test('グループ作成から費用追加まで', async ({ page }) => {
  await page.goto('/');
  await page.click('[data-testid="create-group"]');

  await page.fill('input[name="name"]', '沖縄旅行');
  await page.selectOption('select[name="currency"]', 'JPY');
  await page.fill('input[name="members.0.name"]', '太郎');
  await page.click('button[type="submit"]');

  await expect(page).toHaveURL(/\/g\/[a-z0-9-]+/);
  await expect(page.locator('h1')).toHaveText('沖縄旅行');
});

ルール:

  • 全コンポーネントに E2E テスト → しない(コストが高すぎる)
  • ハッピーパスのみ。エッジケースはユニット/インテグレーションで
  • ページ遷移・データ永続化を確認する唯一の層

3b: Sepolia Testnet テスト

実際のトランザクションを実行。事前準備が必須。

事前準備:

# .env.local に設定
TEST_WALLET_PRIVATE_KEY=0x...  # テスト専用ウォレット(本番NG)
TEST_RECIPIENT_ADDRESS=0x...
NEXT_PUBLIC_SEPOLIA_RPC_URL=https://eth-sepolia.g.alchemy.com/v2/...
// test/e2e/blockchain/token-transfer.spec.ts
test('USDC トークン送金', async () => {
  const account = privateKeyToAccount(process.env.TEST_WALLET_PRIVATE_KEY as `0x${string}`);
  const client = createWalletClient({
    account,
    chain: sepolia,
    transport: http(process.env.NEXT_PUBLIC_SEPOLIA_RPC_URL),
  });

  const hash = await client.writeContract({
    address: CONTRACTS[sepolia.id].USDC,
    abi: erc20Abi,
    functionName: 'transfer',
    args: [RECIPIENT, parseUnits('1', 6)],
  });

  const receipt = await client.waitForTransactionReceipt({ hash, timeout: 60_000 });
  expect(receipt.status).toBe('success');
});

セキュリティ:

  • テスト専用ウォレットのみ使用(本番ウォレット禁止)
  • 秘密鍵は .env.local + .gitignore で管理
  • CI では GitHub Secrets から注入

TDD ワークフロー

Red → Green → Refactor

1. Red:    テストを書く → 実行 → 失敗を確認
2. Green:  最小限の実装でテストを通す
3. Refactor: コードを整理(テストが通ったまま)
4. Repeat

テストを書く前の判断

この機能にテストが必要か?

[ ] ビジネスロジックが含まれるか?
    → YES → Unit テスト (lib/)

[ ] API の入力バリデーションか?
    → YES → Integration テスト (API route)

[ ] ユーザーの操作に応じた UI 変化か?
    → YES → Integration テスト (RTL)

[ ] 外部ライブラリ (wagmi/viem) の返り値をそのまま返すだけか?
    → YES → テスト不要(ライブラリが保証)

[ ] 重要なユーザーフロー全体か?
    → YES → E2E テスト (Playwright)

テストファイル構成

lib/
├── calculate-settlement.test.ts   # Unit: 精算計算ロジック
├── format-currency.test.ts        # Unit: 通貨フォーマット
└── parse-web3-error.test.ts       # Unit: Web3 エラーパース

hooks/
├── use-group.test.ts              # Integration: データ変換ロジックあり
├── use-expenses.test.ts           # Integration: データ変換ロジックあり
└── use-settlement.test.ts         # Integration: 計算ロジックあり
# use-token-balance, use-gas-estimate, use-token-transfer → 削除 or 最小化

components/
├── group/GroupForm.test.tsx        # Integration: ユーザー操作
├── expense/ExpenseForm.test.tsx    # Integration: ユーザー操作
└── settlement/SettlementList.test.tsx  # Integration: 表示ロジック

app/api/
├── groups/route.test.ts           # Integration: バリデーション + DB境界
├── groups/[id]/expenses/route.test.ts
└── groups/[id]/route.test.ts

test/e2e/
├── create-group.spec.ts           # E2E: グループ作成フロー
├── add-expense.spec.ts            # E2E: 費用追加フロー
├── settlement-flow.spec.ts        # E2E: 精算フロー
└── blockchain/
    └── token-transfer.spec.ts     # E2E: Sepolia 送金

セットアップ

vitest.config.ts

import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
import path from 'path';

export default defineConfig({
  plugins: [react()],
  test: {
    environment: 'jsdom',
    globals: true,
    setupFiles: ['./test/setup.ts'],
    coverage: {
      provider: 'v8',
      thresholds: {
        lines: 80,
        functions: 80,
        branches: 80,
      },
      exclude: [
        'node_modules/**',
        'test/**',
        '**/*.config.*',
        'hooks/use-token-balance.ts',  // wagmi ラッパー
        'hooks/use-gas-estimate.ts',   // viem ラッパー
      ],
    },
  },
  resolve: {
    alias: { '@': path.resolve(__dirname, './') },
  },
});

test/setup.ts

import '@testing-library/jest-dom';
import { vi } from 'vitest';

// wagmi: デフォルトモック(各テストで必要に応じてオーバーライド)
vi.mock('wagmi', async () => {
  const actual = await vi.importActual('wagmi');
  return {
    ...actual,
    useAccount: vi.fn(() => ({ address: undefined, isConnected: false })),
    useWriteContract: vi.fn(() => ({
      writeContract: vi.fn(),
      data: undefined,
      isPending: false,
      error: null,
    })),
    useWaitForTransactionReceipt: vi.fn(() => ({
      isLoading: false,
      isSuccess: false,
      data: undefined,
    })),
    useReadContract: vi.fn(() => ({ data: undefined, isLoading: false })),
  };
});

vi.mock('@privy-io/react-auth', () => ({
  usePrivy: vi.fn(() => ({ login: vi.fn(), authenticated: false, user: null })),
  PrivyProvider: ({ children }: { children: React.ReactNode }) => children,
}));

Object.defineProperty(window, 'matchMedia', {
  writable: true,
  value: vi.fn().mockImplementation((query: string) => ({
    matches: false,
    media: query,
    onchange: null,
    addEventListener: vi.fn(),
    removeEventListener: vi.fn(),
    dispatchEvent: vi.fn(),
  })),
});

実行コマンド

bunx vitest run               # ユニット + インテグレーション (⚠️ bun test は不可)
bunx vitest run --coverage    # カバレッジ確認(80%+ 必須)
bun run test:e2e              # E2E (UI フロー)
bun run test:e2e:blockchain   # E2E (Sepolia 実トランザクション)

ブラウザ確認 (Playwright MCP)

Claude Code が Playwright MCP を使って実ブラウザで目視確認する。 テストを書く前・修正後の探索的確認に使う。

# 1. 開発サーバーを起動
bun dev

# 2. Claude Code が自律的に以下を実行
#    playwright_navigate → http://localhost:3000
#    playwright_screenshot → 画面確認
#    playwright_console_logs → エラーチェック

詳細: docs/browser-verification.md


アンチパターン

やってはいけないこと

// ❌ wagmi の状態ミラーをテストしている
it('isPending が true になる', () => {
  vi.mocked(useWriteContract).mockReturnValue({ isPending: true, ... });
  const { result } = renderHook(() => useTokenTransfer());
  expect(result.current.isPending).toBe(true); // wagmi をテストしているだけ
});

// ✅ 実際の振る舞いをテストする
it('transfer() を呼ぶと writeContract が正しい引数で呼ばれる', async () => {
  const { result } = renderHook(() => useTokenTransfer());
  await act(() => result.current.transfer({ token: 'USDC', amount: '10', ... }));
  expect(mockWriteContract).toHaveBeenCalledWith(
    expect.objectContaining({ functionName: 'transfer', args: [...] })
  );
});
// ❌ 実装詳細(何回呼ばれたか)をテスト
expect(mockFrom).toHaveBeenCalledTimes(2);

// ✅ 結果をテスト
expect(res.status).toBe(201);
expect((await res.json()).name).toBe('Trip');
// ❌ render してスナップショット
expect(container).toMatchSnapshot();

// ✅ ユーザー操作の結果
await user.click(screen.getByRole('button', { name: '作成' }));
expect(mockOnSubmit).toHaveBeenCalled();

CI/CD

# .github/workflows/test.yml
jobs:
  unit-integration:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: oven-sh/setup-bun@v2
      - run: bun install --frozen-lockfile
      - run: bunx vitest run --coverage

  e2e-ui:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: oven-sh/setup-bun@v2
      - run: bun install --frozen-lockfile
      - run: bunx playwright install --with-deps
      - run: bun run test:e2e

  e2e-blockchain:
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main'  # main のみ実行
    steps:
      - uses: actions/checkout@v4
      - uses: oven-sh/setup-bun@v2
      - run: bun install --frozen-lockfile
      - run: bunx playwright install --with-deps
      - name: Sepolia テスト
        env:
          TEST_WALLET_PRIVATE_KEY: ${{ secrets.TEST_WALLET_PRIVATE_KEY }}
          TEST_RECIPIENT_ADDRESS: ${{ secrets.TEST_RECIPIENT_ADDRESS }}
          NEXT_PUBLIC_SEPOLIA_RPC_URL: ${{ secrets.NEXT_PUBLIC_SEPOLIA_RPC_URL }}
        run: bun run test:e2e:blockchain