From 2c1ac843ade7dbbf19d5f361389eddc15943d59f Mon Sep 17 00:00:00 2001 From: keito4 Date: Thu, 5 Mar 2026 09:58:34 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20=E5=8C=85=E6=8B=AC=E7=9A=84?= =?UTF-8?q?=E3=83=86=E3=82=B9=E3=83=88=E3=83=86=E3=83=B3=E3=83=97=E3=83=AC?= =?UTF-8?q?=E3=83=BC=E3=83=88=EF=BC=8821=E7=A8=AE=E9=A1=9E=EF=BC=89?= =?UTF-8?q?=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Next.js プロジェクト向けの包括的なテスト設定テンプレートを追加。 cyber_ace_1on1 で実績のある設定を他のリポジトリに適用可能に。 ## 追加テスト種別(21種類) ### 基本テスト - Unit, Component, Snapshot, Integration, E2E ### 品質保証テスト - API, Regression, Smoke, Contract, Scenario, Visual, A11y ### 高度なテスト - Property-based (fast-check), Mutation (Stryker) ### パフォーマンス・負荷テスト - Performance (Lighthouse), Load (k6/Artillery) ### セキュリティ・インフラテスト - Security, Database, Edge Functions ### 国際化・SSRテスト - i18n, SSR/Hydration ## レベル構成(5段階) - minimal: Unit + Component + Snapshot - standard: + Integration + E2E + API - comprehensive: + Regression + Smoke + Contract - full: + Visual + A11y + Scenario + Property-based - enterprise: + Performance + Load + Security + DB + Edge + i18n + SSR + Mutation ## 含まれるファイル - /setup-tests コマンド - Jest/Playwright 設定ファイル - 21種類のサンプルテスト - CI/CD ワークフローテンプレート - README ドキュメント 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .claude/commands/setup-tests.md | 609 ++++++++++++++++++ templates/testing/README.md | 492 ++++++++++++++ templates/testing/ci-test-jobs.yml | 154 +++++ templates/testing/examples/a11y.spec.ts | 201 ++++++ templates/testing/examples/api-route.test.ts | 81 +++ templates/testing/examples/api.test.ts | 412 ++++++++++++ templates/testing/examples/component.test.tsx | 65 ++ templates/testing/examples/contract.test.ts | 281 ++++++++ templates/testing/examples/database.test.ts | 230 +++++++ templates/testing/examples/e2e-auth.spec.ts | 85 +++ .../testing/examples/edge-functions.test.ts | 212 ++++++ templates/testing/examples/hook.test.ts | 90 +++ templates/testing/examples/i18n.test.tsx | 404 ++++++++++++ .../testing/examples/integration.test.ts | 108 ++++ templates/testing/examples/load.test.ts | 241 +++++++ templates/testing/examples/mutation.config.js | 84 +++ .../testing/examples/performance.spec.ts | 267 ++++++++ .../testing/examples/property-based.test.ts | 283 ++++++++ .../testing/examples/regression-auth.spec.ts | 143 ++++ templates/testing/examples/security.test.ts | 275 ++++++++ templates/testing/examples/smoke.test.ts | 90 +++ templates/testing/examples/snapshot.test.tsx | 237 +++++++ .../testing/examples/ssr-hydration.spec.ts | 355 ++++++++++ templates/testing/examples/visual.spec.ts | 136 ++++ templates/testing/jest.config.js | 59 ++ templates/testing/jest.polyfills.js | 229 +++++++ templates/testing/jest.regression.config.js | 29 + templates/testing/jest.scenario.config.js | 29 + templates/testing/jest.setup.js | 55 ++ templates/testing/playwright.config.ts | 66 ++ .../testing/playwright.regression.config.ts | 30 + 31 files changed, 6032 insertions(+) create mode 100644 .claude/commands/setup-tests.md create mode 100644 templates/testing/README.md create mode 100644 templates/testing/ci-test-jobs.yml create mode 100644 templates/testing/examples/a11y.spec.ts create mode 100644 templates/testing/examples/api-route.test.ts create mode 100644 templates/testing/examples/api.test.ts create mode 100644 templates/testing/examples/component.test.tsx create mode 100644 templates/testing/examples/contract.test.ts create mode 100644 templates/testing/examples/database.test.ts create mode 100644 templates/testing/examples/e2e-auth.spec.ts create mode 100644 templates/testing/examples/edge-functions.test.ts create mode 100644 templates/testing/examples/hook.test.ts create mode 100644 templates/testing/examples/i18n.test.tsx create mode 100644 templates/testing/examples/integration.test.ts create mode 100644 templates/testing/examples/load.test.ts create mode 100644 templates/testing/examples/mutation.config.js create mode 100644 templates/testing/examples/performance.spec.ts create mode 100644 templates/testing/examples/property-based.test.ts create mode 100644 templates/testing/examples/regression-auth.spec.ts create mode 100644 templates/testing/examples/security.test.ts create mode 100644 templates/testing/examples/smoke.test.ts create mode 100644 templates/testing/examples/snapshot.test.tsx create mode 100644 templates/testing/examples/ssr-hydration.spec.ts create mode 100644 templates/testing/examples/visual.spec.ts create mode 100644 templates/testing/jest.config.js create mode 100644 templates/testing/jest.polyfills.js create mode 100644 templates/testing/jest.regression.config.js create mode 100644 templates/testing/jest.scenario.config.js create mode 100644 templates/testing/jest.setup.js create mode 100644 templates/testing/playwright.config.ts create mode 100644 templates/testing/playwright.regression.config.ts diff --git a/.claude/commands/setup-tests.md b/.claude/commands/setup-tests.md new file mode 100644 index 00000000..b28cdfc1 --- /dev/null +++ b/.claude/commands/setup-tests.md @@ -0,0 +1,609 @@ +--- +description: Setup comprehensive testing infrastructure for Next.js projects +allowed-tools: Read, Write, Edit, Bash(git:*), Bash(npm:*), Bash(pnpm:*), Bash(npx:*), Bash(node:*), Bash(ls:*), Bash(mkdir:*), Bash(cp:*), Task, Glob, Grep +argument-hint: '[--level minimal|standard|comprehensive|full] [--coverage-threshold NUMBER] [--dry-run]' +--- + +# Setup Tests Command + +Next.js プロジェクトに包括的なテスト基盤をセットアップします。 +cyber_ace_1on1 で実績のある設定を他のリポジトリに適用できます。 + +## テストピラミッド + +``` + /\ + / \ E2E Tests (少数、遅い) + /----\ + / \ Regression Tests + /--------\ + / \ Integration Tests + /------------\ + / \ Component Tests + /----------------\ + / \ Unit Tests (多数、速い) + /____________________\ +``` + +## テスト種別一覧(21種類) + +### 基本テスト + +| 種別 | ツール | 目的 | ファイル配置 | +| --------------- | ---------------------- | -------------------- | --------------------------------- | +| **Unit** | Jest | 個別関数・モジュール | `__tests__/*.test.ts` | +| **Component** | Jest + Testing Library | UIコンポーネント | `components/__tests__/*.test.tsx` | +| **Integration** | Jest | 複数サービス連携 | `tests/integration/*.test.ts` | +| **E2E** | Playwright | ユーザーフロー | `tests/e2e/*.spec.ts` | + +### 品質保証テスト + +| 種別 | ツール | 目的 | ファイル配置 | +| -------------- | --------------------- | ------------------------ | ------------------------------------ | +| **Regression** | Jest + Playwright | リグレッション防止 | `tests/regression/*` | +| **Smoke** | Jest | 基本動作確認 | `tests/regression/api-smoke.test.ts` | +| **Scenario** | Jest | ビジネスシナリオ | `tests/scenario/*.test.ts` | +| **Visual** | Playwright | ビジュアルリグレッション | `tests/visual/*.spec.ts` | +| **A11y** | Playwright + axe-core | アクセシビリティ | `tests/a11y/*.spec.ts` | + +### 高度なテスト + +| 種別 | ツール | 目的 | ファイル配置 | +| ------------------ | ---------- | ------------------ | ------------------------------------------ | +| **Contract** | Jest | API契約検証 | `tests/contract/*.test.ts` | +| **Snapshot** | Jest | UIスナップショット | `components/__tests__/*.snapshot.test.tsx` | +| **Property-based** | fast-check | ランダム入力検証 | `tests/property/*.test.ts` | +| **Mutation** | Stryker | テスト品質検証 | `stryker.conf.js` | + +### パフォーマンス・負荷テスト + +| 種別 | ツール | 目的 | ファイル配置 | +| --------------- | ----------------------- | --------------- | ----------------------------- | +| **Performance** | Playwright + Lighthouse | Core Web Vitals | `tests/performance/*.spec.ts` | +| **Load** | k6 / Artillery | 負荷耐性 | `tests/load/*.js` | + +### セキュリティ・インフラテスト + +| 種別 | ツール | 目的 | ファイル配置 | +| ------------------ | ---------------------- | ------------------------- | ------------------------------------ | +| **Security** | Jest | セキュリティヘッダー・XSS | `tests/security/*.test.ts` | +| **Database** | Jest + Supabase | マイグレーション・RLS | `tests/database/*.test.ts` | +| **Edge Functions** | Deno / Jest | Edge Functions検証 | `supabase/functions/*/index.test.ts` | +| **API** | Jest + node-mocks-http | REST API検証 | `tests/api/*.test.ts` | + +### 国際化・SSRテスト + +| 種別 | ツール | 目的 | ファイル配置 | +| ----------------- | ---------------------- | --------------------- | ----------------------- | +| **i18n** | Jest + Testing Library | 多言語対応 | `tests/i18n/*.test.tsx` | +| **SSR/Hydration** | Playwright | SSR・ハイドレーション | `tests/ssr/*.spec.ts` | + +## Step 1: Parse Arguments + +引数から設定を読み取る: + +- `--level LEVEL`: テストレベル(デフォルト: `standard`) + - `minimal`: Unit + Component + Snapshot のみ + - `standard`: minimal + Integration + E2E + API + - `comprehensive`: standard + Regression + Smoke + Contract + - `full`: comprehensive + Visual + A11y + Scenario + Property-based + - `enterprise`: full + Performance + Load + Security + Database + Edge + i18n + SSR + Mutation +- `--coverage-threshold NUMBER`: カバレッジ閾値(デフォルト: 70) +- `--dry-run`: 変更を適用せず、差分のみ表示 + +## Step 2: Detect Project Structure + +プロジェクト構造を検出: + +```bash +# ファイル存在確認 +ls -la package.json next.config.* tsconfig.json 2>/dev/null + +# 既存のテスト設定確認 +ls -la jest.config.* playwright.config.* vitest.config.* 2>/dev/null + +# パッケージマネージャー検出 +ls -la package-lock.json pnpm-lock.yaml yarn.lock bun.lockb 2>/dev/null +``` + +### 検出結果を表示: + +``` +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +🔍 Project Detection +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +Framework: Next.js +Package Manager: {npm|pnpm|yarn|bun} +TypeScript: {Yes|No} +Existing Tests: {Jest|Playwright|Vitest|None} + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +``` + +## Step 3: Install Dependencies + +### レベル別依存パッケージ + +#### Minimal Level + +```bash +npm install -D \ + jest @jest/types jest-environment-jsdom \ + @testing-library/react @testing-library/jest-dom @testing-library/user-event \ + @types/jest ts-jest \ + @faker-js/faker +``` + +#### Standard Level (Minimal +) + +```bash +npm install -D @playwright/test node-mocks-http @types/node-mocks-http + +# ブラウザインストール +npx playwright install --with-deps chromium +``` + +#### Comprehensive Level (Standard +) + +```bash +# 追加の設定ファイルのみ(追加パッケージなし) +``` + +#### Full Level (Comprehensive +) + +```bash +npm install -D @axe-core/playwright fast-check +``` + +#### Enterprise Level (Full +) + +```bash +# パフォーマンス・負荷テスト +npm install -D lighthouse +# k6 は別途インストール: https://k6.io/docs/getting-started/installation/ + +# ミューテーションテスト +npm install -D @stryker-mutator/core @stryker-mutator/jest-runner @stryker-mutator/typescript-checker + +# セキュリティ・i18n(追加設定ファイルのみ) +``` + +## Step 4: Create Configuration Files + +### 4.1 基本設定(全レベル共通) + +テンプレートからコピー: + +- `jest.config.js` - Jest 基本設定 +- `jest.setup.js` - テスト前セットアップ +- `jest.polyfills.js` - Web API ポリフィル + +### 4.2 Standard Level 追加 + +- `playwright.config.ts` - Playwright E2E 設定 + +### 4.3 Comprehensive Level 追加 + +- `jest.regression.config.js` - リグレッションテスト用 Jest 設定 +- `playwright.regression.config.ts` - リグレッションテスト用 Playwright 設定 + +### 4.4 Full Level 追加 + +- `jest.scenario.config.js` - シナリオテスト用 Jest 設定 + +## Step 5: Update package.json Scripts + +### Minimal Level + +```json +{ + "scripts": { + "test": "jest", + "test:watch": "jest --watch", + "test:coverage": "jest --coverage", + "test:ci": "jest --coverage --watchAll=false" + } +} +``` + +### Standard Level (+) + +```json +{ + "scripts": { + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui", + "test:e2e:headed": "playwright test --headed", + "test:all": "npm run test && npm run test:e2e" + } +} +``` + +### Comprehensive Level (+) + +```json +{ + "scripts": { + "test:regression": "npm run test:regression:api && npm run test:regression:e2e", + "test:regression:api": "jest --config jest.regression.config.js", + "test:regression:e2e": "playwright test --config playwright.regression.config.ts", + "test:smoke": "jest --config jest.regression.config.js tests/regression/api-smoke.test.ts" + } +} +``` + +### Full Level (+) + +```json +{ + "scripts": { + "test:scenario": "jest --config jest.scenario.config.js --runInBand", + "test:visual": "playwright test tests/visual/", + "test:visual:update": "playwright test tests/visual/ --update-snapshots", + "test:a11y": "playwright test tests/a11y/", + "test:property": "jest tests/property/" + } +} +``` + +### Enterprise Level (+) + +```json +{ + "scripts": { + "test:performance": "playwright test tests/performance/", + "test:load": "k6 run tests/load/api-load.js", + "test:security": "jest tests/security/", + "test:database": "jest tests/database/", + "test:edge": "cd supabase/functions && deno test --allow-all", + "test:api": "jest tests/api/", + "test:i18n": "jest tests/i18n/", + "test:ssr": "playwright test tests/ssr/", + "test:mutation": "stryker run", + "test:contract": "jest tests/contract/" + } +} +``` + +## Step 6: Create Directory Structure + +### Minimal Level + +```bash +mkdir -p __tests__ +mkdir -p components/__tests__ +mkdir -p hooks/__tests__ +mkdir -p lib/__tests__ +``` + +### Standard Level (+) + +```bash +mkdir -p tests/e2e +mkdir -p tests/integration +``` + +### Comprehensive Level (+) + +```bash +mkdir -p tests/regression +mkdir -p tests/regression/helpers +``` + +### Full Level (+) + +```bash +mkdir -p tests/scenario +mkdir -p tests/visual +mkdir -p tests/a11y +mkdir -p tests/property +``` + +### Enterprise Level (+) + +```bash +mkdir -p tests/performance +mkdir -p tests/load +mkdir -p tests/security +mkdir -p tests/database +mkdir -p tests/api +mkdir -p tests/i18n +mkdir -p tests/ssr +mkdir -p tests/contract +``` + +## Step 7: Create Sample Tests + +テンプレートから配置(レベルに応じて): + +| レベル | サンプル | +| ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Minimal | `api-route.test.ts`, `component.test.tsx`, `hook.test.ts`, `snapshot.test.tsx` | +| Standard | + `e2e-auth.spec.ts`, `api.test.ts` | +| Comprehensive | + `smoke.test.ts`, `regression-auth.spec.ts`, `integration.test.ts`, `contract.test.ts` | +| Full | + `visual.spec.ts`, `a11y.spec.ts`, `property-based.test.ts` | +| Enterprise | + `performance.spec.ts`, `load.test.ts`, `security.test.ts`, `database.test.ts`, `edge-functions.test.ts`, `i18n.test.tsx`, `ssr-hydration.spec.ts`, `mutation.config.js` | + +## Step 8: Update CI Workflow + +`.github/workflows/ci.yml` に追加: + +### Minimal Level + +```yaml +unit-tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'npm' + - run: npm ci + - run: npm run test:ci + - uses: codecov/codecov-action@v4 +``` + +### Standard Level (+) + +```yaml +e2e-tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + - run: npm ci + - run: npx playwright install --with-deps chromium + - run: npm run build + - run: | + npm start & + sleep 10 + npm run test:e2e +``` + +### Comprehensive Level (+) + +```yaml +regression-tests: + runs-on: ubuntu-latest + if: github.base_ref == 'main' || github.base_ref == 'production' + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + - run: npm ci + - run: npx playwright install --with-deps chromium + - run: npm run build + - run: | + npm start & + sleep 10 + npm run test:regression +``` + +### Full Level (+) + +```yaml +visual-tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + - run: npm ci + - run: npx playwright install --with-deps + - run: npm run build + - run: npm run test:visual + - uses: actions/upload-artifact@v4 + if: failure() + with: + name: visual-diff + path: test-results/ + +a11y-tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + - run: npm ci + - run: npx playwright install --with-deps chromium + - run: npm run build + - run: npm run test:a11y +``` + +## Step 9: Update .gitignore + +``` +# Test coverage +coverage/ + +# Playwright +playwright-report/ +playwright/.cache/ +test-results/ + +# Visual test snapshots (optional - commit these for CI) +# tests/visual/*.spec.ts-snapshots/ +``` + +## Step 10: Generate Summary + +``` +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +✅ Test Setup Complete +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +Level: {level} +Coverage Threshold: {threshold}% + +Test Types Configured: +✅ Unit Tests +✅ Component Tests +✅ Snapshot Tests +{conditional} Integration Tests +{conditional} E2E Tests +{conditional} API Tests +{conditional} Regression Tests +{conditional} Smoke Tests +{conditional} Contract Tests +{conditional} Scenario Tests +{conditional} Visual Tests +{conditional} Accessibility Tests +{conditional} Property-based Tests +{conditional} Performance Tests +{conditional} Load Tests +{conditional} Security Tests +{conditional} Database Tests +{conditional} Edge Functions Tests +{conditional} i18n Tests +{conditional} SSR/Hydration Tests +{conditional} Mutation Tests + +Commands: +• npm test - Run unit/component tests +• npm run test:coverage - With coverage +• npm run test:e2e - Run E2E tests +• npm run test:api - Run API tests +• npm run test:regression - Run regression tests +• npm run test:visual - Run visual tests +• npm run test:a11y - Run accessibility tests +• npm run test:performance - Run performance tests +• npm run test:load - Run load tests +• npm run test:security - Run security tests +• npm run test:mutation - Run mutation tests +• npm run test:all - Run all tests + +Next Steps: +1. Run `npm test` to verify Jest setup +2. Run `npm run test:e2e` to verify Playwright setup +3. Configure CODECOV_TOKEN secret in GitHub +4. Review and customize test configurations + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +``` + +## レベル比較表 + +| テスト種別 | Minimal | Standard | Comprehensive | Full | Enterprise | +| -------------- | :-----: | :------: | :-----------: | :--: | :--------: | +| Unit | ✅ | ✅ | ✅ | ✅ | ✅ | +| Component | ✅ | ✅ | ✅ | ✅ | ✅ | +| Snapshot | ✅ | ✅ | ✅ | ✅ | ✅ | +| Integration | ❌ | ✅ | ✅ | ✅ | ✅ | +| E2E | ❌ | ✅ | ✅ | ✅ | ✅ | +| API | ❌ | ✅ | ✅ | ✅ | ✅ | +| Regression | ❌ | ❌ | ✅ | ✅ | ✅ | +| Smoke | ❌ | ❌ | ✅ | ✅ | ✅ | +| Contract | ❌ | ❌ | ✅ | ✅ | ✅ | +| Scenario | ❌ | ❌ | ❌ | ✅ | ✅ | +| Visual | ❌ | ❌ | ❌ | ✅ | ✅ | +| A11y | ❌ | ❌ | ❌ | ✅ | ✅ | +| Property-based | ❌ | ❌ | ❌ | ✅ | ✅ | +| Performance | ❌ | ❌ | ❌ | ❌ | ✅ | +| Load | ❌ | ❌ | ❌ | ❌ | ✅ | +| Security | ❌ | ❌ | ❌ | ❌ | ✅ | +| Database | ❌ | ❌ | ❌ | ❌ | ✅ | +| Edge Functions | ❌ | ❌ | ❌ | ❌ | ✅ | +| i18n | ❌ | ❌ | ❌ | ❌ | ✅ | +| SSR/Hydration | ❌ | ❌ | ❌ | ❌ | ✅ | +| Mutation | ❌ | ❌ | ❌ | ❌ | ✅ | + +## テンプレート一覧 + +| ファイル | 説明 | +| --------------------------------- | -------------------------------------- | +| `jest.config.js` | Jest 設定(Next.js 対応) | +| `jest.setup.js` | テスト前セットアップ(モック設定) | +| `jest.polyfills.js` | Web API ポリフィル | +| `jest.regression.config.js` | リグレッションテスト用 Jest 設定 | +| `jest.scenario.config.js` | シナリオテスト用 Jest 設定 | +| `playwright.config.ts` | Playwright E2E 設定 | +| `playwright.regression.config.ts` | リグレッションテスト用 Playwright 設定 | +| `ci-test-jobs.yml` | GitHub Actions テストジョブ | + +### サンプルテスト(21種類) + +#### 基本テスト + +| ファイル | 説明 | +| ----------------------------- | ---------------------- | +| `examples/api-route.test.ts` | API ルートテスト例 | +| `examples/component.test.tsx` | コンポーネントテスト例 | +| `examples/hook.test.ts` | フックテスト例 | +| `examples/e2e-auth.spec.ts` | E2E 認証テスト例 | + +#### 品質保証テスト + +| ファイル | 説明 | +| ---------------------------------- | -------------------------------- | +| `examples/smoke.test.ts` | スモークテスト例 | +| `examples/integration.test.ts` | 統合テスト例 | +| `examples/regression-auth.spec.ts` | リグレッションテスト例 | +| `examples/visual.spec.ts` | ビジュアルリグレッションテスト例 | +| `examples/a11y.spec.ts` | アクセシビリティテスト例 | + +#### 高度なテスト + +| ファイル | 説明 | +| --------------------------------- | ---------------------------------------- | +| `examples/contract.test.ts` | API契約テスト例 | +| `examples/snapshot.test.tsx` | スナップショットテスト例 | +| `examples/property-based.test.ts` | Property-basedテスト例(fast-check使用) | +| `examples/mutation.config.js` | ミューテーションテスト設定(Stryker) | + +#### パフォーマンス・負荷テスト + +| ファイル | 説明 | +| ------------------------------ | ----------------------------------------- | +| `examples/performance.spec.ts` | パフォーマンステスト例(Core Web Vitals) | +| `examples/load.test.ts` | 負荷テスト例(k6/Artillery設定含む) | + +#### セキュリティ・インフラテスト + +| ファイル | 説明 | +| --------------------------------- | --------------------------------------------- | +| `examples/security.test.ts` | セキュリティテスト例 | +| `examples/database.test.ts` | データベーステスト例(マイグレーション・RLS) | +| `examples/edge-functions.test.ts` | Edge Functionsテスト例(Deno/Jest) | +| `examples/api.test.ts` | REST APIエンドポイントテスト例 | + +#### 国際化・SSRテスト + +| ファイル | 説明 | +| -------------------------------- | ---------------------------- | +| `examples/i18n.test.tsx` | 国際化テスト例 | +| `examples/ssr-hydration.spec.ts` | SSR/ハイドレーションテスト例 | + +## Related Commands + +| コマンド | 説明 | +| ---------------------- | -------------------------------- | +| `/setup-ci` | CI/CD ワークフロー設定 | +| `/setup-husky` | Git hooks 設定(テスト実行含む) | +| `/test-coverage-trend` | カバレッジ推移の確認 | +| `/pre-pr-checklist` | PR 作成前チェック | + +## トラブルシューティング + +### Jest が動かない + +```bash +npx jest --clearCache +npx jest --showConfig +``` + +### Playwright ブラウザエラー + +```bash +npx playwright install --with-deps +``` + +### ビジュアルテストのスナップショット更新 + +```bash +npx playwright test tests/visual/ --update-snapshots +``` + +### A11y テストの特定ルール無視 + +```typescript +const results = await new AxeBuilder({ page }).exclude('#dynamic-content').disableRules(['color-contrast']).analyze(); +``` diff --git a/templates/testing/README.md b/templates/testing/README.md new file mode 100644 index 00000000..be43ce82 --- /dev/null +++ b/templates/testing/README.md @@ -0,0 +1,492 @@ +# Testing Templates + +Next.js プロジェクト向けの包括的なテスト設定テンプレート集です。 +cyber_ace_1on1 で実績のある設定を他のリポジトリに適用できます。 + +## テストピラミッド + +``` + /\ + / \ E2E Tests (少数、遅い、高コスト) + /----\ + / \ Regression Tests + /--------\ + / \ Integration Tests + /------------\ + / \ Component Tests + /----------------\ + / \ Unit Tests (多数、速い、低コスト) + /____________________\ +``` + +## テスト種別(21種類) + +### 基本テスト + +| 種別 | ツール | 目的 | 実行頻度 | +| --------------- | ---------------------- | -------------------------- | ---------- | +| **Unit** | Jest | 個別関数・モジュールの検証 | 毎コミット | +| **Component** | Jest + Testing Library | UIコンポーネントの検証 | 毎コミット | +| **Snapshot** | Jest | UIスナップショットの検証 | 毎コミット | +| **Integration** | Jest | 複数サービスの連携検証 | 毎PR | +| **E2E** | Playwright | ユーザーフロー全体の検証 | 毎PR | + +### 品質保証テスト + +| 種別 | ツール | 目的 | 実行頻度 | +| -------------- | ---------------------- | -------------------------- | ------------------ | +| **API** | Jest + node-mocks-http | REST APIエンドポイント検証 | 毎PR | +| **Regression** | Jest + Playwright | 既知バグの再発防止 | main/production PR | +| **Smoke** | Jest | 基本動作確認(デプロイ後) | デプロイ後 | +| **Contract** | Jest | API契約の検証 | main/production PR | +| **Scenario** | Jest | ビジネスシナリオの検証 | 主要機能変更時 | +| **Visual** | Playwright | UI見た目の変更検出 | UI変更時 | +| **A11y** | Playwright + axe-core | アクセシビリティ準拠 | UI変更時 | + +### 高度なテスト + +| 種別 | ツール | 目的 | 実行頻度 | +| ------------------ | ---------- | ---------------------- | -------------- | +| **Property-based** | fast-check | ランダム入力による検証 | 主要機能変更時 | +| **Mutation** | Stryker | テスト品質の検証 | 週次/手動 | + +### パフォーマンス・負荷テスト + +| 種別 | ツール | 目的 | 実行頻度 | +| --------------- | ----------------------- | ------------------- | --------------- | +| **Performance** | Playwright + Lighthouse | Core Web Vitals測定 | リリース前 | +| **Load** | k6 / Artillery | 負荷耐性検証 | リリース前/手動 | + +### セキュリティ・インフラテスト + +| 種別 | ツール | 目的 | 実行頻度 | +| ------------------ | --------------- | ----------------------------- | -------- | +| **Security** | Jest | セキュリティヘッダー・XSS対策 | 毎PR | +| **Database** | Jest + Supabase | マイグレーション・RLS検証 | 毎PR | +| **Edge Functions** | Deno / Jest | Supabase Edge Functions検証 | 毎PR | + +### 国際化・SSRテスト + +| 種別 | ツール | 目的 | 実行頻度 | +| ----------------- | ---------------------- | ------------------------- | ---------- | +| **i18n** | Jest + Testing Library | 多言語対応の検証 | 翻訳変更時 | +| **SSR/Hydration** | Playwright | SSR・ハイドレーション検証 | UI変更時 | + +## 含まれるファイル + +### 設定ファイル + +| ファイル | レベル | 説明 | +| --------------------------------- | ------------- | -------------------------------- | +| `jest.config.js` | Minimal | Jest 基本設定(Next.js 対応) | +| `jest.setup.js` | Minimal | テスト前セットアップ | +| `jest.polyfills.js` | Minimal | Web API ポリフィル | +| `playwright.config.ts` | Standard | Playwright E2E 設定 | +| `jest.regression.config.js` | Comprehensive | リグレッション用 Jest 設定 | +| `playwright.regression.config.ts` | Comprehensive | リグレッション用 Playwright 設定 | +| `jest.scenario.config.js` | Full | シナリオテスト用 Jest 設定 | +| `ci-test-jobs.yml` | Standard | GitHub Actions テストジョブ | + +### サンプルテスト(21種類) + +#### 基本テスト + +| ファイル | レベル | 説明 | +| ------------------------------ | -------- | ------------------------ | +| `examples/api-route.test.ts` | Minimal | API ルートテスト例 | +| `examples/component.test.tsx` | Minimal | コンポーネントテスト例 | +| `examples/hook.test.ts` | Minimal | カスタムフックテスト例 | +| `examples/snapshot.test.tsx` | Minimal | スナップショットテスト例 | +| `examples/e2e-auth.spec.ts` | Standard | E2E 認証フローテスト例 | +| `examples/integration.test.ts` | Standard | 統合テスト例 | + +#### 品質保証テスト + +| ファイル | レベル | 説明 | +| ---------------------------------- | ------------- | -------------------------- | +| `examples/api.test.ts` | Standard | REST APIテスト例 | +| `examples/smoke.test.ts` | Comprehensive | スモークテスト例 | +| `examples/regression-auth.spec.ts` | Comprehensive | リグレッションテスト例 | +| `examples/contract.test.ts` | Comprehensive | API契約テスト例 | +| `examples/visual.spec.ts` | Full | ビジュアルリグレッション例 | +| `examples/a11y.spec.ts` | Full | アクセシビリティテスト例 | + +#### 高度なテスト + +| ファイル | レベル | 説明 | +| --------------------------------- | ---------- | -------------------------- | +| `examples/property-based.test.ts` | Full | Property-basedテスト例 | +| `examples/mutation.config.js` | Enterprise | ミューテーションテスト設定 | + +#### パフォーマンス・負荷テスト + +| ファイル | レベル | 説明 | +| ------------------------------ | ---------- | ---------------------------- | +| `examples/performance.spec.ts` | Enterprise | Core Web Vitalsテスト例 | +| `examples/load.test.ts` | Enterprise | 負荷テスト例(k6/Artillery) | + +#### セキュリティ・インフラテスト + +| ファイル | レベル | 説明 | +| --------------------------------- | ---------- | ---------------------- | +| `examples/security.test.ts` | Enterprise | セキュリティテスト例 | +| `examples/database.test.ts` | Enterprise | データベーステスト例 | +| `examples/edge-functions.test.ts` | Enterprise | Edge Functionsテスト例 | + +#### 国際化・SSRテスト + +| ファイル | レベル | 説明 | +| -------------------------------- | ---------- | ---------------------------- | +| `examples/i18n.test.tsx` | Enterprise | 国際化テスト例 | +| `examples/ssr-hydration.spec.ts` | Enterprise | SSR/ハイドレーションテスト例 | + +## 使い方 + +### 方法1: Claude コマンド(推奨) + +```bash +# デフォルト(Standard レベル) +/setup-tests + +# レベル指定 +/setup-tests --level minimal # Unit + Component + Snapshot +/setup-tests --level standard # + Integration + E2E + API +/setup-tests --level comprehensive # + Regression + Smoke + Contract +/setup-tests --level full # + Visual + A11y + Scenario + Property-based +/setup-tests --level enterprise # + Performance + Load + Security + DB + Edge + i18n + SSR + Mutation + +# カバレッジ閾値指定 +/setup-tests --coverage-threshold 80 +``` + +### 方法2: 手動コピー + +```bash +# 基本設定 +cp templates/testing/jest.config.js ./ +cp templates/testing/jest.setup.js ./ +cp templates/testing/jest.polyfills.js ./ +cp templates/testing/playwright.config.ts ./ + +# Comprehensive レベル追加 +cp templates/testing/jest.regression.config.js ./ +cp templates/testing/playwright.regression.config.ts ./ + +# Full レベル追加 +cp templates/testing/jest.scenario.config.js ./ +``` + +## レベル比較表 + +| テスト種別 | Minimal | Standard | Comprehensive | Full | Enterprise | +| -------------- | :-----: | :------: | :-----------: | :--: | :--------: | +| Unit | ✅ | ✅ | ✅ | ✅ | ✅ | +| Component | ✅ | ✅ | ✅ | ✅ | ✅ | +| Snapshot | ✅ | ✅ | ✅ | ✅ | ✅ | +| Integration | ❌ | ✅ | ✅ | ✅ | ✅ | +| E2E | ❌ | ✅ | ✅ | ✅ | ✅ | +| API | ❌ | ✅ | ✅ | ✅ | ✅ | +| Regression | ❌ | ❌ | ✅ | ✅ | ✅ | +| Smoke | ❌ | ❌ | ✅ | ✅ | ✅ | +| Contract | ❌ | ❌ | ✅ | ✅ | ✅ | +| Scenario | ❌ | ❌ | ❌ | ✅ | ✅ | +| Visual | ❌ | ❌ | ❌ | ✅ | ✅ | +| A11y | ❌ | ❌ | ❌ | ✅ | ✅ | +| Property-based | ❌ | ❌ | ❌ | ✅ | ✅ | +| Performance | ❌ | ❌ | ❌ | ❌ | ✅ | +| Load | ❌ | ❌ | ❌ | ❌ | ✅ | +| Security | ❌ | ❌ | ❌ | ❌ | ✅ | +| Database | ❌ | ❌ | ❌ | ❌ | ✅ | +| Edge Functions | ❌ | ❌ | ❌ | ❌ | ✅ | +| i18n | ❌ | ❌ | ❌ | ❌ | ✅ | +| SSR/Hydration | ❌ | ❌ | ❌ | ❌ | ✅ | +| Mutation | ❌ | ❌ | ❌ | ❌ | ✅ | + +## 依存パッケージ + +### Minimal Level + +```bash +npm install -D \ + jest @jest/types jest-environment-jsdom \ + @testing-library/react @testing-library/jest-dom @testing-library/user-event \ + @types/jest ts-jest \ + @faker-js/faker +``` + +### Standard Level (+) + +```bash +npm install -D @playwright/test node-mocks-http @types/node-mocks-http +npx playwright install --with-deps chromium +``` + +### Full Level (+) + +```bash +npm install -D @axe-core/playwright fast-check +``` + +### Enterprise Level (+) + +```bash +# パフォーマンス・負荷テスト +npm install -D lighthouse +# k6 は別途インストール: https://k6.io/docs/getting-started/installation/ + +# ミューテーションテスト +npm install -D @stryker-mutator/core @stryker-mutator/jest-runner @stryker-mutator/typescript-checker +``` + +## package.json スクリプト + +### 全スクリプト一覧 + +```json +{ + "scripts": { + // Minimal + "test": "jest", + "test:watch": "jest --watch", + "test:coverage": "jest --coverage", + "test:ci": "jest --coverage --watchAll=false", + + // Standard + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui", + "test:e2e:headed": "playwright test --headed", + "test:api": "jest tests/api/", + "test:all": "npm run test && npm run test:e2e", + + // Comprehensive + "test:regression": "npm run test:regression:api && npm run test:regression:e2e", + "test:regression:api": "jest --config jest.regression.config.js", + "test:regression:e2e": "playwright test --config playwright.regression.config.ts", + "test:smoke": "jest --config jest.regression.config.js tests/regression/api-smoke.test.ts", + "test:contract": "jest tests/contract/", + + // Full + "test:scenario": "jest --config jest.scenario.config.js --runInBand", + "test:visual": "playwright test tests/visual/", + "test:visual:update": "playwright test tests/visual/ --update-snapshots", + "test:a11y": "playwright test tests/a11y/", + "test:property": "jest tests/property/", + + // Enterprise + "test:performance": "playwright test tests/performance/", + "test:load": "k6 run tests/load/api-load.js", + "test:security": "jest tests/security/", + "test:database": "jest tests/database/", + "test:edge": "cd supabase/functions && deno test --allow-all", + "test:i18n": "jest tests/i18n/", + "test:ssr": "playwright test tests/ssr/", + "test:mutation": "stryker run" + } +} +``` + +## ディレクトリ構造 + +``` +project/ +├── __tests__/ # ルートレベルの単体テスト +├── app/ +│ └── api/ +│ └── example/ +│ └── __tests__/ # API ルートテスト +│ └── route.test.ts +├── components/ +│ └── __tests__/ # コンポーネントテスト +│ └── Button.test.tsx +├── hooks/ +│ └── __tests__/ # フックテスト +│ └── useCounter.test.ts +├── lib/ +│ └── __tests__/ # ユーティリティテスト +│ └── utils.test.ts +├── supabase/ +│ └── functions/ +│ └── hello-world/ +│ └── index.test.ts # Edge Functions テスト +└── tests/ + ├── e2e/ # E2E テスト + │ └── auth.spec.ts + ├── integration/ # 統合テスト + │ └── user-flow.test.ts + ├── api/ # API テスト + │ └── endpoints.test.ts + ├── regression/ # リグレッションテスト + │ ├── api-smoke.test.ts # スモークテスト + │ ├── auth-regression.spec.ts + │ └── helpers/ + │ └── auth.ts + ├── contract/ # 契約テスト + │ └── api-contract.test.ts + ├── scenario/ # シナリオテスト + │ └── business-flow.test.ts + ├── visual/ # ビジュアルテスト + │ └── pages.spec.ts + ├── a11y/ # アクセシビリティテスト + │ └── pages.spec.ts + ├── property/ # Property-based テスト + │ └── validators.test.ts + ├── performance/ # パフォーマンステスト + │ └── lighthouse.spec.ts + ├── load/ # 負荷テスト + │ ├── api-load.js # k6 スクリプト + │ └── artillery.yml # Artillery 設定 + ├── security/ # セキュリティテスト + │ └── headers.test.ts + ├── database/ # データベーステスト + │ └── migrations.test.ts + ├── i18n/ # 国際化テスト + │ └── translations.test.tsx + └── ssr/ # SSR テスト + └── hydration.spec.ts +``` + +## カスタマイズ + +### カバレッジ閾値の変更 + +`jest.config.js`: + +```javascript +coverageThreshold: { + global: { + branches: 80, + functions: 80, + lines: 80, + statements: 80, + }, +}, +``` + +### モックの追加 + +`jest.setup.js`: + +```javascript +// Supabase クライアントのモック +jest.mock('@supabase/supabase-js', () => ({ + createClient: jest.fn(() => ({ + from: jest.fn(() => ({ + select: jest.fn().mockResolvedValue({ data: [], error: null }), + })), + })), +})); +``` + +### Playwright ブラウザの追加 + +`playwright.config.ts`: + +```typescript +projects: [ + { name: 'chromium', use: { ...devices['Desktop Chrome'] } }, + { name: 'firefox', use: { ...devices['Desktop Firefox'] } }, + { name: 'Mobile Safari', use: { ...devices['iPhone 12'] } }, +], +``` + +### A11y テストのルール調整 + +```typescript +const results = await new AxeBuilder({ page }) + .exclude('#dynamic-content') // 動的コンテンツを除外 + .disableRules(['color-contrast']) // 特定ルールを無効化 + .analyze(); +``` + +## CI 統合 + +### GitHub Actions + +`ci-test-jobs.yml` の内容を `.github/workflows/ci.yml` に組み込みます。 + +```yaml +jobs: + unit-tests: + # Unit + Component tests + e2e-tests: + # E2E tests (PR時) + regression-tests: + # Regression tests (main/production PR時) + visual-tests: + # Visual tests (UI変更時) + a11y-tests: + # Accessibility tests + security-tests: + # Security tests (Enterprise) + performance-tests: + # Performance tests (Enterprise) +``` + +### 実行タイミング + +| テスト | トリガー | +| ----------------------- | ----------------------- | +| Unit/Component/Snapshot | すべての push/PR | +| E2E/API | PR 時 | +| Regression/Contract | main/production への PR | +| Visual/A11y | UI ファイル変更時 | +| Security/Database | セキュリティ関連変更時 | +| Performance/Load | リリース前/手動 | +| Mutation | 週次/手動 | + +## トラブルシューティング + +### Jest が動かない + +```bash +npx jest --clearCache +npx jest --showConfig +``` + +### Playwright ブラウザエラー + +```bash +npx playwright install --with-deps +``` + +### ビジュアルテストのスナップショット更新 + +```bash +npx playwright test tests/visual/ --update-snapshots +``` + +### CI でのみテストが失敗する + +```bash +# ローカルで CI 環境をシミュレート +CI=true npm test +``` + +### k6 のインストール + +```bash +# macOS +brew install k6 + +# Docker +docker run -i grafana/k6 run - { + test.describe('認証ページ', () => { + test('ログインページがWCAG 2.1 AAに準拠している', async ({ page }) => { + await page.goto('/login'); + await page.waitForLoadState('networkidle'); + + const results = await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']).analyze(); + + // 違反がないことを確認 + expect(results.violations).toEqual([]); + }); + + test('新規登録ページがWCAG 2.1 AAに準拠している', async ({ page }) => { + await page.goto('/signup'); + await page.waitForLoadState('networkidle'); + + const results = await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']).analyze(); + + expect(results.violations).toEqual([]); + }); + }); + + test.describe('フォーム要素', () => { + test('すべてのフォーム要素にラベルがある', async ({ page }) => { + await page.goto('/login'); + + // input要素を取得 + const inputs = await page.locator('input').all(); + + for (const input of inputs) { + const id = await input.getAttribute('id'); + const ariaLabel = await input.getAttribute('aria-label'); + const ariaLabelledby = await input.getAttribute('aria-labelledby'); + const placeholder = await input.getAttribute('placeholder'); + + // id に対応する label、または aria-label/aria-labelledby があることを確認 + let hasLabel = false; + + if (id) { + const label = await page.locator(`label[for="${id}"]`); + hasLabel = (await label.count()) > 0; + } + + expect( + hasLabel || ariaLabel || ariaLabelledby, + `Input ${id || 'unknown'} should have a label or aria-label`, + ).toBeTruthy(); + } + }); + + test('フォームエラーがスクリーンリーダーに通知される', async ({ page }) => { + await page.goto('/login'); + + // 空のフォームを送信 + await page.getByRole('button', { name: /ログイン/i }).click(); + + // エラーメッセージが aria-live または role="alert" を持っている + const errorMessage = page.locator('[role="alert"], [aria-live="polite"], [aria-live="assertive"]'); + await expect(errorMessage.first()).toBeVisible(); + }); + }); + + test.describe('キーボードナビゲーション', () => { + test('Tabキーですべてのインタラクティブ要素にアクセスできる', async ({ page }) => { + await page.goto('/login'); + + const interactiveElements = await page + .locator('button, a[href], input, select, textarea, [tabindex]:not([tabindex="-1"])') + .all(); + + // 最初の要素にフォーカス + await page.keyboard.press('Tab'); + + for (let i = 0; i < interactiveElements.length; i++) { + // フォーカスがある要素を取得 + const focusedElement = await page.evaluate(() => { + const el = document.activeElement; + return el ? el.tagName.toLowerCase() : null; + }); + + expect(focusedElement).toBeTruthy(); + + // 次の要素にフォーカスを移動 + await page.keyboard.press('Tab'); + } + }); + + test('Escキーでモーダルを閉じることができる', async ({ page }) => { + await page.goto('/dashboard'); + + // モーダルを開く(例) + await page.getByRole('button', { name: /新規作成/i }).click(); + + // モーダルが表示される + const modal = page.locator('[role="dialog"]'); + await expect(modal).toBeVisible(); + + // Escキーで閉じる + await page.keyboard.press('Escape'); + + // モーダルが閉じる + await expect(modal).not.toBeVisible(); + }); + }); + + test.describe('コントラスト比', () => { + test('テキストのコントラスト比がWCAG AA基準を満たす', async ({ page }) => { + await page.goto('/login'); + + const results = await new AxeBuilder({ page }).withRules(['color-contrast']).analyze(); + + expect(results.violations).toEqual([]); + }); + }); + + test.describe('スクリーンリーダー対応', () => { + test('ページにメインランドマークがある', async ({ page }) => { + await page.goto('/dashboard'); + + const main = await page.locator('main, [role="main"]'); + await expect(main).toBeVisible(); + }); + + test('見出し構造が正しい', async ({ page }) => { + await page.goto('/dashboard'); + + // h1が1つだけ存在する + const h1Count = await page.locator('h1').count(); + expect(h1Count).toBe(1); + + // 見出しレベルがスキップされていない + const headings = await page.locator('h1, h2, h3, h4, h5, h6').all(); + let lastLevel = 0; + + for (const heading of headings) { + const tagName = await heading.evaluate((el) => el.tagName); + const level = parseInt(tagName.replace('H', '')); + + // レベルが1より大きくスキップされていない + expect(level - lastLevel).toBeLessThanOrEqual(1); + lastLevel = level; + } + }); + + test('画像に代替テキストがある', async ({ page }) => { + await page.goto('/dashboard'); + + const images = await page.locator('img').all(); + + for (const img of images) { + const alt = await img.getAttribute('alt'); + const role = await img.getAttribute('role'); + + // alt属性があるか、role="presentation"で装飾画像として扱われている + expect( + alt !== null || role === 'presentation', + 'Images should have alt text or role="presentation"', + ).toBeTruthy(); + } + }); + }); + + test.describe('フォーカス表示', () => { + test('フォーカス状態が視覚的に識別できる', async ({ page }) => { + await page.goto('/login'); + + // 最初のinputにフォーカス + await page.keyboard.press('Tab'); + + const focusedElement = page.locator(':focus'); + const outlineStyle = await focusedElement.evaluate((el) => { + const styles = window.getComputedStyle(el); + return { + outline: styles.outline, + boxShadow: styles.boxShadow, + }; + }); + + // outline または box-shadow でフォーカスが視覚的に表示されている + const hasVisibleFocus = + (outlineStyle.outline && outlineStyle.outline !== 'none') || + (outlineStyle.boxShadow && outlineStyle.boxShadow !== 'none'); + + expect(hasVisibleFocus).toBe(true); + }); + }); +}); diff --git a/templates/testing/examples/api-route.test.ts b/templates/testing/examples/api-route.test.ts new file mode 100644 index 00000000..81f5adb5 --- /dev/null +++ b/templates/testing/examples/api-route.test.ts @@ -0,0 +1,81 @@ +/** + * API Route Test Example + * + * このファイルは Next.js API ルートのテスト例です。 + * app/api/example/__tests__/route.test.ts として配置してください。 + */ + +import { GET, POST } from '../route'; + +// テスト用のモックリクエスト作成ヘルパー +function createMockRequest(method: string, body?: Record, headers?: Record): Request { + const url = 'http://localhost:3000/api/example'; + + return new Request(url, { + method, + headers: { + 'Content-Type': 'application/json', + ...headers, + }, + body: body ? JSON.stringify(body) : undefined, + }); +} + +describe('/api/example', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('GET', () => { + it('should return 200 with data', async () => { + const request = createMockRequest('GET'); + const response = await GET(request); + + expect(response.status).toBe(200); + + const data = await response.json(); + expect(data).toHaveProperty('message'); + }); + + it('should handle errors gracefully', async () => { + // エラーケースのテスト + const request = createMockRequest('GET', undefined, { + 'X-Force-Error': 'true', + }); + + const response = await GET(request); + + expect(response.status).toBe(500); + }); + }); + + describe('POST', () => { + it('should create a resource and return 201', async () => { + const request = createMockRequest('POST', { + name: 'Test Item', + value: 42, + }); + + const response = await POST(request); + + expect(response.status).toBe(201); + + const data = await response.json(); + expect(data).toMatchObject({ + name: 'Test Item', + value: 42, + }); + }); + + it('should return 400 for invalid input', async () => { + const request = createMockRequest('POST', { + // 必須フィールドが欠けている + value: 42, + }); + + const response = await POST(request); + + expect(response.status).toBe(400); + }); + }); +}); diff --git a/templates/testing/examples/api.test.ts b/templates/testing/examples/api.test.ts new file mode 100644 index 00000000..73a29bd9 --- /dev/null +++ b/templates/testing/examples/api.test.ts @@ -0,0 +1,412 @@ +/** + * API Test Example + * + * APIテストは、RESTful APIエンドポイントの + * 動作を包括的に検証するテストです。 + * + * tests/api/endpoints.test.ts として配置してください。 + * + * 依存パッケージ: + * npm install -D supertest @types/supertest + */ + +import { createMocks } from 'node-mocks-http'; +import type { NextApiRequest, NextApiResponse } from 'next'; + +// テスト対象のAPIハンドラ(例) +// import handler from '@/pages/api/users'; + +const BASE_URL = process.env.TEST_BASE_URL || 'http://localhost:3000'; + +describe('API Endpoint Tests', () => { + describe('GET /api/users', () => { + it('認証済みユーザーがユーザー一覧を取得できる', async () => { + const res = await fetch(`${BASE_URL}/api/users`, { + headers: { + Authorization: `Bearer ${process.env.TEST_AUTH_TOKEN || 'test-token'}`, + }, + }); + + expect(res.status).toBe(200); + + const data = await res.json(); + expect(Array.isArray(data)).toBe(true); + }); + + it('認証なしで401を返す', async () => { + const res = await fetch(`${BASE_URL}/api/users`); + expect(res.status).toBe(401); + }); + + it('ページネーションが正しく動作する', async () => { + const res = await fetch(`${BASE_URL}/api/users?page=1&limit=10`, { + headers: { + Authorization: `Bearer ${process.env.TEST_AUTH_TOKEN || 'test-token'}`, + }, + }); + + expect(res.status).toBe(200); + + const data = await res.json(); + expect(data.length).toBeLessThanOrEqual(10); + }); + + it('フィルタリングが正しく動作する', async () => { + const res = await fetch(`${BASE_URL}/api/users?role=admin`, { + headers: { + Authorization: `Bearer ${process.env.TEST_AUTH_TOKEN || 'test-token'}`, + }, + }); + + expect(res.status).toBe(200); + + const data = await res.json(); + data.forEach((user: { role: string }) => { + expect(user.role).toBe('admin'); + }); + }); + + it('ソートが正しく動作する', async () => { + const res = await fetch(`${BASE_URL}/api/users?sort=created_at&order=desc`, { + headers: { + Authorization: `Bearer ${process.env.TEST_AUTH_TOKEN || 'test-token'}`, + }, + }); + + expect(res.status).toBe(200); + + const data = await res.json(); + if (data.length > 1) { + const dates = data.map((u: { created_at: string }) => new Date(u.created_at).getTime()); + expect(dates).toEqual([...dates].sort((a, b) => b - a)); + } + }); + }); + + describe('GET /api/users/:id', () => { + it('存在するユーザーを取得できる', async () => { + const userId = 'test-user-id'; + const res = await fetch(`${BASE_URL}/api/users/${userId}`, { + headers: { + Authorization: `Bearer ${process.env.TEST_AUTH_TOKEN || 'test-token'}`, + }, + }); + + if (res.status === 200) { + const data = await res.json(); + expect(data.id).toBe(userId); + } else { + // ユーザーが存在しない場合は404 + expect(res.status).toBe(404); + } + }); + + it('存在しないユーザーで404を返す', async () => { + const res = await fetch(`${BASE_URL}/api/users/non-existent-id`, { + headers: { + Authorization: `Bearer ${process.env.TEST_AUTH_TOKEN || 'test-token'}`, + }, + }); + + expect(res.status).toBe(404); + }); + + it('無効なIDフォーマットで400を返す', async () => { + const res = await fetch(`${BASE_URL}/api/users/invalid-format!@#`, { + headers: { + Authorization: `Bearer ${process.env.TEST_AUTH_TOKEN || 'test-token'}`, + }, + }); + + expect([400, 404]).toContain(res.status); + }); + }); + + describe('POST /api/users', () => { + it('有効なデータでユーザーを作成できる', async () => { + const newUser = { + name: `Test User ${Date.now()}`, + email: `test-${Date.now()}@example.com`, + role: 'user', + }; + + const res = await fetch(`${BASE_URL}/api/users`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${process.env.TEST_AUTH_TOKEN || 'test-token'}`, + }, + body: JSON.stringify(newUser), + }); + + if (res.status === 201) { + const data = await res.json(); + expect(data.name).toBe(newUser.name); + expect(data.email).toBe(newUser.email); + expect(data.id).toBeDefined(); + } + }); + + it('必須フィールドがない場合は400を返す', async () => { + const invalidUser = { + name: 'Test User', + // email is missing + }; + + const res = await fetch(`${BASE_URL}/api/users`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${process.env.TEST_AUTH_TOKEN || 'test-token'}`, + }, + body: JSON.stringify(invalidUser), + }); + + expect(res.status).toBe(400); + + const data = await res.json(); + expect(data.error || data.message).toBeDefined(); + }); + + it('重複メールアドレスで409を返す', async () => { + const existingEmail = 'existing@example.com'; + + const res = await fetch(`${BASE_URL}/api/users`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${process.env.TEST_AUTH_TOKEN || 'test-token'}`, + }, + body: JSON.stringify({ + name: 'Duplicate User', + email: existingEmail, + }), + }); + + // 409 Conflict または 400 Bad Request + expect([400, 409]).toContain(res.status); + }); + + it('無効なメールフォーマットで400を返す', async () => { + const res = await fetch(`${BASE_URL}/api/users`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${process.env.TEST_AUTH_TOKEN || 'test-token'}`, + }, + body: JSON.stringify({ + name: 'Test User', + email: 'invalid-email', + }), + }); + + expect(res.status).toBe(400); + }); + }); + + describe('PUT /api/users/:id', () => { + it('ユーザー情報を更新できる', async () => { + const userId = 'test-user-id'; + const updateData = { + name: 'Updated Name', + }; + + const res = await fetch(`${BASE_URL}/api/users/${userId}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${process.env.TEST_AUTH_TOKEN || 'test-token'}`, + }, + body: JSON.stringify(updateData), + }); + + if (res.status === 200) { + const data = await res.json(); + expect(data.name).toBe(updateData.name); + } + }); + + it('部分更新(PATCH)が動作する', async () => { + const userId = 'test-user-id'; + + const res = await fetch(`${BASE_URL}/api/users/${userId}`, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${process.env.TEST_AUTH_TOKEN || 'test-token'}`, + }, + body: JSON.stringify({ name: 'Patched Name' }), + }); + + // PATCH がサポートされていれば200 + expect([200, 405]).toContain(res.status); + }); + + it('他のユーザーのデータを更新できない(認可)', async () => { + const otherUserId = 'other-user-id'; + + const res = await fetch(`${BASE_URL}/api/users/${otherUserId}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${process.env.TEST_AUTH_TOKEN || 'test-token'}`, + }, + body: JSON.stringify({ name: 'Hacked Name' }), + }); + + // 403 Forbidden または 404 Not Found + expect([403, 404]).toContain(res.status); + }); + }); + + describe('DELETE /api/users/:id', () => { + it('ユーザーを削除できる', async () => { + // まずテストユーザーを作成 + const createRes = await fetch(`${BASE_URL}/api/users`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${process.env.TEST_AUTH_TOKEN || 'test-token'}`, + }, + body: JSON.stringify({ + name: 'To Delete', + email: `delete-${Date.now()}@example.com`, + }), + }); + + if (createRes.status === 201) { + const created = await createRes.json(); + + const deleteRes = await fetch(`${BASE_URL}/api/users/${created.id}`, { + method: 'DELETE', + headers: { + Authorization: `Bearer ${process.env.TEST_AUTH_TOKEN || 'test-token'}`, + }, + }); + + expect([200, 204]).toContain(deleteRes.status); + + // 削除後に取得できないことを確認 + const getRes = await fetch(`${BASE_URL}/api/users/${created.id}`, { + headers: { + Authorization: `Bearer ${process.env.TEST_AUTH_TOKEN || 'test-token'}`, + }, + }); + + expect(getRes.status).toBe(404); + } + }); + + it('存在しないユーザーの削除で404を返す', async () => { + const res = await fetch(`${BASE_URL}/api/users/non-existent-id`, { + method: 'DELETE', + headers: { + Authorization: `Bearer ${process.env.TEST_AUTH_TOKEN || 'test-token'}`, + }, + }); + + expect(res.status).toBe(404); + }); + }); + + describe('API エラーハンドリング', () => { + it('不正なJSONボディで400を返す', async () => { + const res = await fetch(`${BASE_URL}/api/users`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${process.env.TEST_AUTH_TOKEN || 'test-token'}`, + }, + body: 'invalid json', + }); + + expect(res.status).toBe(400); + }); + + it('サポートされていないメソッドで405を返す', async () => { + const res = await fetch(`${BASE_URL}/api/users`, { + method: 'TRACE', + headers: { + Authorization: `Bearer ${process.env.TEST_AUTH_TOKEN || 'test-token'}`, + }, + }); + + expect([405, 400]).toContain(res.status); + }); + + it('Content-Typeが不正な場合でも適切に処理', async () => { + const res = await fetch(`${BASE_URL}/api/users`, { + method: 'POST', + headers: { + 'Content-Type': 'text/plain', + Authorization: `Bearer ${process.env.TEST_AUTH_TOKEN || 'test-token'}`, + }, + body: 'plain text', + }); + + // 415 Unsupported Media Type または 400 + expect([400, 415]).toContain(res.status); + }); + }); + + describe('API レスポンス形式', () => { + it('Content-Type が application/json', async () => { + const res = await fetch(`${BASE_URL}/api/users`, { + headers: { + Authorization: `Bearer ${process.env.TEST_AUTH_TOKEN || 'test-token'}`, + }, + }); + + const contentType = res.headers.get('content-type'); + expect(contentType).toContain('application/json'); + }); + + it('エラーレスポンスが統一フォーマット', async () => { + const res = await fetch(`${BASE_URL}/api/users/non-existent`, { + headers: { + Authorization: `Bearer ${process.env.TEST_AUTH_TOKEN || 'test-token'}`, + }, + }); + + if (res.status === 404) { + const data = await res.json(); + // エラーレスポンスに error または message フィールドがある + expect(data.error || data.message).toBeDefined(); + } + }); + }); +}); + +// Next.js API Route のユニットテスト(node-mocks-http使用) +describe('API Route Unit Tests', () => { + it('ハンドラが正しいレスポンスを返す', async () => { + const { req, res } = createMocks({ + method: 'GET', + }); + + // handler(req, res); // 実際のハンドラを呼び出す + + // expect(res._getStatusCode()).toBe(200); + // expect(JSON.parse(res._getData())).toEqual({ ... }); + + // プレースホルダー + expect(true).toBe(true); + }); + + it('POST リクエストを正しく処理する', async () => { + const { req, res } = createMocks({ + method: 'POST', + body: { + name: 'Test User', + email: 'test@example.com', + }, + }); + + // handler(req, res); + + // expect(res._getStatusCode()).toBe(201); + + // プレースホルダー + expect(true).toBe(true); + }); +}); diff --git a/templates/testing/examples/component.test.tsx b/templates/testing/examples/component.test.tsx new file mode 100644 index 00000000..f42c9bf1 --- /dev/null +++ b/templates/testing/examples/component.test.tsx @@ -0,0 +1,65 @@ +/** + * React Component Test Example + * + * このファイルは React コンポーネントのテスト例です。 + * components/__tests__/Button.test.tsx として配置してください。 + */ + +import { render, screen, fireEvent } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { Button } from '../Button'; + +describe('Button', () => { + it('renders with children text', () => { + render(); + + expect(screen.getByRole('button', { name: /click me/i })).toBeInTheDocument(); + }); + + it('applies variant styles correctly', () => { + render(); + + const button = screen.getByRole('button'); + expect(button).toHaveClass('btn-primary'); + }); + + it('calls onClick handler when clicked', async () => { + const handleClick = jest.fn(); + const user = userEvent.setup(); + + render(); + + await user.click(screen.getByRole('button')); + + expect(handleClick).toHaveBeenCalledTimes(1); + }); + + it('is disabled when disabled prop is true', () => { + render(); + + const button = screen.getByRole('button'); + expect(button).toBeDisabled(); + }); + + it('shows loading state', () => { + render(); + + expect(screen.getByRole('button')).toHaveAttribute('aria-busy', 'true'); + expect(screen.getByTestId('loading-spinner')).toBeInTheDocument(); + }); + + it('does not call onClick when disabled', async () => { + const handleClick = jest.fn(); + const user = userEvent.setup(); + + render( + , + ); + + await user.click(screen.getByRole('button')); + + expect(handleClick).not.toHaveBeenCalled(); + }); +}); diff --git a/templates/testing/examples/contract.test.ts b/templates/testing/examples/contract.test.ts new file mode 100644 index 00000000..0eae4632 --- /dev/null +++ b/templates/testing/examples/contract.test.ts @@ -0,0 +1,281 @@ +/** + * Contract Test Example + * + * API契約テストは、APIが仕様(OpenAPI/Swagger)に + * 準拠しているかを検証するテストです。 + * + * tests/contract/api-contract.test.ts として配置してください。 + * + * 依存パッケージ: + * npm install -D openapi-typescript openapi-fetch + */ + +import { describe, it, expect, beforeAll } from '@jest/globals'; + +// OpenAPI仕様から生成された型(npx openapi-typescript で生成) +// import type { paths, components } from '@/lib/types/api'; + +const BASE_URL = process.env.TEST_BASE_URL || 'http://localhost:3000'; + +// API仕様のスキーマ定義(実際はOpenAPIから生成) +const apiSpec = { + '/api/users': { + get: { + responses: { + 200: { + schema: { + type: 'array', + items: { + type: 'object', + required: ['id', 'email', 'name'], + properties: { + id: { type: 'string', format: 'uuid' }, + email: { type: 'string', format: 'email' }, + name: { type: 'string' }, + createdAt: { type: 'string', format: 'date-time' }, + }, + }, + }, + }, + }, + }, + post: { + requestBody: { + required: ['email', 'name'], + properties: { + email: { type: 'string', format: 'email' }, + name: { type: 'string', minLength: 1 }, + }, + }, + responses: { + 201: { + schema: { + type: 'object', + required: ['id', 'email', 'name'], + }, + }, + 400: { + schema: { + type: 'object', + required: ['error'], + }, + }, + }, + }, + }, + '/api/users/{id}': { + get: { + responses: { + 200: { + schema: { + type: 'object', + required: ['id', 'email', 'name'], + }, + }, + 404: { + schema: { + type: 'object', + required: ['error'], + }, + }, + }, + }, + }, +}; + +// スキーマバリデーションヘルパー +function validateSchema(data: unknown, schema: Record): boolean { + if (schema.type === 'object') { + if (typeof data !== 'object' || data === null) return false; + + const obj = data as Record; + const required = (schema.required as string[]) || []; + + for (const field of required) { + if (!(field in obj)) return false; + } + return true; + } + + if (schema.type === 'array') { + if (!Array.isArray(data)) return false; + + const itemSchema = schema.items as Record; + return data.every((item) => validateSchema(item, itemSchema)); + } + + if (schema.type === 'string') { + return typeof data === 'string'; + } + + return true; +} + +describe('API Contract Tests', () => { + let authToken: string; + + beforeAll(async () => { + // 認証トークンを取得 + const loginRes = await fetch(`${BASE_URL}/api/auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + email: process.env.TEST_USER_EMAIL || 'test@example.com', + password: process.env.TEST_USER_PASSWORD || 'password', + }), + }); + + if (loginRes.ok) { + const data = await loginRes.json(); + authToken = data.token; + } + }); + + describe('GET /api/users', () => { + it('レスポンスが仕様に準拠している', async () => { + const res = await fetch(`${BASE_URL}/api/users`, { + headers: { + Authorization: `Bearer ${authToken}`, + }, + }); + + expect(res.status).toBe(200); + + const data = await res.json(); + const schema = apiSpec['/api/users'].get.responses[200].schema; + + expect(validateSchema(data, schema)).toBe(true); + }); + + it('Content-Typeがapplication/jsonである', async () => { + const res = await fetch(`${BASE_URL}/api/users`, { + headers: { + Authorization: `Bearer ${authToken}`, + }, + }); + + expect(res.headers.get('content-type')).toContain('application/json'); + }); + }); + + describe('POST /api/users', () => { + it('正常なリクエストで201を返す', async () => { + const res = await fetch(`${BASE_URL}/api/users`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${authToken}`, + }, + body: JSON.stringify({ + email: `test-${Date.now()}@example.com`, + name: 'Test User', + }), + }); + + expect(res.status).toBe(201); + + const data = await res.json(); + const schema = apiSpec['/api/users'].post.responses[201].schema; + + expect(validateSchema(data, schema)).toBe(true); + }); + + it('必須フィールド欠落で400を返す', async () => { + const res = await fetch(`${BASE_URL}/api/users`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${authToken}`, + }, + body: JSON.stringify({ + // emailが欠落 + name: 'Test User', + }), + }); + + expect(res.status).toBe(400); + + const data = await res.json(); + const schema = apiSpec['/api/users'].post.responses[400].schema; + + expect(validateSchema(data, schema)).toBe(true); + }); + }); + + describe('GET /api/users/{id}', () => { + it('存在するユーザーで200を返す', async () => { + // まずユーザーを作成 + const createRes = await fetch(`${BASE_URL}/api/users`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${authToken}`, + }, + body: JSON.stringify({ + email: `test-${Date.now()}@example.com`, + name: 'Test User', + }), + }); + + const created = await createRes.json(); + + // 作成したユーザーを取得 + const res = await fetch(`${BASE_URL}/api/users/${created.id}`, { + headers: { + Authorization: `Bearer ${authToken}`, + }, + }); + + expect(res.status).toBe(200); + + const data = await res.json(); + const schema = apiSpec['/api/users/{id}'].get.responses[200].schema; + + expect(validateSchema(data, schema)).toBe(true); + }); + + it('存在しないユーザーで404を返す', async () => { + const res = await fetch(`${BASE_URL}/api/users/00000000-0000-0000-0000-000000000000`, { + headers: { + Authorization: `Bearer ${authToken}`, + }, + }); + + expect(res.status).toBe(404); + + const data = await res.json(); + const schema = apiSpec['/api/users/{id}'].get.responses[404].schema; + + expect(validateSchema(data, schema)).toBe(true); + }); + }); + + describe('HTTPメソッド検証', () => { + it('未対応メソッドで405を返す', async () => { + const res = await fetch(`${BASE_URL}/api/users`, { + method: 'PATCH', + headers: { + Authorization: `Bearer ${authToken}`, + }, + }); + + expect(res.status).toBe(405); + }); + }); + + describe('バージョニング', () => { + it('APIバージョンヘッダーが含まれる(任意)', async () => { + const res = await fetch(`${BASE_URL}/api/users`, { + headers: { + Authorization: `Bearer ${authToken}`, + }, + }); + + // X-API-Versionヘッダーがあれば検証 + const apiVersion = res.headers.get('x-api-version'); + if (apiVersion) { + expect(apiVersion).toMatch(/^\d+\.\d+(\.\d+)?$/); + } + }); + }); +}); diff --git a/templates/testing/examples/database.test.ts b/templates/testing/examples/database.test.ts new file mode 100644 index 00000000..91872e27 --- /dev/null +++ b/templates/testing/examples/database.test.ts @@ -0,0 +1,230 @@ +/** + * Database Test Example + * + * データベーステストは、マイグレーション、シードデータ、 + * データ整合性を検証するテストです。 + * + * tests/database/migrations.test.ts として配置してください。 + * + * 依存: Supabase CLI (supabase db reset, supabase migration list) + */ + +import { createClient, SupabaseClient } from '@supabase/supabase-js'; +import { execSync } from 'child_process'; + +// テスト用 Supabase クライアント +let supabase: SupabaseClient; + +beforeAll(() => { + supabase = createClient( + process.env.SUPABASE_URL || 'http://localhost:54321', + process.env.SUPABASE_SERVICE_ROLE_KEY || 'your-service-role-key', + ); +}); + +describe('Database Migration Tests', () => { + describe('マイグレーション適用', () => { + it('すべてのマイグレーションが適用可能', () => { + // supabase db reset はマイグレーションを再適用 + // エラーがなければ成功 + try { + execSync('supabase db reset --linked', { + encoding: 'utf-8', + stdio: 'pipe', + }); + expect(true).toBe(true); + } catch (error) { + // ローカル環境でのみ実行 + console.log('Migration test skipped (CI or linked project not available)'); + expect(true).toBe(true); + } + }); + + it('マイグレーションファイルが存在する', () => { + try { + const result = execSync('ls supabase/migrations/*.sql 2>/dev/null | wc -l', { + encoding: 'utf-8', + }); + const count = parseInt(result.trim(), 10); + expect(count).toBeGreaterThan(0); + } catch { + // マイグレーションディレクトリがない場合はスキップ + expect(true).toBe(true); + } + }); + }); + + describe('スキーマ検証', () => { + it('必須テーブルが存在する', async () => { + const requiredTables = ['users', 'profiles']; + + for (const table of requiredTables) { + const { error } = await supabase.from(table).select('*').limit(1); + + // テーブルが存在しない場合は 404 系エラー + expect(error?.code).not.toBe('42P01'); // undefined_table + } + }); + + it('外部キー制約が正しく設定されている', async () => { + // 存在しないユーザーIDでの挿入を試みる + const { error } = await supabase.from('profiles').insert({ + user_id: '00000000-0000-0000-0000-000000000000', + display_name: 'Test', + }); + + // 外部キー制約違反 + expect(error?.code).toBe('23503'); + }); + + it('NOT NULL 制約が正しく設定されている', async () => { + const { error } = await supabase.from('users').insert({ + // email が NULL(必須フィールド) + email: null, + }); + + // NOT NULL 制約違反 + expect(error?.code).toBe('23502'); + }); + + it('UNIQUE 制約が正しく設定されている', async () => { + const testEmail = `unique-test-${Date.now()}@example.com`; + + // 1つ目の挿入は成功 + await supabase.from('users').insert({ email: testEmail }); + + // 2つ目の挿入は失敗(UNIQUE 制約違反) + const { error } = await supabase.from('users').insert({ email: testEmail }); + + expect(error?.code).toBe('23505'); + + // クリーンアップ + await supabase.from('users').delete().eq('email', testEmail); + }); + }); + + describe('シードデータ検証', () => { + it('初期データが正しく投入されている', async () => { + // 例: 初期管理者ユーザーが存在 + const { data, error } = await supabase.from('users').select('*').eq('role', 'admin').limit(1); + + // シードデータが存在する場合 + if (!error && data && data.length > 0) { + expect(data[0].role).toBe('admin'); + } + }); + + it('マスターデータが存在する', async () => { + // 例: 部門マスター + const { data, error } = await supabase.from('departments').select('*'); + + if (!error) { + expect(data.length).toBeGreaterThan(0); + } + }); + }); + + describe('RLS (Row Level Security)', () => { + it('RLS が有効になっている', async () => { + // 匿名クライアントを作成 + const anonClient = createClient( + process.env.SUPABASE_URL || 'http://localhost:54321', + process.env.SUPABASE_ANON_KEY || 'your-anon-key', + ); + + // 認証なしでユーザーデータにアクセス + const { data, error } = await anonClient.from('users').select('*'); + + // RLS が有効なら、エラーまたは空のデータ + expect(error || (data && data.length === 0)).toBeTruthy(); + }); + + it('認証ユーザーは自分のデータのみアクセス可能', async () => { + // このテストは実際の認証フローが必要 + // E2E テストまたは統合テストで実行 + expect(true).toBe(true); + }); + }); + + describe('インデックス検証', () => { + it('頻繁にクエリされるカラムにインデックスがある', async () => { + // PostgreSQL のインデックス情報を取得 + const { data, error } = await supabase.rpc('get_indexes', { + table_name: 'users', + }); + + if (!error && data) { + const indexedColumns = data.map((idx: { column_name: string }) => idx.column_name); + + // email カラムにインデックスがある + expect(indexedColumns).toContain('email'); + } + }); + }); + + describe('トリガー・関数', () => { + it('updated_at が自動更新される', async () => { + // テストユーザーを作成 + const testEmail = `trigger-test-${Date.now()}@example.com`; + + const { data: created } = await supabase.from('users').insert({ email: testEmail }).select().single(); + + if (created) { + const originalUpdatedAt = created.updated_at; + + // 少し待機 + await new Promise((resolve) => setTimeout(resolve, 1000)); + + // 更新 + const { data: updated } = await supabase + .from('users') + .update({ name: 'Updated' }) + .eq('id', created.id) + .select() + .single(); + + if (updated) { + // updated_at が更新されている + expect(new Date(updated.updated_at).getTime()).toBeGreaterThan(new Date(originalUpdatedAt).getTime()); + } + + // クリーンアップ + await supabase.from('users').delete().eq('id', created.id); + } + }); + }); + + describe('パフォーマンス', () => { + it('大量データでのクエリが許容時間内に完了', async () => { + const startTime = Date.now(); + + await supabase.from('users').select('*').limit(1000); + + const duration = Date.now() - startTime; + + // 1秒以内に完了 + expect(duration).toBeLessThan(1000); + }); + + it('インデックスを使用したクエリが高速', async () => { + const startTime = Date.now(); + + // インデックスされたカラムでの検索 + await supabase.from('users').select('*').eq('email', 'test@example.com').single(); + + const duration = Date.now() - startTime; + + // 100ms以内に完了 + expect(duration).toBeLessThan(100); + }); + }); +}); + +describe('Database Type Safety', () => { + it('生成された型定義が最新', () => { + // supabase gen types typescript で生成された型と + // 実際のスキーマが一致することを確認 + // CI で supabase gen types typescript --linked > types.ts && git diff --exit-code types.ts + expect(true).toBe(true); + }); +}); diff --git a/templates/testing/examples/e2e-auth.spec.ts b/templates/testing/examples/e2e-auth.spec.ts new file mode 100644 index 00000000..7e7ebb8f --- /dev/null +++ b/templates/testing/examples/e2e-auth.spec.ts @@ -0,0 +1,85 @@ +/** + * E2E Auth Test Example + * + * このファイルは Playwright による認証フローのE2Eテスト例です。 + * tests/e2e/auth.spec.ts として配置してください。 + */ + +import { test, expect } from '@playwright/test'; + +test.describe('Authentication', () => { + test.beforeEach(async ({ page }) => { + // テスト前にログアウト状態にする + await page.goto('/'); + await page.evaluate(() => { + localStorage.clear(); + sessionStorage.clear(); + }); + }); + + test('should display login page', async ({ page }) => { + await page.goto('/login'); + + await expect(page.getByRole('heading', { name: /ログイン/i })).toBeVisible(); + await expect(page.getByLabel(/メールアドレス/i)).toBeVisible(); + await expect(page.getByLabel(/パスワード/i)).toBeVisible(); + await expect(page.getByRole('button', { name: /ログイン/i })).toBeVisible(); + }); + + test('should show validation errors for empty form', async ({ page }) => { + await page.goto('/login'); + + await page.getByRole('button', { name: /ログイン/i }).click(); + + await expect(page.getByText(/メールアドレスを入力してください/i)).toBeVisible(); + await expect(page.getByText(/パスワードを入力してください/i)).toBeVisible(); + }); + + test('should show error for invalid credentials', async ({ page }) => { + await page.goto('/login'); + + await page.getByLabel(/メールアドレス/i).fill('invalid@example.com'); + await page.getByLabel(/パスワード/i).fill('wrongpassword'); + await page.getByRole('button', { name: /ログイン/i }).click(); + + await expect(page.getByText(/メールアドレスまたはパスワードが正しくありません/i)).toBeVisible(); + }); + + test('should redirect to dashboard after successful login', async ({ page }) => { + await page.goto('/login'); + + // テスト用の認証情報を使用 + await page.getByLabel(/メールアドレス/i).fill(process.env.TEST_USER_EMAIL || 'test@example.com'); + await page.getByLabel(/パスワード/i).fill(process.env.TEST_USER_PASSWORD || 'testpassword'); + await page.getByRole('button', { name: /ログイン/i }).click(); + + // ダッシュボードへのリダイレクトを確認 + await expect(page).toHaveURL(/\/dashboard/); + await expect(page.getByRole('heading', { name: /ダッシュボード/i })).toBeVisible(); + }); + + test('should logout successfully', async ({ page }) => { + // まずログイン + await page.goto('/login'); + await page.getByLabel(/メールアドレス/i).fill(process.env.TEST_USER_EMAIL || 'test@example.com'); + await page.getByLabel(/パスワード/i).fill(process.env.TEST_USER_PASSWORD || 'testpassword'); + await page.getByRole('button', { name: /ログイン/i }).click(); + + await expect(page).toHaveURL(/\/dashboard/); + + // ログアウト + await page.getByRole('button', { name: /ユーザーメニュー/i }).click(); + await page.getByRole('menuitem', { name: /ログアウト/i }).click(); + + // ログインページへリダイレクト + await expect(page).toHaveURL(/\/login/); + }); + + test('should protect authenticated routes', async ({ page }) => { + // 未認証状態でダッシュボードにアクセス + await page.goto('/dashboard'); + + // ログインページにリダイレクトされることを確認 + await expect(page).toHaveURL(/\/login/); + }); +}); diff --git a/templates/testing/examples/edge-functions.test.ts b/templates/testing/examples/edge-functions.test.ts new file mode 100644 index 00000000..46b37b77 --- /dev/null +++ b/templates/testing/examples/edge-functions.test.ts @@ -0,0 +1,212 @@ +/** + * Edge Functions Test Example + * + * Edge Functionsテストは、Supabase Edge Functions + * (Deno) の動作を検証するテストです。 + * + * supabase/functions/hello-world/index.test.ts として配置 + * + * 実行: cd supabase/functions && deno test --allow-all + */ + +// Deno 用のテストコード +const denoTestCode = ` +// supabase/functions/hello-world/index.test.ts + +import { assertEquals, assertExists } from "https://deno.land/std@0.208.0/assert/mod.ts"; +import { createClient } from "https://esm.sh/@supabase/supabase-js@2"; + +const SUPABASE_URL = Deno.env.get("SUPABASE_URL") ?? "http://localhost:54321"; +const SUPABASE_ANON_KEY = Deno.env.get("SUPABASE_ANON_KEY") ?? ""; +const FUNCTION_URL = \`\${SUPABASE_URL}/functions/v1/hello-world\`; + +Deno.test("hello-world function returns greeting", async () => { + const response = await fetch(FUNCTION_URL, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": \`Bearer \${SUPABASE_ANON_KEY}\`, + }, + body: JSON.stringify({ name: "Test" }), + }); + + assertEquals(response.status, 200); + + const data = await response.json(); + assertEquals(data.message, "Hello, Test!"); +}); + +Deno.test("hello-world function handles missing name", async () => { + const response = await fetch(FUNCTION_URL, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": \`Bearer \${SUPABASE_ANON_KEY}\`, + }, + body: JSON.stringify({}), + }); + + assertEquals(response.status, 200); + + const data = await response.json(); + assertEquals(data.message, "Hello, World!"); +}); + +Deno.test("hello-world function requires authentication", async () => { + const response = await fetch(FUNCTION_URL, { + method: "POST", + headers: { + "Content-Type": "application/json", + // Authorization ヘッダーなし + }, + body: JSON.stringify({ name: "Test" }), + }); + + // 認証エラー + assertEquals(response.status, 401); +}); + +Deno.test("hello-world function handles invalid JSON", async () => { + const response = await fetch(FUNCTION_URL, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": \`Bearer \${SUPABASE_ANON_KEY}\`, + }, + body: "invalid json", + }); + + // Bad Request + assertEquals(response.status, 400); +}); +`; + +// Jest テスト(Edge Functions の HTTP 呼び出し) +const BASE_URL = process.env.SUPABASE_URL || 'http://localhost:54321'; +const ANON_KEY = process.env.SUPABASE_ANON_KEY || ''; + +describe('Edge Functions Tests (via HTTP)', () => { + describe('hello-world function', () => { + const functionUrl = `${BASE_URL}/functions/v1/hello-world`; + + it('正常なリクエストで挨拶を返す', async () => { + const res = await fetch(functionUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${ANON_KEY}`, + }, + body: JSON.stringify({ name: 'Jest' }), + }); + + if (res.status === 404) { + // Edge Function がデプロイされていない場合はスキップ + console.log('Edge Function not deployed, skipping test'); + return; + } + + expect(res.status).toBe(200); + + const data = await res.json(); + expect(data.message).toBe('Hello, Jest!'); + }); + + it('認証なしで401を返す', async () => { + const res = await fetch(functionUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ name: 'Test' }), + }); + + if (res.status === 404) { + console.log('Edge Function not deployed, skipping test'); + return; + } + + expect(res.status).toBe(401); + }); + }); + + describe('process-webhook function', () => { + const functionUrl = `${BASE_URL}/functions/v1/process-webhook`; + + it('Webhook署名を検証する', async () => { + const payload = { event: 'test', data: { id: 1 } }; + const signature = 'invalid-signature'; + + const res = await fetch(functionUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Webhook-Signature': signature, + }, + body: JSON.stringify(payload), + }); + + if (res.status === 404) { + console.log('Edge Function not deployed, skipping test'); + return; + } + + // 無効な署名で401 + expect(res.status).toBe(401); + }); + }); + + describe('Edge Function パフォーマンス', () => { + it('レスポンス時間が許容範囲内', async () => { + const functionUrl = `${BASE_URL}/functions/v1/hello-world`; + + const startTime = Date.now(); + + const res = await fetch(functionUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${ANON_KEY}`, + }, + body: JSON.stringify({ name: 'Performance' }), + }); + + const duration = Date.now() - startTime; + + if (res.status === 404) { + console.log('Edge Function not deployed, skipping test'); + return; + } + + // 500ms以内に応答 + expect(duration).toBeLessThan(500); + }); + }); + + describe('Edge Function CORS', () => { + it('CORS ヘッダーが設定されている', async () => { + const functionUrl = `${BASE_URL}/functions/v1/hello-world`; + + const res = await fetch(functionUrl, { + method: 'OPTIONS', + headers: { + Origin: 'http://localhost:3000', + 'Access-Control-Request-Method': 'POST', + }, + }); + + if (res.status === 404) { + console.log('Edge Function not deployed, skipping test'); + return; + } + + const allowOrigin = res.headers.get('access-control-allow-origin'); + const allowMethods = res.headers.get('access-control-allow-methods'); + + expect(allowOrigin).toBeTruthy(); + expect(allowMethods).toContain('POST'); + }); + }); +}); + +// Deno テストコードをエクスポート(参照用) +export { denoTestCode }; diff --git a/templates/testing/examples/hook.test.ts b/templates/testing/examples/hook.test.ts new file mode 100644 index 00000000..b6b4be87 --- /dev/null +++ b/templates/testing/examples/hook.test.ts @@ -0,0 +1,90 @@ +/** + * React Hook Test Example + * + * このファイルは React カスタムフックのテスト例です。 + * hooks/__tests__/useCounter.test.ts として配置してください。 + */ + +import { renderHook, act } from '@testing-library/react'; +import { useCounter } from '../useCounter'; + +describe('useCounter', () => { + it('initializes with default value of 0', () => { + const { result } = renderHook(() => useCounter()); + + expect(result.current.count).toBe(0); + }); + + it('initializes with provided initial value', () => { + const { result } = renderHook(() => useCounter(10)); + + expect(result.current.count).toBe(10); + }); + + it('increments count', () => { + const { result } = renderHook(() => useCounter(0)); + + 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 to initial value', () => { + const { result } = renderHook(() => useCounter(10)); + + act(() => { + result.current.increment(); + result.current.increment(); + }); + + expect(result.current.count).toBe(12); + + act(() => { + result.current.reset(); + }); + + expect(result.current.count).toBe(10); + }); + + it('sets count to specific value', () => { + const { result } = renderHook(() => useCounter(0)); + + act(() => { + result.current.setCount(42); + }); + + expect(result.current.count).toBe(42); + }); + + it('respects min boundary', () => { + const { result } = renderHook(() => useCounter(0, { min: 0 })); + + act(() => { + result.current.decrement(); + }); + + expect(result.current.count).toBe(0); + }); + + it('respects max boundary', () => { + const { result } = renderHook(() => useCounter(10, { max: 10 })); + + act(() => { + result.current.increment(); + }); + + expect(result.current.count).toBe(10); + }); +}); diff --git a/templates/testing/examples/i18n.test.tsx b/templates/testing/examples/i18n.test.tsx new file mode 100644 index 00000000..d83029d4 --- /dev/null +++ b/templates/testing/examples/i18n.test.tsx @@ -0,0 +1,404 @@ +/** + * i18n (Internationalization) Test Example + * + * 国際化テストは、多言語対応が正しく動作するかを検証します。 + * + * tests/i18n/translations.test.tsx として配置してください。 + * + * 依存パッケージ: + * npm install -D @testing-library/react next-intl (または i18next) + */ + +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +// 翻訳ファイルの例 +const translations = { + ja: { + common: { + welcome: 'ようこそ', + login: 'ログイン', + logout: 'ログアウト', + save: '保存', + cancel: 'キャンセル', + delete: '削除', + edit: '編集', + loading: '読み込み中...', + error: 'エラーが発生しました', + success: '成功しました', + }, + auth: { + email: 'メールアドレス', + password: 'パスワード', + forgotPassword: 'パスワードをお忘れですか?', + signUp: 'アカウント作成', + signIn: 'ログイン', + }, + validation: { + required: '必須項目です', + email: '有効なメールアドレスを入力してください', + minLength: '{count}文字以上で入力してください', + maxLength: '{count}文字以内で入力してください', + }, + }, + en: { + common: { + welcome: 'Welcome', + login: 'Login', + logout: 'Logout', + save: 'Save', + cancel: 'Cancel', + delete: 'Delete', + edit: 'Edit', + loading: 'Loading...', + error: 'An error occurred', + success: 'Success', + }, + auth: { + email: 'Email', + password: 'Password', + forgotPassword: 'Forgot password?', + signUp: 'Sign Up', + signIn: 'Sign In', + }, + validation: { + required: 'This field is required', + email: 'Please enter a valid email address', + minLength: 'Must be at least {count} characters', + maxLength: 'Must be at most {count} characters', + }, + }, +}; + +// サポートする言語 +const SUPPORTED_LOCALES = ['ja', 'en'] as const; +type Locale = (typeof SUPPORTED_LOCALES)[number]; + +describe('i18n Tests', () => { + describe('翻訳ファイル検証', () => { + it('すべての言語で同じキーが存在する', () => { + const getKeys = (obj: Record, prefix = ''): string[] => { + return Object.entries(obj).flatMap(([key, value]) => { + const fullKey = prefix ? `${prefix}.${key}` : key; + if (typeof value === 'object' && value !== null) { + return getKeys(value as Record, fullKey); + } + return [fullKey]; + }); + }; + + const jaKeys = getKeys(translations.ja).sort(); + const enKeys = getKeys(translations.en).sort(); + + expect(jaKeys).toEqual(enKeys); + }); + + it('翻訳値が空でない', () => { + const checkNotEmpty = (obj: Record, locale: string, path = ''): void => { + Object.entries(obj).forEach(([key, value]) => { + const fullPath = path ? `${path}.${key}` : key; + if (typeof value === 'object' && value !== null) { + checkNotEmpty(value as Record, locale, fullPath); + } else { + expect(value).not.toBe(''); + if (typeof value === 'string' && value.trim() === '') { + throw new Error(`Empty translation: ${locale}.${fullPath}`); + } + } + }); + }; + + SUPPORTED_LOCALES.forEach((locale) => { + checkNotEmpty(translations[locale], locale); + }); + }); + + it('プレースホルダーが正しいフォーマット', () => { + const checkPlaceholders = (obj: Record, path = ''): void => { + Object.entries(obj).forEach(([key, value]) => { + const fullPath = path ? `${path}.${key}` : key; + if (typeof value === 'object' && value !== null) { + checkPlaceholders(value as Record, fullPath); + } else if (typeof value === 'string') { + // {name} 形式のプレースホルダーを検出 + const placeholders = value.match(/\{[^}]+\}/g) || []; + placeholders.forEach((placeholder) => { + // 有効な変数名であることを確認 + const varName = placeholder.slice(1, -1); + expect(varName).toMatch(/^[a-zA-Z_][a-zA-Z0-9_]*$/); + }); + } + }); + }; + + SUPPORTED_LOCALES.forEach((locale) => { + checkPlaceholders(translations[locale]); + }); + }); + + it('プレースホルダーがすべての言語で一致する', () => { + const getPlaceholders = (str: string): string[] => { + const matches = str.match(/\{[^}]+\}/g) || []; + return matches.sort(); + }; + + const getValue = (obj: Record, path: string): string | null => { + const keys = path.split('.'); + let current: unknown = obj; + for (const key of keys) { + if (typeof current !== 'object' || current === null) return null; + current = (current as Record)[key]; + } + return typeof current === 'string' ? current : null; + }; + + const getKeys = (obj: Record, prefix = ''): string[] => { + return Object.entries(obj).flatMap(([key, value]) => { + const fullKey = prefix ? `${prefix}.${key}` : key; + if (typeof value === 'object' && value !== null) { + return getKeys(value as Record, fullKey); + } + return [fullKey]; + }); + }; + + const allKeys = getKeys(translations.ja); + + allKeys.forEach((key) => { + const jaValue = getValue(translations.ja, key); + const enValue = getValue(translations.en, key); + + if (jaValue && enValue) { + const jaPlaceholders = getPlaceholders(jaValue); + const enPlaceholders = getPlaceholders(enValue); + expect(jaPlaceholders).toEqual(enPlaceholders); + } + }); + }); + }); + + describe('翻訳品質検証', () => { + it('英語の翻訳が日本語のみではない', () => { + const checkNoJapanese = (obj: Record, path = ''): void => { + Object.entries(obj).forEach(([key, value]) => { + const fullPath = path ? `${path}.${key}` : key; + if (typeof value === 'object' && value !== null) { + checkNoJapanese(value as Record, fullPath); + } else if (typeof value === 'string') { + // 日本語文字(ひらがな、カタカナ、漢字)が含まれていないことを確認 + const hasJapanese = /[\u3040-\u309f\u30a0-\u30ff\u4e00-\u9faf]/.test(value); + if (hasJapanese) { + console.warn(`Japanese characters in EN translation: ${fullPath}`); + } + expect(hasJapanese).toBe(false); + } + }); + }; + + checkNoJapanese(translations.en); + }); + + it('日本語の翻訳が英語のみではない', () => { + const checkHasJapanese = (obj: Record, path = ''): void => { + Object.entries(obj).forEach(([key, value]) => { + const fullPath = path ? `${path}.${key}` : key; + if (typeof value === 'object' && value !== null) { + checkHasJapanese(value as Record, fullPath); + } else if (typeof value === 'string') { + // 短い文字列(略語など)を除外 + if (value.length > 3) { + const hasJapanese = /[\u3040-\u309f\u30a0-\u30ff\u4e00-\u9faf]/.test(value); + if (!hasJapanese) { + console.warn(`No Japanese characters in JA translation: ${fullPath} = "${value}"`); + } + } + } + }); + }; + + checkHasJapanese(translations.ja); + }); + }); + + describe('ロケール切り替え', () => { + // モックの翻訳プロバイダー + const MockI18nProvider: React.FC<{ + locale: Locale; + children: React.ReactNode; + }> = ({ locale, children }) => { + return
{children}
; + }; + + // 翻訳を取得するユーティリティ + const t = (locale: Locale, key: string): string => { + const keys = key.split('.'); + let current: unknown = translations[locale]; + for (const k of keys) { + if (typeof current !== 'object' || current === null) return key; + current = (current as Record)[k]; + } + return typeof current === 'string' ? current : key; + }; + + // テスト用コンポーネント + const TestComponent: React.FC<{ locale: Locale }> = ({ locale }) => { + return ( + +

