Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
609 changes: 609 additions & 0 deletions .claude/commands/setup-tests.md

Large diffs are not rendered by default.

10 changes: 10 additions & 0 deletions .gitleaks.toml
Original file line number Diff line number Diff line change
@@ -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/.*'''
]
1 change: 1 addition & 0 deletions jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ module.exports = {
'!node_modules/**/*',
'!coverage/**/*',
'!dist/**/*',
'!templates/**/*',
'!**/*.test.js',
'!**/*.spec.js',
],
Expand Down
492 changes: 492 additions & 0 deletions templates/testing/README.md

Large diffs are not rendered by default.

154 changes: 154 additions & 0 deletions templates/testing/ci-test-jobs.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
# CI Test Jobs Template
# このファイルは ci.yml に組み込むためのテストジョブ定義です

# =========================================
# Unit Tests & Coverage
# =========================================
unit-tests:
name: Unit Tests
runs-on: ubuntu-latest
needs: quality-gate
timeout-minutes: 15
steps:
- uses: actions/checkout@v4

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'

- name: Install dependencies
run: npm ci

- name: Run tests with coverage
run: npm test -- --coverage --watchAll=false

- name: Upload coverage to Codecov
uses: codecov/codecov-action@v4
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: ./coverage/lcov.info
verbose: true
fail_ci_if_error: false

- name: Comment PR with coverage report
if: github.event_name == 'pull_request'
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const coverageSummary = JSON.parse(
fs.readFileSync('./coverage/coverage-summary.json', 'utf8')
);
const total = coverageSummary.total;

const body = `## Coverage Report

| Metric | Coverage |
|--------|----------|
| Lines | ${total.lines.pct}% |
| Statements | ${total.statements.pct}% |
| Functions | ${total.functions.pct}% |
| Branches | ${total.branches.pct}% |
`;

github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: body
});

# =========================================
# E2E Tests (Critical Path)
# =========================================
e2e-critical:
name: E2E Critical Path
runs-on: ubuntu-latest
needs: [quality-gate, unit-tests]
if: github.event_name == 'pull_request'
timeout-minutes: 20
steps:
- uses: actions/checkout@v4

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'

- name: Install dependencies
run: npm ci

- name: Install Playwright browsers
run: npx playwright install --with-deps chromium

- name: Build application
run: npm run build
env:
# Dummy API keys for build
OPENAI_API_KEY: 'sk-dummy-key-for-ci-build'
ANTHROPIC_API_KEY: 'sk-ant-dummy-key-for-ci-build'

- name: Start server and run E2E tests
run: |
npm start &
sleep 10
npx playwright test --project=chromium tests/e2e/
env:
PLAYWRIGHT_BASE_URL: 'http://127.0.0.1:3000'

- name: Upload Playwright report
uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-report-critical
path: playwright-report/
retention-days: 7

# =========================================
# E2E Tests (Full Suite) - main branch only
# =========================================
e2e-full:
name: E2E Full Suite
runs-on: ubuntu-latest
needs: [quality-gate, unit-tests]
if: github.ref == 'refs/heads/main'
timeout-minutes: 30
steps:
- uses: actions/checkout@v4

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'

- name: Install dependencies
run: npm ci

- name: Install Playwright browsers
run: npx playwright install --with-deps

- name: Build application
run: npm run build
env:
OPENAI_API_KEY: 'sk-dummy-key-for-ci-build'
ANTHROPIC_API_KEY: 'sk-ant-dummy-key-for-ci-build'

- name: Start server and run E2E tests
run: |
npm start &
sleep 10
npx playwright test
env:
PLAYWRIGHT_BASE_URL: 'http://127.0.0.1:3000'

- name: Upload Playwright report
uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report-full
path: playwright-report/
retention-days: 14
201 changes: 201 additions & 0 deletions templates/testing/examples/a11y.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
/**
* Accessibility Test Example
*
* アクセシビリティテストは、WCAG 2.1 ガイドラインに
* 準拠しているかを確認するテストです。
*
* tests/a11y/pages.spec.ts として配置してください。
*
* 依存パッケージ: npm install -D @axe-core/playwright
*/

import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';

test.describe('アクセシビリティテスト', () => {
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);
});
});
});
Loading
Loading