walipay のテスト設計方針。AI 駆動開発 (AIDD) における実践的 TDD。
「実装の詳細ではなく、振る舞いをテストする」 「wagmi をテストしない。wagmi を使ったビジネスロジックをテストする」
AI 駆動開発では、AI がテストを自動生成しやすい。しかし「1ファイル = 1テスト」の機械的な適用は モックをテストするテスト を生み出し、維持コストだけが高い脆弱なテスト群になる。
テストは「本番で壊れる可能性があるもの」だけを検証する。
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// 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 が見落としがちなもの)
モックは 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が何回呼ばれたか等)はテストしない
「何が 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乱用禁止)
対象: 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 テスト → しない(コストが高すぎる)
- ハッピーパスのみ。エッジケースはユニット/インテグレーションで
- ページ遷移・データ永続化を確認する唯一の層
実際のトランザクションを実行。事前準備が必須。
事前準備:
# .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 から注入
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 送金
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, './') },
},
});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 実トランザクション)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();# .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