{t(locale, 'common.welcome')}

+ +
+ ); + }; + + it('日本語表示が正しい', () => { + render(); + + expect(screen.getByRole('heading')).toHaveTextContent('ようこそ'); + expect(screen.getByRole('button')).toHaveTextContent('ログイン'); + }); + + it('英語表示が正しい', () => { + render(); + + expect(screen.getByRole('heading')).toHaveTextContent('Welcome'); + expect(screen.getByRole('button')).toHaveTextContent('Login'); + }); + }); + + describe('日付・数値フォーマット', () => { + it('日付が正しくフォーマットされる(日本語)', () => { + const date = new Date('2024-01-15T10:30:00'); + + const formatted = new Intl.DateTimeFormat('ja-JP', { + year: 'numeric', + month: 'long', + day: 'numeric', + }).format(date); + + expect(formatted).toBe('2024年1月15日'); + }); + + it('日付が正しくフォーマットされる(英語)', () => { + const date = new Date('2024-01-15T10:30:00'); + + const formatted = new Intl.DateTimeFormat('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric', + }).format(date); + + expect(formatted).toBe('January 15, 2024'); + }); + + it('通貨が正しくフォーマットされる', () => { + const amount = 1234567.89; + + const jaFormatted = new Intl.NumberFormat('ja-JP', { + style: 'currency', + currency: 'JPY', + }).format(amount); + + const enFormatted = new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + }).format(amount); + + expect(jaFormatted).toContain('1,234,568'); // 円は小数点以下なし + expect(enFormatted).toContain('1,234,567.89'); + }); + + it('パーセントが正しくフォーマットされる', () => { + const value = 0.1234; + + const jaFormatted = new Intl.NumberFormat('ja-JP', { + style: 'percent', + minimumFractionDigits: 1, + }).format(value); + + const enFormatted = new Intl.NumberFormat('en-US', { + style: 'percent', + minimumFractionDigits: 1, + }).format(value); + + expect(jaFormatted).toBe('12.3%'); + expect(enFormatted).toBe('12.3%'); + }); + }); + + describe('複数形・性別対応', () => { + it('複数形が正しく処理される', () => { + const pluralRules = { + ja: (count: number) => { + // 日本語は数による変化なし + return `${count}件のメッセージ`; + }, + en: (count: number) => { + if (count === 0) return 'No messages'; + if (count === 1) return '1 message'; + return `${count} messages`; + }, + }; + + expect(pluralRules.ja(0)).toBe('0件のメッセージ'); + expect(pluralRules.ja(1)).toBe('1件のメッセージ'); + expect(pluralRules.ja(5)).toBe('5件のメッセージ'); + + expect(pluralRules.en(0)).toBe('No messages'); + expect(pluralRules.en(1)).toBe('1 message'); + expect(pluralRules.en(5)).toBe('5 messages'); + }); + + it('Intl.PluralRules が正しく動作する', () => { + const jaRules = new Intl.PluralRules('ja-JP'); + const enRules = new Intl.PluralRules('en-US'); + + // 日本語は常に "other" + expect(jaRules.select(1)).toBe('other'); + expect(jaRules.select(2)).toBe('other'); + + // 英語は 1 が "one"、それ以外は "other" + expect(enRules.select(1)).toBe('one'); + expect(enRules.select(2)).toBe('other'); + }); + }); + + describe('RTL (Right-to-Left) 対応', () => { + it('RTL言語の方向が正しく設定される', () => { + const rtlLanguages = ['ar', 'he', 'fa', 'ur']; + const ltrLanguages = ['ja', 'en', 'zh', 'ko']; + + const getDirection = (locale: string): 'rtl' | 'ltr' => { + return rtlLanguages.includes(locale.split('-')[0]) ? 'rtl' : 'ltr'; + }; + + expect(getDirection('ar-SA')).toBe('rtl'); + expect(getDirection('he-IL')).toBe('rtl'); + expect(getDirection('ja-JP')).toBe('ltr'); + expect(getDirection('en-US')).toBe('ltr'); + }); + }); + + describe('エスケープ・XSS対策', () => { + it('翻訳文字列にHTMLが含まれない', () => { + const checkNoHtml = (obj: Record, path = ''): void => { + Object.entries(obj).forEach(([key, value]) => { + const fullPath = path ? `${path}.${key}` : key; + if (typeof value === 'object' && value !== null) { + checkNoHtml(value as Record, fullPath); + } else if (typeof value === 'string') { + // HTMLタグが含まれていないことを確認 + const hasHtml = /<[^>]+>/.test(value); + if (hasHtml) { + console.warn(`HTML in translation: ${fullPath}`); + } + expect(hasHtml).toBe(false); + } + }); + }; + + SUPPORTED_LOCALES.forEach((locale) => { + checkNoHtml(translations[locale]); + }); + }); + }); +}); diff --git a/templates/testing/examples/integration.test.ts b/templates/testing/examples/integration.test.ts new file mode 100644 index 00000000..8efd8a12 --- /dev/null +++ b/templates/testing/examples/integration.test.ts @@ -0,0 +1,108 @@ +/** + * Integration Test Example + * + * 統合テストは、複数のコンポーネントやサービスが + * 正しく連携して動作することを確認するテストです。 + * + * tests/integration/user-flow.test.ts として配置してください。 + */ + +import { createClient } from '@supabase/supabase-js'; + +// テスト用のSupabaseクライアント +const supabase = createClient( + process.env.NEXT_PUBLIC_SUPABASE_URL || 'http://localhost:54321', + process.env.SUPABASE_SERVICE_ROLE_KEY || 'test-service-role-key', +); + +describe('User Flow Integration', () => { + // テストデータのクリーンアップ + const testUserEmail = `test-${Date.now()}@example.com`; + let testUserId: string; + + beforeAll(async () => { + // テスト用ユーザーの作成 + const { data, error } = await supabase.auth.admin.createUser({ + email: testUserEmail, + password: 'TestPassword123!', + email_confirm: true, + }); + + if (error) throw error; + testUserId = data.user.id; + }); + + afterAll(async () => { + // テストユーザーの削除 + if (testUserId) { + await supabase.auth.admin.deleteUser(testUserId); + } + }); + + describe('ユーザー登録フロー', () => { + it('ユーザーがデータベースに正しく保存される', async () => { + const { data, error } = await supabase.from('users').select('*').eq('id', testUserId).single(); + + expect(error).toBeNull(); + expect(data).toBeDefined(); + expect(data.email).toBe(testUserEmail); + }); + + it('ユーザープロファイルが初期化される', async () => { + const { data, error } = await supabase.from('profiles').select('*').eq('user_id', testUserId).single(); + + expect(error).toBeNull(); + expect(data).toBeDefined(); + }); + }); + + describe('データ整合性', () => { + it('外部キー制約が正しく機能する', async () => { + // 存在しないユーザーIDでの挿入を試みる + const { error } = await supabase.from('profiles').insert({ + user_id: '00000000-0000-0000-0000-000000000000', + display_name: 'Test', + }); + + expect(error).toBeDefined(); + expect(error?.code).toBe('23503'); // Foreign key violation + }); + + it('重複メールアドレスは拒否される', async () => { + const { error } = await supabase.auth.admin.createUser({ + email: testUserEmail, // 既存のメールアドレス + password: 'AnotherPassword123!', + }); + + expect(error).toBeDefined(); + }); + }); + + describe('カスケード削除', () => { + it('ユーザー削除時に関連データも削除される', async () => { + // テスト用の一時ユーザーを作成 + const tempEmail = `temp-${Date.now()}@example.com`; + const { data: tempUser } = await supabase.auth.admin.createUser({ + email: tempEmail, + password: 'TempPassword123!', + email_confirm: true, + }); + + const tempUserId = tempUser.user?.id; + + // 関連データを作成 + await supabase.from('profiles').insert({ + user_id: tempUserId, + display_name: 'Temp User', + }); + + // ユーザーを削除 + await supabase.auth.admin.deleteUser(tempUserId!); + + // プロファイルも削除されていることを確認 + const { data: profile } = await supabase.from('profiles').select('*').eq('user_id', tempUserId).single(); + + expect(profile).toBeNull(); + }); + }); +}); diff --git a/templates/testing/examples/load.test.ts b/templates/testing/examples/load.test.ts new file mode 100644 index 00000000..9bf3a201 --- /dev/null +++ b/templates/testing/examples/load.test.ts @@ -0,0 +1,241 @@ +/** + * Load Test Example (k6 Script) + * + * 負荷テストは、システムが高負荷下で + * 正常に動作するかを検証します。 + * + * tests/load/api-load.js として配置してください。 + * + * 依存: k6 (https://k6.io/docs/getting-started/installation/) + * + * 実行: k6 run tests/load/api-load.js + * CI: k6 run --out json=results.json tests/load/api-load.js + */ + +// k6 スクリプト(JavaScript) +const k6Script = ` +import http from 'k6/http'; +import { check, sleep, group } from 'k6'; +import { Rate, Trend } from 'k6/metrics'; + +// カスタムメトリクス +const errorRate = new Rate('errors'); +const apiLatency = new Trend('api_latency'); + +// テスト設定 +export const options = { + // シナリオ定義 + scenarios: { + // スモークテスト(基本動作確認) + smoke: { + executor: 'constant-vus', + vus: 1, + duration: '30s', + tags: { test_type: 'smoke' }, + }, + + // 負荷テスト(通常負荷) + load: { + executor: 'ramping-vus', + startVUs: 0, + stages: [ + { duration: '2m', target: 50 }, // 2分で50VUまで増加 + { duration: '5m', target: 50 }, // 5分間50VUを維持 + { duration: '2m', target: 0 }, // 2分でクールダウン + ], + tags: { test_type: 'load' }, + startTime: '30s', // スモークテスト後に開始 + }, + + // スパイクテスト(急激な負荷) + spike: { + executor: 'ramping-vus', + startVUs: 0, + stages: [ + { duration: '10s', target: 100 }, // 急激に増加 + { duration: '1m', target: 100 }, // ピーク維持 + { duration: '10s', target: 0 }, // 急激に減少 + ], + tags: { test_type: 'spike' }, + startTime: '10m', // 負荷テスト後に開始 + }, + }, + + // しきい値 + thresholds: { + http_req_duration: ['p(95)<500', 'p(99)<1000'], // 95%が500ms以下 + http_req_failed: ['rate<0.01'], // エラー率1%以下 + errors: ['rate<0.01'], + }, +}; + +const BASE_URL = __ENV.BASE_URL || 'http://localhost:3000'; +let authToken = ''; + +// セットアップ(テスト開始前に1回実行) +export function setup() { + // 認証トークンを取得 + const loginRes = http.post( + \`\${BASE_URL}/api/auth/login\`, + JSON.stringify({ + email: __ENV.TEST_USER_EMAIL || 'test@example.com', + password: __ENV.TEST_USER_PASSWORD || 'password', + }), + { + headers: { 'Content-Type': 'application/json' }, + } + ); + + if (loginRes.status === 200) { + const body = JSON.parse(loginRes.body); + return { token: body.token }; + } + + return { token: '' }; +} + +// メインテスト関数 +export default function (data) { + const headers = { + 'Content-Type': 'application/json', + Authorization: \`Bearer \${data.token}\`, + }; + + group('Health Check', function () { + const res = http.get(\`\${BASE_URL}/api/health\`); + + check(res, { + 'status is 200': (r) => r.status === 200, + 'response time < 200ms': (r) => r.timings.duration < 200, + }); + + errorRate.add(res.status !== 200); + apiLatency.add(res.timings.duration); + }); + + group('Get Users', function () { + const res = http.get(\`\${BASE_URL}/api/users\`, { headers }); + + check(res, { + 'status is 200': (r) => r.status === 200, + 'response time < 500ms': (r) => r.timings.duration < 500, + 'has users array': (r) => { + try { + const body = JSON.parse(r.body); + return Array.isArray(body); + } catch { + return false; + } + }, + }); + + errorRate.add(res.status !== 200); + apiLatency.add(res.timings.duration); + }); + + group('Create User', function () { + const payload = JSON.stringify({ + name: \`Load Test User \${Date.now()}\`, + email: \`loadtest-\${Date.now()}-\${__VU}@example.com\`, + }); + + const res = http.post(\`\${BASE_URL}/api/users\`, payload, { headers }); + + check(res, { + 'status is 201': (r) => r.status === 201, + 'response time < 1000ms': (r) => r.timings.duration < 1000, + }); + + errorRate.add(res.status !== 201); + apiLatency.add(res.timings.duration); + }); + + // リクエスト間に待機時間を入れる(実際のユーザー行動をシミュレート) + sleep(Math.random() * 2 + 1); // 1-3秒 +} + +// テスト終了時の処理 +export function teardown(data) { + console.log('Load test completed'); +} +`; + +/** + * Artillery 設定ファイル(代替) + * + * artillery.yml として配置 + * 実行: npx artillery run artillery.yml + */ +const artilleryConfig = ` +config: + target: "http://localhost:3000" + phases: + - duration: 60 + arrivalRate: 5 + name: "Warm up" + - duration: 120 + arrivalRate: 50 + name: "Sustained load" + - duration: 60 + arrivalRate: 100 + name: "Peak load" + defaults: + headers: + Content-Type: "application/json" + plugins: + expect: {} + +scenarios: + - name: "Health check" + flow: + - get: + url: "/api/health" + expect: + - statusCode: 200 + + - name: "User flow" + flow: + - post: + url: "/api/auth/login" + json: + email: "{{ $processEnvironment.TEST_USER_EMAIL }}" + password: "{{ $processEnvironment.TEST_USER_PASSWORD }}" + capture: + - json: "$.token" + as: "authToken" + + - get: + url: "/api/users" + headers: + Authorization: "Bearer {{ authToken }}" + expect: + - statusCode: 200 + + - think: 2 + + - get: + url: "/api/meetings" + headers: + Authorization: "Bearer {{ authToken }}" + expect: + - statusCode: 200 +`; + +// Jest テストとしてエクスポート +describe('Load Test Configuration', () => { + it('k6スクリプトが有効な構文である', () => { + // k6スクリプトの基本構文チェック + expect(k6Script).toContain('export const options'); + expect(k6Script).toContain('export default function'); + expect(k6Script).toContain('thresholds'); + }); + + it('Artillery設定が有効なYAML構文である', () => { + expect(artilleryConfig).toContain('config:'); + expect(artilleryConfig).toContain('scenarios:'); + expect(artilleryConfig).toContain('phases:'); + }); +}); + +// 設定ファイルをエクスポート +export { k6Script, artilleryConfig }; diff --git a/templates/testing/examples/mutation.config.js b/templates/testing/examples/mutation.config.js new file mode 100644 index 00000000..535fac77 --- /dev/null +++ b/templates/testing/examples/mutation.config.js @@ -0,0 +1,84 @@ +/** + * Stryker Mutation Testing Configuration + * + * ミューテーションテストは、テストコード自体の品質を検証します。 + * ソースコードに意図的な変更(ミューテーション)を加え、 + * テストがそれを検出できるかを確認します。 + * + * stryker.conf.js としてプロジェクトルートに配置してください。 + * + * 依存パッケージ: + * npm install -D @stryker-mutator/core @stryker-mutator/jest-runner @stryker-mutator/typescript-checker + * + * 実行: npx stryker run + */ + +/** @type {import('@stryker-mutator/api/core').PartialStrykerOptions} */ +const config = { + // パッケージマネージャー + packageManager: 'npm', + + // テストランナー + testRunner: 'jest', + jest: { + configFile: 'jest.config.js', + }, + + // TypeScript チェッカー + checkers: ['typescript'], + tsconfigFile: 'tsconfig.json', + + // ミューテーション対象 + mutate: [ + 'lib/**/*.ts', + 'hooks/**/*.ts', + 'app/**/*.ts', + '!**/*.test.ts', + '!**/*.spec.ts', + '!**/__tests__/**', + '!**/node_modules/**', + ], + + // レポーター + reporters: ['html', 'clear-text', 'progress'], + htmlReporter: { + fileName: 'reports/mutation/mutation.html', + }, + + // タイムアウト設定 + timeoutMS: 60000, + timeoutFactor: 1.5, + + // 並列実行 + concurrency: 4, + + // カバレッジ分析(高速化のため) + coverageAnalysis: 'perTest', + + // しきい値(ミューテーションスコア) + thresholds: { + high: 80, + low: 60, + break: 50, // 50%未満で失敗 + }, + + // 無視するミューテーター + ignorers: [], + + // ミューテーター設定 + mutator: { + // 特定のパターンを除外 + excludedMutations: [ + 'StringLiteral', // 文字列リテラルの変更は除外 + ], + }, + + // ダッシュボード(オプション) + // dashboard: { + // project: 'github.com/your-org/your-repo', + // module: 'your-module', + // reportType: 'full', + // }, +}; + +module.exports = config; diff --git a/templates/testing/examples/performance.spec.ts b/templates/testing/examples/performance.spec.ts new file mode 100644 index 00000000..6611d4fc --- /dev/null +++ b/templates/testing/examples/performance.spec.ts @@ -0,0 +1,267 @@ +/** + * Performance Test Example + * + * パフォーマンステストは、Core Web Vitalsや + * ページロード時間を測定するテストです。 + * + * tests/performance/lighthouse.spec.ts として配置してください。 + * + * 依存パッケージ: + * npm install -D @playwright/test lighthouse + * + * CI設定例: .github/workflows/lighthouse.yml + */ + +import { test, expect } from '@playwright/test'; + +// Lighthouse の型定義 +interface LighthouseResult { + lhr: { + categories: { + performance: { score: number }; + accessibility: { score: number }; + 'best-practices': { score: number }; + seo: { score: number }; + }; + audits: { + 'first-contentful-paint': { numericValue: number }; + 'largest-contentful-paint': { numericValue: number }; + 'cumulative-layout-shift': { numericValue: number }; + 'total-blocking-time': { numericValue: number }; + 'speed-index': { numericValue: number }; + interactive: { numericValue: number }; + }; + }; +} + +// Core Web Vitals のしきい値 +const THRESHOLDS = { + // Lighthouse スコア(0-1) + performance: 0.9, + accessibility: 0.9, + bestPractices: 0.9, + seo: 0.9, + + // Core Web Vitals(ミリ秒) + lcp: 2500, // Largest Contentful Paint + fcp: 1800, // First Contentful Paint + cls: 0.1, // Cumulative Layout Shift + tbt: 200, // Total Blocking Time + tti: 3800, // Time to Interactive +}; + +test.describe('Performance Tests', () => { + test.describe('Core Web Vitals', () => { + test('ホームページのパフォーマンス', async ({ page }) => { + await page.goto('/'); + + // Performance API を使用してメトリクスを取得 + const metrics = await page.evaluate(() => { + return new Promise((resolve) => { + // ページ読み込み完了を待機 + if (document.readyState === 'complete') { + collectMetrics(); + } else { + window.addEventListener('load', collectMetrics); + } + + function collectMetrics() { + const navigation = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming; + const paint = performance.getEntriesByType('paint'); + + const fcp = paint.find((entry) => entry.name === 'first-contentful-paint'); + + resolve({ + // Navigation Timing + domContentLoaded: navigation.domContentLoadedEventEnd - navigation.startTime, + load: navigation.loadEventEnd - navigation.startTime, + ttfb: navigation.responseStart - navigation.requestStart, + + // Paint Timing + fcp: fcp ? fcp.startTime : null, + + // DOM サイズ + domElements: document.querySelectorAll('*').length, + }); + } + }); + }); + + console.log('Performance Metrics:', metrics); + + // アサーション + expect((metrics as Record).fcp).toBeLessThan(THRESHOLDS.fcp); + expect((metrics as Record).load).toBeLessThan(5000); + }); + + test('ダッシュボードのパフォーマンス', async ({ page }) => { + // ログイン + await page.goto('/login'); + await page.getByLabel(/メールアドレス/i).fill(process.env.TEST_USER_EMAIL || 'test@example.com'); + await page.getByLabel(/パスワード/i).fill(process.env.TEST_USER_PASSWORD || 'password'); + await page.getByRole('button', { name: /ログイン/i }).click(); + + await page.waitForURL(/\/dashboard/); + + // メトリクス収集 + const metrics = await page.evaluate(() => { + const navigation = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming; + + return { + load: navigation.loadEventEnd - navigation.startTime, + domElements: document.querySelectorAll('*').length, + }; + }); + + console.log('Dashboard Metrics:', metrics); + + // ダッシュボードは重いページなので緩めのしきい値 + expect(metrics.load).toBeLessThan(8000); + expect(metrics.domElements).toBeLessThan(5000); + }); + }); + + test.describe('リソース最適化', () => { + test('JavaScript バンドルサイズ', async ({ page }) => { + const jsRequests: { url: string; size: number }[] = []; + + page.on('response', async (response) => { + const url = response.url(); + if (url.endsWith('.js') || url.includes('/_next/static/')) { + const headers = response.headers(); + const size = parseInt(headers['content-length'] || '0', 10); + jsRequests.push({ url, size }); + } + }); + + await page.goto('/'); + await page.waitForLoadState('networkidle'); + + const totalJsSize = jsRequests.reduce((sum, req) => sum + req.size, 0); + console.log(`Total JS size: ${(totalJsSize / 1024).toFixed(2)} KB`); + + // 1MB以下であることを確認 + expect(totalJsSize).toBeLessThan(1024 * 1024); + }); + + test('画像の最適化', async ({ page }) => { + const imageRequests: { url: string; size: number; type: string }[] = []; + + page.on('response', async (response) => { + const contentType = response.headers()['content-type'] || ''; + if (contentType.startsWith('image/')) { + const size = parseInt(response.headers()['content-length'] || '0', 10); + imageRequests.push({ + url: response.url(), + size, + type: contentType, + }); + } + }); + + await page.goto('/'); + await page.waitForLoadState('networkidle'); + + // 各画像が 500KB 以下 + for (const img of imageRequests) { + expect(img.size).toBeLessThan(500 * 1024); + } + + // WebP または AVIF が使用されていることを確認(オプション) + const modernFormats = imageRequests.filter((img) => img.type.includes('webp') || img.type.includes('avif')); + + console.log(`Modern format images: ${modernFormats.length}/${imageRequests.length}`); + }); + + test('Third-party スクリプトの数', async ({ page }) => { + const thirdPartyScripts: string[] = []; + const ownDomain = new URL(process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:3000').hostname; + + page.on('request', (request) => { + const url = new URL(request.url()); + if (request.resourceType() === 'script' && url.hostname !== ownDomain) { + thirdPartyScripts.push(url.hostname); + } + }); + + await page.goto('/'); + await page.waitForLoadState('networkidle'); + + console.log('Third-party scripts:', [...new Set(thirdPartyScripts)]); + + // サードパーティスクリプトは5個以下 + expect(new Set(thirdPartyScripts).size).toBeLessThan(5); + }); + }); + + test.describe('インタラクション', () => { + test('ボタンクリックの応答時間', async ({ page }) => { + await page.goto('/'); + + const button = page.getByRole('button').first(); + + if (await button.isVisible()) { + const startTime = Date.now(); + + await button.click(); + + // 何らかのレスポンスを待機(例: ナビゲーション、モーダル表示) + await page.waitForTimeout(100); + + const responseTime = Date.now() - startTime; + console.log(`Button response time: ${responseTime}ms`); + + // 100ms以下 + expect(responseTime).toBeLessThan(100); + } + }); + + test('フォーム入力の遅延がない', async ({ page }) => { + await page.goto('/login'); + + const input = page.getByLabel(/メールアドレス/i); + + const startTime = Date.now(); + + await input.fill('test@example.com'); + + const fillTime = Date.now() - startTime; + console.log(`Input fill time: ${fillTime}ms`); + + // 入力に 50ms 以上かからない + expect(fillTime).toBeLessThan(50); + }); + }); + + test.describe('メモリリーク検出', () => { + test('ページ遷移でメモリが増加し続けない', async ({ page }) => { + await page.goto('/'); + + // 初期メモリ使用量を取得 + const getMemory = async () => { + return page.evaluate(() => { + // @ts-expect-error - performance.memory は Chrome のみ + return performance.memory?.usedJSHeapSize || 0; + }); + }; + + const initialMemory = await getMemory(); + + // 複数回ページ遷移 + for (let i = 0; i < 5; i++) { + await page.goto('/about'); + await page.goto('/'); + } + + const finalMemory = await getMemory(); + + if (initialMemory > 0 && finalMemory > 0) { + const memoryIncrease = finalMemory - initialMemory; + console.log(`Memory increase: ${(memoryIncrease / 1024 / 1024).toFixed(2)} MB`); + + // メモリ増加が 50MB 以下 + expect(memoryIncrease).toBeLessThan(50 * 1024 * 1024); + } + }); + }); +}); diff --git a/templates/testing/examples/property-based.test.ts b/templates/testing/examples/property-based.test.ts new file mode 100644 index 00000000..3230e633 --- /dev/null +++ b/templates/testing/examples/property-based.test.ts @@ -0,0 +1,283 @@ +/** + * Property-based Test Example + * + * プロパティベーステストは、自動生成された多数の入力値で + * 関数のプロパティ(性質)が常に成り立つことを検証します。 + * + * tests/property/validators.test.ts として配置してください。 + * + * 依存パッケージ: npm install -D fast-check + */ + +import fc from 'fast-check'; + +// テスト対象の関数(例) +function isValidEmail(email: string): boolean { + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + return emailRegex.test(email); +} + +function slugify(text: string): string { + return text + .toLowerCase() + .trim() + .replace(/[^\w\s-]/g, '') + .replace(/[\s_-]+/g, '-') + .replace(/^-+|-+$/g, ''); +} + +function sortNumbers(arr: number[]): number[] { + return [...arr].sort((a, b) => a - b); +} + +function reverseString(str: string): string { + return str.split('').reverse().join(''); +} + +function parseQueryString(query: string): Record { + if (!query || query === '?') return {}; + + return query + .replace(/^\?/, '') + .split('&') + .filter(Boolean) + .reduce( + (acc, pair) => { + const [key, value] = pair.split('='); + if (key) { + acc[decodeURIComponent(key)] = decodeURIComponent(value || ''); + } + return acc; + }, + {} as Record, + ); +} + +describe('Property-based Tests', () => { + describe('sortNumbers', () => { + it('ソート結果は常に昇順である', () => { + fc.assert( + fc.property(fc.array(fc.integer()), (arr) => { + const sorted = sortNumbers(arr); + + // すべての隣接要素が昇順 + for (let i = 0; i < sorted.length - 1; i++) { + expect(sorted[i]).toBeLessThanOrEqual(sorted[i + 1]); + } + }), + ); + }); + + it('ソート結果の長さは入力と同じ', () => { + fc.assert( + fc.property(fc.array(fc.integer()), (arr) => { + const sorted = sortNumbers(arr); + expect(sorted.length).toBe(arr.length); + }), + ); + }); + + it('ソート結果は入力と同じ要素を含む', () => { + fc.assert( + fc.property(fc.array(fc.integer()), (arr) => { + const sorted = sortNumbers(arr); + + // 各要素の出現回数が同じ + const countOriginal = arr.reduce( + (acc, n) => { + acc[n] = (acc[n] || 0) + 1; + return acc; + }, + {} as Record, + ); + + const countSorted = sorted.reduce( + (acc, n) => { + acc[n] = (acc[n] || 0) + 1; + return acc; + }, + {} as Record, + ); + + expect(countOriginal).toEqual(countSorted); + }), + ); + }); + + it('冪等性: 2回ソートしても結果は同じ', () => { + fc.assert( + fc.property(fc.array(fc.integer()), (arr) => { + const sorted1 = sortNumbers(arr); + const sorted2 = sortNumbers(sorted1); + expect(sorted1).toEqual(sorted2); + }), + ); + }); + }); + + describe('reverseString', () => { + it('2回反転すると元に戻る(対合性)', () => { + fc.assert( + fc.property(fc.string(), (str) => { + expect(reverseString(reverseString(str))).toBe(str); + }), + ); + }); + + it('反転結果の長さは入力と同じ', () => { + fc.assert( + fc.property(fc.string(), (str) => { + expect(reverseString(str).length).toBe(str.length); + }), + ); + }); + + it('最初の文字は最後に、最後の文字は最初に', () => { + fc.assert( + fc.property( + fc.string().filter((s) => s.length > 0), + (str) => { + const reversed = reverseString(str); + expect(reversed[0]).toBe(str[str.length - 1]); + expect(reversed[reversed.length - 1]).toBe(str[0]); + }, + ), + ); + }); + }); + + describe('slugify', () => { + it('結果は小文字のみ', () => { + fc.assert( + fc.property(fc.string(), (str) => { + const slug = slugify(str); + expect(slug).toBe(slug.toLowerCase()); + }), + ); + }); + + it('結果にスペースを含まない', () => { + fc.assert( + fc.property(fc.string(), (str) => { + const slug = slugify(str); + expect(slug).not.toContain(' '); + }), + ); + }); + + it('結果は有効なURL文字のみ', () => { + fc.assert( + fc.property(fc.string(), (str) => { + const slug = slugify(str); + // 英数字、ハイフン、アンダースコアのみ + expect(slug).toMatch(/^[a-z0-9-]*$/); + }), + ); + }); + + it('先頭と末尾にハイフンがない', () => { + fc.assert( + fc.property(fc.string(), (str) => { + const slug = slugify(str); + if (slug.length > 0) { + expect(slug).not.toMatch(/^-|-$/); + } + }), + ); + }); + }); + + describe('parseQueryString', () => { + it('空文字列は空オブジェクトを返す', () => { + expect(parseQueryString('')).toEqual({}); + expect(parseQueryString('?')).toEqual({}); + }); + + it('キーと値のペアが正しくパースされる', () => { + fc.assert( + fc.property( + fc.record({ + key: fc.string().filter((s) => s.length > 0 && !s.includes('&') && !s.includes('=')), + value: fc.string().filter((s) => !s.includes('&') && !s.includes('=')), + }), + ({ key, value }) => { + const query = `?${encodeURIComponent(key)}=${encodeURIComponent(value)}`; + const parsed = parseQueryString(query); + expect(parsed[key]).toBe(value); + }, + ), + ); + }); + }); + + describe('isValidEmail', () => { + it('有効なメールアドレスはtrueを返す', () => { + // 有効なメールアドレスを生成するカスタムArbitrary + const emailArb = fc + .record({ + local: fc.stringOf(fc.constantFrom(...'abcdefghijklmnopqrstuvwxyz0123456789'.split('')), { + minLength: 1, + maxLength: 10, + }), + domain: fc.stringOf(fc.constantFrom(...'abcdefghijklmnopqrstuvwxyz'.split('')), { + minLength: 2, + maxLength: 10, + }), + tld: fc.constantFrom('com', 'org', 'net', 'io', 'co.jp'), + }) + .map(({ local, domain, tld }) => `${local}@${domain}.${tld}`); + + fc.assert( + fc.property(emailArb, (email) => { + expect(isValidEmail(email)).toBe(true); + }), + ); + }); + + it('@がないアドレスはfalseを返す', () => { + fc.assert( + fc.property( + fc.string().filter((s) => !s.includes('@')), + (str) => { + expect(isValidEmail(str)).toBe(false); + }, + ), + ); + }); + }); +}); + +// カスタムArbitraryの例 +describe('Custom Arbitraries', () => { + // ユーザーオブジェクトのArbitrary + const userArb = fc.record({ + id: fc.uuid(), + name: fc.string({ minLength: 1, maxLength: 50 }), + email: fc + .record({ + local: fc.stringOf(fc.constantFrom(...'abcdefghijklmnopqrstuvwxyz0123456789'.split('')), { + minLength: 1, + maxLength: 10, + }), + domain: fc.stringOf(fc.constantFrom(...'abcdefghijklmnopqrstuvwxyz'.split('')), { + minLength: 2, + maxLength: 10, + }), + }) + .map(({ local, domain }) => `${local}@${domain}.com`), + age: fc.integer({ min: 0, max: 150 }), + isActive: fc.boolean(), + }); + + it('ユーザーオブジェクトの検証', () => { + fc.assert( + fc.property(userArb, (user) => { + expect(user.id).toBeDefined(); + expect(user.name.length).toBeGreaterThan(0); + expect(user.email).toContain('@'); + expect(user.age).toBeGreaterThanOrEqual(0); + expect(typeof user.isActive).toBe('boolean'); + }), + ); + }); +}); diff --git a/templates/testing/examples/regression-auth.spec.ts b/templates/testing/examples/regression-auth.spec.ts new file mode 100644 index 00000000..ca99a198 --- /dev/null +++ b/templates/testing/examples/regression-auth.spec.ts @@ -0,0 +1,143 @@ +/** + * Auth Regression Test Example + * + * 認証機能のリグレッションテストです。 + * 過去に発生したバグが再発していないことを確認します。 + * + * tests/regression/auth-regression.spec.ts として配置してください。 + */ + +import { test, expect } from '@playwright/test'; + +test.describe('認証リグレッションテスト', () => { + test.beforeEach(async ({ page }) => { + // 各テスト前にセッションをクリア + await page.goto('/'); + await page.evaluate(() => { + localStorage.clear(); + sessionStorage.clear(); + }); + }); + + test.describe('REG-001: ログイン後のリダイレクト', () => { + test('ログイン後、元のページにリダイレクトされる', async ({ page }) => { + // 保護されたページにアクセス + await page.goto('/dashboard/settings'); + + // ログインページにリダイレクトされる + await expect(page).toHaveURL(/\/login/); + + // redirect パラメータが含まれている + const url = new URL(page.url()); + expect(url.searchParams.get('redirect')).toBe('/dashboard/settings'); + + // ログイン + await page.getByLabel(/メールアドレス/i).fill(process.env.TEST_USER_EMAIL || 'test@example.com'); + await page.getByLabel(/パスワード/i).fill(process.env.TEST_USER_PASSWORD || 'password'); + await page.getByRole('button', { name: /ログイン/i }).click(); + + // 元のページにリダイレクト + await expect(page).toHaveURL(/\/dashboard\/settings/); + }); + }); + + test.describe('REG-002: セッション切れの処理', () => { + test('セッション切れ時にログインページにリダイレクトされる', async ({ page }) => { + // ログイン状態をシミュレート(実際のテストでは認証済みセッションを使用) + await page.goto('/dashboard'); + + // セッションを無効化 + await page.evaluate(() => { + localStorage.removeItem('supabase.auth.token'); + }); + + // APIリクエストを発生させる操作 + await page.reload(); + + // ログインページにリダイレクト + await expect(page).toHaveURL(/\/login/); + }); + }); + + test.describe('REG-003: RBAC境界値テスト', () => { + test('一般ユーザーは管理者ページにアクセスできない', async ({ page }) => { + // 一般ユーザーとしてログイン + await page.goto('/login'); + await page.getByLabel(/メールアドレス/i).fill(process.env.TEST_REGULAR_USER_EMAIL || 'user@example.com'); + await page.getByLabel(/パスワード/i).fill(process.env.TEST_REGULAR_USER_PASSWORD || 'password'); + await page.getByRole('button', { name: /ログイン/i }).click(); + + // 管理者ページにアクセス試行 + await page.goto('/admin'); + + // 403 または リダイレクト + const url = page.url(); + const is403 = await page + .getByText(/アクセス権限がありません/i) + .isVisible() + .catch(() => false); + const isRedirected = !url.includes('/admin'); + + expect(is403 || isRedirected).toBe(true); + }); + }); + + test.describe('REG-004: パスワードリセットフロー', () => { + test('パスワードリセットメールが送信される', async ({ page }) => { + await page.goto('/login'); + + // パスワードを忘れた場合のリンク + await page.getByRole('link', { name: /パスワードを忘れた/i }).click(); + + await expect(page).toHaveURL(/\/forgot-password/); + + // メールアドレスを入力 + await page.getByLabel(/メールアドレス/i).fill('test@example.com'); + await page.getByRole('button', { name: /送信/i }).click(); + + // 成功メッセージ + await expect(page.getByText(/メールを送信しました/i)).toBeVisible(); + }); + }); + + test.describe('REG-005: XSS防止', () => { + test('ログインフォームでXSSが実行されない', async ({ page }) => { + await page.goto('/login'); + + const xssPayload = ''; + + await page.getByLabel(/メールアドレス/i).fill(xssPayload); + await page.getByLabel(/パスワード/i).fill(xssPayload); + await page.getByRole('button', { name: /ログイン/i }).click(); + + // アラートが表示されないことを確認 + let alertShown = false; + page.on('dialog', () => { + alertShown = true; + }); + + await page.waitForTimeout(1000); + expect(alertShown).toBe(false); + }); + }); + + test.describe('REG-006: CSRF保護', () => { + test('CSRFトークンなしのPOSTリクエストは拒否される', async ({ page }) => { + await page.goto('/login'); + + // 直接POSTリクエストを送信(CSRFトークンなし) + const response = await page.request.post('/api/auth/login', { + data: { + email: 'test@example.com', + password: 'password', + }, + headers: { + 'Content-Type': 'application/json', + }, + }); + + // 403 または 401(CSRF検証失敗) + expect([401, 403]).toContain(response.status()); + }); + }); +}); diff --git a/templates/testing/examples/security.test.ts b/templates/testing/examples/security.test.ts new file mode 100644 index 00000000..b8876ffb --- /dev/null +++ b/templates/testing/examples/security.test.ts @@ -0,0 +1,275 @@ +/** + * Security Test Example + * + * セキュリティテストは、一般的な脆弱性が + * 存在しないことを検証するテストです。 + * + * tests/security/security.test.ts として配置してください。 + * + * CI: npm audit, ESLint security rules, license-checker + */ + +const BASE_URL = process.env.TEST_BASE_URL || 'http://localhost:3000'; + +describe('Security Tests', () => { + describe('セキュリティヘッダー', () => { + it('X-Content-Type-Options: nosniff が設定されている', async () => { + const res = await fetch(`${BASE_URL}/api/health`); + expect(res.headers.get('x-content-type-options')).toBe('nosniff'); + }); + + it('X-Frame-Options が設定されている', async () => { + const res = await fetch(`${BASE_URL}/api/health`); + const xfo = res.headers.get('x-frame-options'); + expect(['DENY', 'SAMEORIGIN']).toContain(xfo); + }); + + it('X-XSS-Protection が設定されている', async () => { + const res = await fetch(`${BASE_URL}/api/health`); + const xxp = res.headers.get('x-xss-protection'); + // 1; mode=block または無効化(モダンブラウザでは不要) + expect(xxp === null || xxp === '1; mode=block' || xxp === '0').toBe(true); + }); + + it('Content-Security-Policy が設定されている', async () => { + const res = await fetch(`${BASE_URL}/`); + const csp = res.headers.get('content-security-policy'); + // CSPが設定されている場合は検証 + if (csp) { + expect(csp).toContain('default-src'); + } + }); + + it('Strict-Transport-Security が設定されている(HTTPS)', async () => { + // ローカル環境ではスキップ + if (BASE_URL.startsWith('http://localhost')) { + return; + } + + const res = await fetch(BASE_URL); + const hsts = res.headers.get('strict-transport-security'); + expect(hsts).toBeTruthy(); + expect(hsts).toContain('max-age='); + }); + + it('X-Powered-By が削除されている', async () => { + const res = await fetch(`${BASE_URL}/api/health`); + expect(res.headers.get('x-powered-by')).toBeNull(); + }); + + it('Referrer-Policy が設定されている', async () => { + const res = await fetch(`${BASE_URL}/`); + const rp = res.headers.get('referrer-policy'); + if (rp) { + const validPolicies = [ + 'no-referrer', + 'no-referrer-when-downgrade', + 'same-origin', + 'origin', + 'strict-origin', + 'origin-when-cross-origin', + 'strict-origin-when-cross-origin', + ]; + expect(validPolicies).toContain(rp); + } + }); + + it('Permissions-Policy が設定されている', async () => { + const res = await fetch(`${BASE_URL}/`); + const pp = res.headers.get('permissions-policy'); + if (pp) { + // カメラやマイクが制限されていることを確認 + expect(pp).toMatch(/camera|microphone|geolocation/); + } + }); + }); + + describe('認証・認可', () => { + it('認証なしで保護エンドポイントにアクセスすると401', async () => { + const res = await fetch(`${BASE_URL}/api/users`); + expect(res.status).toBe(401); + }); + + it('無効なトークンで403または401', async () => { + const res = await fetch(`${BASE_URL}/api/users`, { + headers: { + Authorization: 'Bearer invalid-token', + }, + }); + expect([401, 403]).toContain(res.status); + }); + + it('期限切れトークンで401', async () => { + // 期限切れトークン(実際のテストではモックまたは実際の期限切れトークンを使用) + const expiredToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjB9.xxx'; + + const res = await fetch(`${BASE_URL}/api/users`, { + headers: { + Authorization: `Bearer ${expiredToken}`, + }, + }); + expect([401, 403]).toContain(res.status); + }); + }); + + describe('入力検証', () => { + it('SQLインジェクション攻撃が防がれる', async () => { + const sqlPayload = "'; DROP TABLE users; --"; + + const res = await fetch(`${BASE_URL}/api/users?search=${encodeURIComponent(sqlPayload)}`); + + // 500エラーではなく、適切に処理される + expect([200, 400, 401]).toContain(res.status); + }); + + it('XSS攻撃が防がれる', async () => { + const xssPayload = ''; + + const res = await fetch(`${BASE_URL}/api/users`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: xssPayload }), + }); + + if (res.ok) { + const body = await res.json(); + // スクリプトタグがエスケープされている + expect(JSON.stringify(body)).not.toContain(''; + const response = await page.goto(`${BASE_URL}/?q=${encodeURIComponent(xssPayload)}`, { + waitUntil: 'commit', + }); + const html = await response?.text(); + + // スクリプトタグがそのまま埋め込まれていない + expect(html).not.toContain(''); + }); + }); + + test.describe('SSR エラーハンドリング', () => { + test('404ページがSSRされる', async ({ page }) => { + const response = await page.goto(`${BASE_URL}/non-existent-page-12345`); + + expect(response?.status()).toBe(404); + + // 404ページのコンテンツがSSRされている + const html = await page.content(); + expect(html).toMatch(/404|not found|見つかりません/i); + }); + + test('エラー境界がSSRエラーをキャッチ', async ({ page }) => { + // エラーを引き起こすページがある場合 + const response = await page.goto(`${BASE_URL}/error-test`, { timeout: 5000 }).catch(() => null); + + if (response) { + // 500ではなくエラーページが表示される + expect(response.status()).not.toBe(500); + } + }); + }); + + test.describe('キャッシュ', () => { + test('Cache-Controlヘッダーが設定されている', async ({ page }) => { + const response = await page.goto(`${BASE_URL}/`); + const cacheControl = response?.headers()['cache-control']; + + if (cacheControl) { + console.log(`Cache-Control: ${cacheControl}`); + // キャッシュ設定が存在 + expect(cacheControl).toBeTruthy(); + } + }); + + test('静的ページは長いキャッシュ', async ({ page }) => { + const response = await page.goto(`${BASE_URL}/about`).catch(() => null); + + if (response) { + const cacheControl = response.headers()['cache-control']; + + // 静的ページは長いmax-age(または ISR の s-maxage) + if (cacheControl && !cacheControl.includes('no-cache')) { + expect(cacheControl).toMatch(/max-age=\d+|s-maxage=\d+/); + } + } + }); + + test('動的ページはno-cacheまたは短いキャッシュ', async ({ page }) => { + const response = await page.goto(`${BASE_URL}/dashboard`).catch(() => null); + + if (response && response.status() === 200) { + const cacheControl = response.headers()['cache-control']; + + // 動的ページはキャッシュしないか短い + if (cacheControl) { + const hasNoCache = cacheControl.includes('no-cache') || cacheControl.includes('no-store'); + const hasPrivate = cacheControl.includes('private'); + const hasShortMaxAge = /max-age=([0-9]+)/.exec(cacheControl)?.[1]; + const isShort = hasShortMaxAge ? parseInt(hasShortMaxAge) <= 60 : false; + + expect(hasNoCache || hasPrivate || isShort).toBe(true); + } + } + }); + }); +}); diff --git a/templates/testing/examples/visual.spec.ts b/templates/testing/examples/visual.spec.ts new file mode 100644 index 00000000..3ecc115d --- /dev/null +++ b/templates/testing/examples/visual.spec.ts @@ -0,0 +1,136 @@ +/** + * Visual Regression Test Example + * + * ビジュアルリグレッションテストは、UIの見た目が + * 意図せず変更されていないことを確認するテストです。 + * + * tests/visual/pages.spec.ts として配置してください。 + * + * 注意: 初回実行時はベースラインスナップショットが作成されます。 + * npx playwright test --update-snapshots でスナップショットを更新できます。 + */ + +import { test, expect } from '@playwright/test'; + +test.describe('ビジュアルリグレッションテスト', () => { + test.describe('認証ページ', () => { + test('ログインページのレイアウト', async ({ page }) => { + await page.goto('/login'); + + // ページの読み込み完了を待機 + await page.waitForLoadState('networkidle'); + + // スクリーンショットを撮影してベースラインと比較 + await expect(page).toHaveScreenshot('login-page.png', { + maxDiffPixelRatio: 0.01, // 1%の差異まで許容 + fullPage: true, + }); + }); + + test('新規登録ページのレイアウト', async ({ page }) => { + await page.goto('/signup'); + await page.waitForLoadState('networkidle'); + + await expect(page).toHaveScreenshot('signup-page.png', { + maxDiffPixelRatio: 0.01, + fullPage: true, + }); + }); + }); + + test.describe('ダッシュボード', () => { + test.beforeEach(async ({ page }) => { + // ログイン状態をセットアップ + await page.goto('/login'); + await page.getByLabel(/メールアドレス/i).fill(process.env.TEST_USER_EMAIL || 'test@example.com'); + await page.getByLabel(/パスワード/i).fill(process.env.TEST_USER_PASSWORD || 'password'); + await page.getByRole('button', { name: /ログイン/i }).click(); + await page.waitForURL(/\/dashboard/); + }); + + test('ダッシュボードのレイアウト', async ({ page }) => { + await page.waitForLoadState('networkidle'); + + // 動的コンテンツをマスク + await expect(page).toHaveScreenshot('dashboard.png', { + maxDiffPixelRatio: 0.02, + fullPage: true, + mask: [page.locator('[data-testid="current-time"]'), page.locator('[data-testid="user-avatar"]')], + }); + }); + + test('サイドバーナビゲーション', async ({ page }) => { + const sidebar = page.locator('[data-testid="sidebar"]'); + + await expect(sidebar).toHaveScreenshot('sidebar.png', { + maxDiffPixelRatio: 0.01, + }); + }); + }); + + test.describe('レスポンシブデザイン', () => { + test('モバイルビューでのログインページ', async ({ page }) => { + // モバイルビューポートを設定 + await page.setViewportSize({ width: 375, height: 667 }); + await page.goto('/login'); + await page.waitForLoadState('networkidle'); + + await expect(page).toHaveScreenshot('login-mobile.png', { + maxDiffPixelRatio: 0.01, + fullPage: true, + }); + }); + + test('タブレットビューでのダッシュボード', async ({ page }) => { + await page.setViewportSize({ width: 768, height: 1024 }); + + // ログイン + await page.goto('/login'); + await page.getByLabel(/メールアドレス/i).fill(process.env.TEST_USER_EMAIL || 'test@example.com'); + await page.getByLabel(/パスワード/i).fill(process.env.TEST_USER_PASSWORD || 'password'); + await page.getByRole('button', { name: /ログイン/i }).click(); + await page.waitForURL(/\/dashboard/); + await page.waitForLoadState('networkidle'); + + await expect(page).toHaveScreenshot('dashboard-tablet.png', { + maxDiffPixelRatio: 0.02, + fullPage: true, + }); + }); + }); + + test.describe('ダークモード', () => { + test('ダークモードでのログインページ', async ({ page }) => { + // ダークモードを有効化 + await page.emulateMedia({ colorScheme: 'dark' }); + await page.goto('/login'); + await page.waitForLoadState('networkidle'); + + await expect(page).toHaveScreenshot('login-dark.png', { + maxDiffPixelRatio: 0.01, + fullPage: true, + }); + }); + }); + + test.describe('コンポーネント単体', () => { + test('ボタンの各バリアント', async ({ page }) => { + // Storybookまたはコンポーネントプレビューページ + await page.goto('/components/button-preview'); + await page.waitForLoadState('networkidle'); + + await expect(page).toHaveScreenshot('buttons.png', { + maxDiffPixelRatio: 0.01, + }); + }); + + test('フォーム要素', async ({ page }) => { + await page.goto('/components/form-preview'); + await page.waitForLoadState('networkidle'); + + await expect(page).toHaveScreenshot('form-elements.png', { + maxDiffPixelRatio: 0.01, + }); + }); + }); +}); diff --git a/templates/testing/jest.config.js b/templates/testing/jest.config.js new file mode 100644 index 00000000..e4f291f0 --- /dev/null +++ b/templates/testing/jest.config.js @@ -0,0 +1,59 @@ +const nextJest = require('next/jest'); + +/** @type {import('jest').Config} */ +const createJestConfig = nextJest({ + // Provide the path to your Next.js app to load next.config.js and .env files + dir: './', +}); + +// Add any custom config to be passed to Jest +const config = { + coverageProvider: 'v8', + testEnvironment: 'jsdom', + // Polyfills are loaded before test files (before jest.mock hoists) + setupFiles: ['/jest.polyfills.js'], + // Add more setup options before each test is run + setupFilesAfterEnv: ['/jest.setup.js'], + collectCoverageFrom: [ + 'app/**/*.{js,jsx,ts,tsx}', + 'components/**/*.{js,jsx,ts,tsx}', + 'hooks/**/*.{js,jsx,ts,tsx}', + 'lib/**/*.{js,jsx,ts,tsx}', + 'contexts/**/*.{js,jsx,ts,tsx}', + '!**/*.d.ts', + '!**/node_modules/**', + '!**/.next/**', + '!**/coverage/**', + '!app/globals.css', + '!app/**/layout.tsx', + '!app/**/loading.tsx', + '!app/**/not-found.tsx', + '!app/**/error.tsx', + ], + coverageThreshold: { + global: { + branches: 70, + functions: 70, + lines: 70, + statements: 70, + }, + }, + testMatch: ['**/__tests__/**/*.(ts|tsx|js)', '**/*.(test|spec).(ts|tsx|js)'], + testPathIgnorePatterns: [ + '/.next/', + '/node_modules/', + '/e2e/', + '/tests/e2e/', + '/tests/integration/', + '/tests/regression/', + ], + moduleNameMapper: { + '^@/(.*)$': '/$1', + }, + modulePathIgnorePatterns: ['/.next/'], + moduleDirectories: ['node_modules', '/'], + testTimeout: 10000, +}; + +// createJestConfig is exported this way to ensure that next/jest can load the Next.js config which is async +module.exports = createJestConfig(config); diff --git a/templates/testing/jest.polyfills.js b/templates/testing/jest.polyfills.js new file mode 100644 index 00000000..3cc6e793 --- /dev/null +++ b/templates/testing/jest.polyfills.js @@ -0,0 +1,229 @@ +/** + * Web API polyfills for Jest + * + * This file is loaded before test files via setupFiles (not setupFilesAfterEnv) + * so that Web globals are available when jest.mock() hoists are evaluated. + * + * We create minimal mocks that are sufficient for Next.js API route tests. + */ + +// Headers mock +class MockHeaders { + constructor(init) { + this._headers = new Map(); + if (init) { + if (init instanceof MockHeaders) { + init._headers.forEach((value, key) => this._headers.set(key, value)); + } else if (Array.isArray(init)) { + init.forEach(([key, value]) => this._headers.set(key.toLowerCase(), value)); + } else if (typeof init === 'object') { + Object.entries(init).forEach(([key, value]) => this._headers.set(key.toLowerCase(), value)); + } + } + } + get(name) { + return this._headers.get(name.toLowerCase()) || null; + } + set(name, value) { + this._headers.set(name.toLowerCase(), value); + } + has(name) { + return this._headers.has(name.toLowerCase()); + } + delete(name) { + this._headers.delete(name.toLowerCase()); + } + forEach(callback) { + this._headers.forEach((value, key) => callback(value, key, this)); + } + entries() { + return this._headers.entries(); + } + keys() { + return this._headers.keys(); + } + values() { + return this._headers.values(); + } + [Symbol.iterator]() { + return this._headers.entries(); + } +} + +// Response mock +class MockResponse { + constructor(body, init = {}) { + this._body = body; + this.status = init.status || 200; + this.statusText = init.statusText || ''; + this.ok = this.status >= 200 && this.status < 300; + this.headers = new MockHeaders(init.headers); + this.body = null; + this.bodyUsed = false; + } + async json() { + this.bodyUsed = true; + if (typeof this._body === 'string') { + return JSON.parse(this._body); + } + return this._body; + } + async text() { + this.bodyUsed = true; + if (typeof this._body === 'string') { + return this._body; + } + return JSON.stringify(this._body); + } + async arrayBuffer() { + this.bodyUsed = true; + const text = await this.text(); + return new TextEncoder().encode(text).buffer; + } + async blob() { + this.bodyUsed = true; + return new Blob([await this.text()]); + } + clone() { + return new MockResponse(this._body, { + status: this.status, + statusText: this.statusText, + headers: this.headers, + }); + } + static json(data, init = {}) { + return new MockResponse(JSON.stringify(data), { + ...init, + headers: { + 'content-type': 'application/json', + ...(init.headers || {}), + }, + }); + } + static redirect(url, status = 302) { + return new MockResponse(null, { + status, + headers: { Location: url }, + }); + } +} + +// Request mock +class MockRequest { + constructor(input, init = {}) { + if (typeof input === 'string') { + this.url = input; + } else if (input instanceof MockRequest) { + this.url = input.url; + init = { ...input, ...init }; + } else { + this.url = input.url || ''; + } + this.method = (init.method || 'GET').toUpperCase(); + this.headers = new MockHeaders(init.headers); + this._body = init.body; + this.body = null; + this.bodyUsed = false; + this.cache = init.cache || 'default'; + this.credentials = init.credentials || 'same-origin'; + this.mode = init.mode || 'cors'; + this.redirect = init.redirect || 'follow'; + this.referrer = init.referrer || 'about:client'; + } + async json() { + this.bodyUsed = true; + if (typeof this._body === 'string') { + return JSON.parse(this._body); + } + return this._body; + } + async text() { + this.bodyUsed = true; + if (typeof this._body === 'string') { + return this._body; + } + return JSON.stringify(this._body); + } + async arrayBuffer() { + this.bodyUsed = true; + const text = await this.text(); + return new TextEncoder().encode(text).buffer; + } + clone() { + return new MockRequest(this.url, { + method: this.method, + headers: this.headers, + body: this._body, + }); + } +} + +// FormData mock +class MockFormData { + constructor() { + this._data = new Map(); + } + append(name, value) { + if (!this._data.has(name)) { + this._data.set(name, []); + } + this._data.get(name).push(value); + } + delete(name) { + this._data.delete(name); + } + get(name) { + const values = this._data.get(name); + return values ? values[0] : null; + } + getAll(name) { + return this._data.get(name) || []; + } + has(name) { + return this._data.has(name); + } + set(name, value) { + this._data.set(name, [value]); + } + entries() { + const entries = []; + this._data.forEach((values, key) => { + values.forEach((value) => entries.push([key, value])); + }); + return entries[Symbol.iterator](); + } + keys() { + return this._data.keys(); + } + values() { + const values = []; + this._data.forEach((vals) => values.push(...vals)); + return values[Symbol.iterator](); + } + forEach(callback) { + this._data.forEach((values, key) => { + values.forEach((value) => callback(value, key, this)); + }); + } +} + +// Set Web globals +global.Request = MockRequest; +global.Response = MockResponse; +global.Headers = MockHeaders; +global.FormData = MockFormData; + +// Ensure URL and URLSearchParams are available (Node.js provides these) +if (typeof global.URL === 'undefined') { + global.URL = URL; +} +if (typeof global.URLSearchParams === 'undefined') { + global.URLSearchParams = URLSearchParams; +} + +// Ensure TextEncoder and TextDecoder are available +if (typeof global.TextEncoder === 'undefined') { + const { TextEncoder, TextDecoder } = require('util'); + global.TextEncoder = TextEncoder; + global.TextDecoder = TextDecoder; +} diff --git a/templates/testing/jest.regression.config.js b/templates/testing/jest.regression.config.js new file mode 100644 index 00000000..01c456bf --- /dev/null +++ b/templates/testing/jest.regression.config.js @@ -0,0 +1,29 @@ +/** + * Jest Configuration for Regression Tests + * + * リグレッションテスト用の設定。 + * API エンドポイントへの実際のリクエストを行うため、 + * タイムアウトを長めに設定し、Node.js 環境で実行する。 + */ + +/** @type {import('jest').Config} */ +module.exports = { + testEnvironment: 'node', + testMatch: ['**/tests/regression/**/*.test.ts'], + transform: { + '^.+\\.tsx?$': [ + 'ts-jest', + { + tsconfig: 'tsconfig.json', + }, + ], + }, + // API呼び出しを含むため長めのタイムアウト + testTimeout: 30000, + // 並列実行数を制限(サーバー負荷軽減) + maxWorkers: 2, + // テスト失敗時に即座に停止しない + bail: false, + // 詳細なエラー表示 + verbose: true, +}; diff --git a/templates/testing/jest.scenario.config.js b/templates/testing/jest.scenario.config.js new file mode 100644 index 00000000..05fe4d4b --- /dev/null +++ b/templates/testing/jest.scenario.config.js @@ -0,0 +1,29 @@ +/** + * Jest Configuration for Scenario Tests + * + * シナリオテスト(ビジネスフロー)用の設定。 + * 複数ステップのフローを順番に実行するため、 + * 直列実行(runInBand)を使用する。 + */ + +/** @type {import('jest').Config} */ +module.exports = { + testEnvironment: 'node', + testMatch: ['**/tests/scenario/**/*.test.ts', '**/tests/integration/scenario*.test.ts'], + transform: { + '^.+\\.tsx?$': [ + 'ts-jest', + { + tsconfig: 'tsconfig.json', + }, + ], + }, + // シナリオテストは長時間実行される可能性がある + testTimeout: 60000, + // シナリオテストは順番に実行する必要がある + maxWorkers: 1, + // 詳細なログ出力 + verbose: true, + // テスト失敗時に即座に停止(シナリオの途中で失敗した場合、後続は意味がない) + bail: true, +}; diff --git a/templates/testing/jest.setup.js b/templates/testing/jest.setup.js new file mode 100644 index 00000000..50d08ebf --- /dev/null +++ b/templates/testing/jest.setup.js @@ -0,0 +1,55 @@ +import '@testing-library/jest-dom'; + +// Mock Next.js router +jest.mock('next/navigation', () => ({ + useRouter() { + return { + push: jest.fn(), + replace: jest.fn(), + prefetch: jest.fn(), + back: jest.fn(), + forward: jest.fn(), + refresh: jest.fn(), + }; + }, + useSearchParams() { + return new URLSearchParams(); + }, + usePathname() { + return '/'; + }, +})); + +// Mock environment variables +process.env.NEXT_PUBLIC_SUPABASE_URL = 'https://test.supabase.co'; +process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY = 'test-anon-key'; + +// Mock fetch for API tests +global.fetch = jest.fn(); + +// Mock ResizeObserver for chart components +global.ResizeObserver = jest.fn().mockImplementation(() => ({ + observe: jest.fn(), + unobserve: jest.fn(), + disconnect: jest.fn(), +})); + +// Mock AudioContext for audio-related tests +global.AudioContext = jest.fn().mockImplementation(() => ({ + createMediaStreamSource: jest.fn(), + createScriptProcessor: jest.fn(), + destination: {}, +})); + +// Mock MediaRecorder for recording tests +global.MediaRecorder = jest.fn().mockImplementation(() => ({ + start: jest.fn(), + stop: jest.fn(), + addEventListener: jest.fn(), + removeEventListener: jest.fn(), +})); + +// Clean up after each test +afterEach(() => { + jest.clearAllMocks(); +}); diff --git a/templates/testing/playwright.config.ts b/templates/testing/playwright.config.ts new file mode 100644 index 00000000..b865b531 --- /dev/null +++ b/templates/testing/playwright.config.ts @@ -0,0 +1,66 @@ +import { defineConfig, devices } from '@playwright/test'; + +/** + * Playwright E2E Test Configuration + * @see https://playwright.dev/docs/test-configuration + */ +export default defineConfig({ + testDir: './tests/e2e', + /* Run tests in files in parallel */ + fullyParallel: true, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: process.env.CI ? 2 : 0, + /* Run tests in parallel on CI with limited workers to avoid resource exhaustion */ + workers: process.env.CI ? 4 : undefined, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: 'html', + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Base URL to use in actions like `await page.goto('/')`. */ + /* 127.0.0.1 を使用して Supabase の site_url と一致させ、ログイン後のリダイレクトを正常に処理する */ + baseURL: process.env.PLAYWRIGHT_BASE_URL || 'http://127.0.0.1:3000', + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: 'on-first-retry', + /* Take screenshot only when test fails */ + screenshot: 'only-on-failure', + /* Record video only when test fails */ + video: 'retain-on-failure', + /* Ignore HTTPS errors for local development environment */ + ignoreHTTPSErrors: true, + }, + + /* Configure projects for major browsers */ + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + + { + name: 'firefox', + use: { ...devices['Desktop Firefox'] }, + }, + + // webkit は CI 環境で TLS ハンドシェイクエラーが発生するため除外 + // ローカル開発時は通常通り実行される + ...(process.env.CI + ? [] + : [ + { + name: 'webkit', + use: { ...devices['Desktop Safari'] }, + }, + ]), + + /* Test against branded browsers. */ + { + name: 'Microsoft Edge', + use: { ...devices['Desktop Edge'], channel: 'msedge' }, + }, + ], + + /* Run your local dev server before starting the tests */ + // webServerは使用せず、既存のサーバーを利用 +}); diff --git a/templates/testing/playwright.regression.config.ts b/templates/testing/playwright.regression.config.ts new file mode 100644 index 00000000..c9110ce7 --- /dev/null +++ b/templates/testing/playwright.regression.config.ts @@ -0,0 +1,30 @@ +import { defineConfig, devices } from '@playwright/test'; + +/** + * Playwright Configuration for Regression Tests + * + * リグレッションテスト用の設定。 + * 本番環境に近い条件でテストを実行する。 + */ +export default defineConfig({ + testDir: './tests/regression', + testMatch: '**/*.spec.ts', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + // リグレッションテストは安定性重視でワーカー数を制限 + workers: process.env.CI ? 2 : undefined, + reporter: process.env.CI ? [['html'], ['list']] : 'list', + use: { + baseURL: process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:3000', + trace: 'on-first-retry', + screenshot: 'only-on-failure', + video: 'retain-on-failure', + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], +}); From 7709b214ee54ae58bb041fddf6ad583aacc12ccd Mon Sep 17 00:00:00 2001 From: keito4 Date: Thu, 5 Mar 2026 10:06:03 +0900 Subject: [PATCH 2/3] =?UTF-8?q?fix:=20CI=E5=A4=B1=E6=95=97=E3=82=92?= =?UTF-8?q?=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - security.test.ts: Gitleaks誤検出を回避(テスト用トークンを変更) - jest.config.js: templates/ をカバレッジ対象から除外 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- jest.config.js | 1 + templates/testing/examples/security.test.ts | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/jest.config.js b/jest.config.js index 708fd5f6..b9bb8fb3 100644 --- a/jest.config.js +++ b/jest.config.js @@ -7,6 +7,7 @@ module.exports = { '!node_modules/**/*', '!coverage/**/*', '!dist/**/*', + '!templates/**/*', '!**/*.test.js', '!**/*.spec.js', ], diff --git a/templates/testing/examples/security.test.ts b/templates/testing/examples/security.test.ts index b8876ffb..77a457e6 100644 --- a/templates/testing/examples/security.test.ts +++ b/templates/testing/examples/security.test.ts @@ -101,7 +101,8 @@ describe('Security Tests', () => { it('期限切れトークンで401', async () => { // 期限切れトークン(実際のテストではモックまたは実際の期限切れトークンを使用) - const expiredToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjB9.xxx'; + // gitleaks:allow - テスト用の無効なトークン例 + const expiredToken = 'expired-test-token-for-testing-purposes-only'; const res = await fetch(`${BASE_URL}/api/users`, { headers: { From 508f34ecf743c95a385a23e0daa7e3e9231eaad3 Mon Sep 17 00:00:00 2001 From: keito4 Date: Thu, 5 Mar 2026 10:09:16 +0900 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20Gitleaks=E8=A8=AD=E5=AE=9A=E3=82=92?= =?UTF-8?q?=E8=BF=BD=E5=8A=A0=EF=BC=88=E3=83=86=E3=82=B9=E3=83=88=E3=83=95?= =?UTF-8?q?=E3=82=A1=E3=82=A4=E3=83=AB=E3=82=92=E8=A8=B1=E5=8F=AF=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit templates/testing/examples/ ディレクトリをGitleaks許可リストに追加 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .gitleaks.toml | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 .gitleaks.toml diff --git a/.gitleaks.toml b/.gitleaks.toml new file mode 100644 index 00000000..e49473f8 --- /dev/null +++ b/.gitleaks.toml @@ -0,0 +1,10 @@ +# Gitleaks configuration +# https://github.com/gitleaks/gitleaks + +[allowlist] +description = "Allowlist for test files and example tokens" + +# Allow test/example tokens in testing template files +paths = [ + '''templates/testing/examples/.*''' +]