Gotchas
- スタック検出なしでのテスト生成禁止 — React/Vue/Svelte/Flutter/HTML など、プロジェクトのフレームワークを最初に検出せよ。Flutter プロジェクトに Playwright テストを生成してはならない。
- WCAG 2.2 AA 基準未適用の禁止 — アクセシビリティテストは必ず WCAG 2.2 AA を基準とする。コントラスト比 4.5:1(本文)、3:1(大きなテキスト/非テキスト UI)、タッチターゲット 24×24px(AA) / 44×44px(AAA)。出典: design-kit design-audit Gotcha #3
- axe-core ルールセットのバージョン確認 —
@axe-core/playwrightまたはaxe-coreを使用する場合、プロジェクトにインストールされたバージョンのルールセットを確認せよ。メジャーバージョン間でルールに差異がある。 - 視覚的回帰テストの threshold 未設定禁止 — ピクセル単位の比較は、アンチエイリアシングやフォントレンダリングの違いにより false positive が頻発する。
maxDiffPixelRatio: 0.01のような threshold が必須。 - トークン検証におけるハードコード検出範囲の明確化 — CSS で
#ff0000、16px、400のような raw value があっても、即 FAIL ではない。reset/normalize CSS、外部ライブラリのコードは除外せよ。 - レスポンシブテストにおけるブレークポイントの出典確認 — プロジェクトのデザインシステムで定義されたブレークポイントを使用せよ。任意に 320/768/1024 を設定してはならない。
- ダークモードテストの欠落禁止 — デザイントークンにダークモードのマッピングがある場合、必ず両方のモードでテストする。
prefers-color-scheme: darkメディアクエリまたはトークンクラスの切り替えを使用せよ。 - Storybook 依存の確認 — 視覚的回帰テストで Storybook を前提としてならない。Storybook がない場合は、Playwright のページスクリーンショットで代替する。
- APCA コントラストアルゴリズムは情報提供のみ — WCAG 2.2 AA (4.5:1 WCAG2 公式) が判定基準。APCA Lc は追加情報提供用としてのみ含め、PASS/FAIL の判定基準として使用してはならない。出典: design-guide Gotcha #10
- 初回実行ベースライン生成を「テスト合格」と見なすことの禁止(negative control 必須) — Playwright はスナップショットがない場合、実際の画面をベースラインとして 自動記録し、その実行を合格として処理する。したがって、
--update-snapshots直後の green は何も証明しない — 何とも比較されていないからである(証拠有効性検証 2 の有効化失敗)。ベースラインを作成した後は、必ず negative control を 1 回実行せよ: 対象要素に意図的な変更(例:背景色を 1 段階変更)を加えた状態でテストを実行し 失敗することを確認し、元に戻して合格することを確認する。この 2 回の実行の出力を証拠として引用するまでは、視覚的回帰テストが動作していると報告してはならない。出典: Playwright Visual Comparisons (初回実行時「A snapshot doesn't exist ... writing actual」)、harness/docs/guides/qa-evaluation-guide.md§Evidence Validity Gate 検査 2·3。 - 空のスナップショット/空ページは PASS の証拠ではない — レンダリングに失敗して空の画面がキャプチャされても、視覚的回帰テストはベースラインと同一であれば合格する。視覚テストには コンテンツ存在のアサーションを一緒に生成 せよ(核となる要素の visible、リスト項目数 ≥ 1、コンテナの高さ > 0)。スナップショット比較のみのテストファイルは、unbounded-height collapse のような欠陥を通過させる — 実際の事故例である。詳細:
../../references/visual-change-protocol.md§3。 - 部分変更の検証はスコープ指定スナップショットで — 意図外領域の変化を検出 — 「ボーダーだけ変えた」を検証する際に、ページ全体のスナップショット 1 つだけを使うと、背景まで変わったことを区別できない。変更対象要素の locator 単位のスナップショット と 周辺領域のスナップショット を分離して生成し、対象は変化し、周辺は変化していないことをそれぞれ判定せよ。周辺領域のスナップショットが失敗した場合、それは回帰である。詳細:
../../references/visual-change-protocol.md§2。 prefers-reduced-motion: reduceは「アニメーション除去」ではない — この設定は、前庭誘発性トリガー(scale·pan など移動感を与えるモーション)を より穏やかな代替手段に置き換える ことを意味し、すべてのトランジションをなくすことを意味しない。したがって、reduced-motion テストを「アニメーション数 0」として assert してはならない。transform: scale/translateベースのモーションが消えるか、opacity/dissolve に置き換えられたかを検証せよ。出典: MDN prefers-reduced-motion (Baseline 2020-01)。- ゴールデンのみの決定伝播テストは FAIL — Decision Propagation Manifest Coverage — 確定したデザイン決定がどの画面に反映されるべきかは、
.design/decisions.yamlが列挙する (decision_id→required_surfaces[]→ golden + assertions)。この manifest が存在する場合は、カバレッジ規則 4 条に従ってテストを生成せよ。核心は golden のみで、核となる要素の visible / count / height アサーションがない場合は FAIL である — 空の画面もベースラインと同じであれば合格するため、スナップショット単独では「ユーザーが見る」ことを証明できない(Gotcha 11 と同じ根源)。excluded_surfacesに理由なく欠落した画面は、レビューされたのではなく カバレッジの空白 である。スキーマ・ルール・実行可能なチェッカー:../../references/visual-change-protocol.md§6 Decision Propagation Manifest。 - 証拠にチャネル名を付けよ — スナップショットの存在はユーザー観測ではない —
artifact_snapshot/dom_snapshot/browser_user_visible/device_user_visibleは強度が異なる。PASS 文には viewport · route/state · visible locator · count/height · screenshot/golden id の 5 要素が含まれなければならず、一つでも欠ければ[未検証]である。チャネル定義:../../references/visual-change-protocol.md§7 Evidence Channels。ユーザーが失敗を報告した場合の優先順位規約はharness/docs/guides/skill-design-guide.md§3.8 が原本である — テスト合格を根拠に反論してはならない。
プロセス
Step 0: プロジェクト検出
プロジェクトルートから UI スタックとデザインシステムを検出する:
| 検出対象 | スタック | テストツール |
|---|---|---|
package.json + React | React | Playwright + axe-core + Storybook (任意) |
package.json + Vue | Vue | Playwright + axe-core |
package.json + Svelte | Svelte | Playwright + axe-core |
package.json + Angular | Angular | Playwright + axe-core |
pubspec.yaml + Flutter | Flutter | → flutter-test スキルにリダイレクト |
*.html (静的サイト) | HTML/CSS | Playwright + axe-core + pa11y |
追加検出:
- デザイントークン:
**/tokens/**、**/theme/**、**/design-system/**、CSS カスタムプロパティ (--) - Storybook:
.storybook/、*.stories.* - Tailwind:
tailwind.config.* - CSS-in-JS: styled-components、emotion、vanilla-extract
Step 1: 対象分析
$ARGUMENTS から対象とテストタイプをパースする。
タイプ未指定時の自動推論:
| 対象特性 | テストタイプ |
|---|---|
| トークンファイル、テーマ設定 | token (一貫性検証) |
| コンポーネント、ページ | a11y (アクセシビリティ) + visual (視覚的回帰) |
| レイアウト、グリッド | responsive (レスポンシブ) |
| プロジェクト全体 | 全タイプ |
Step 2: 既存パターン探索
- 既存のアクセシビリティテスト:
*.a11y.test.*、axe-core import - 既存の視覚テスト:
*.visual.test.*、__snapshots__/、.loki/ - Playwright 設定:
playwright.config.* - Storybook テスト:
*.stories.*の有無 - デザイントークン検証: 既存の lint ルール、stylelint 設定
Step 3: トークン一貫性テスト生成
デザイントークンファイルを分析し、一貫性を検証するテストを生成する。
// tests/design/tokens.test.ts
import { describe, it, expect } from 'vitest'
import fs from 'fs'
describe('Design Token Consistency', () => {
// CSS カスタムプロパティのパース
const cssContent = fs.readFileSync('src/styles/tokens.css', 'utf-8')
const tokens = [...cssContent.matchAll(/--([^:]+):\s*([^;]+)/g)]
it('all color tokens use consistent format (hex/oklch/hsl)', () => {
const colorTokens = tokens.filter(([, name]) => name.includes('color'))
const formats = new Set(
colorTokens.map(([, , value]) => {
if (value.trim().startsWith('#')) return 'hex'
if (value.trim().startsWith('oklch')) return 'oklch'
if (value.trim().startsWith('hsl')) return 'hsl'
return 'other'
})
)
expect(formats.size).toBeLessThanOrEqual(1)
})
it('spacing tokens follow scale (base * multiplier)', () => {
const spacingTokens = tokens
.filter(([, name]) => name.includes('spacing'))
.map(([, , value]) => parseFloat(value))
.sort((a, b) => a - b)
for (let i = 1; i < spacingTokens.length; i++) {
const ratio = spacingTokens[i] / spacingTokens[i - 1]
expect(ratio).toBeGreaterThan(1)
}
})
it('no hardcoded values in component files', () => {
const componentCss = fs.readFileSync('src/components/Button.css', 'utf-8')
const rawColors = [...componentCss.matchAll(/#[0-9a-fA-F]{3,8}/g)]
const rawPx = [...componentCss.matchAll(/:\s*\d+px/g)]
// reset、normalize、外部ライブラリファイルは検査対象から除外
expect(rawColors).toEqual([])
expect(rawPx).toEqual([])
})
it('dark mode tokens cover all light mode tokens', () => {
const lightNames = tokens
.filter(([, name]) => !name.includes('dark'))
.map(([, name]) => name.replace('light-', ''))
const darkNames = tokens
.filter(([, name]) => name.includes('dark'))
.map(([, name]) => name.replace('dark-', ''))
const missing = lightNames.filter(n => !darkNames.includes(n))
expect(missing).toEqual([])
})
})
Step 4: アクセシビリティテスト生成
// tests/design/a11y.test.ts
import { test, expect } from '@playwright/test'
import AxeBuilder from '@axe-core/playwright'
const pages = [
{ name: 'Home', path: '/' },
{ name: 'Settings', path: '/settings' },
// Step 1 で検出されたルート一覧
]
for (const page of pages) {
test.describe(`${page.name} Accessibility`, () => {
test('should pass axe-core WCAG 2.2 AA', async ({ page: p }) => {
await p.goto(page.path)
const results = await new AxeBuilder({ page: p })
.withTags(['wcag2a', 'wcag2aa', 'wcag22aa'])
.analyze()
expect(results.violations).toEqual([])
})
test('all interactive elements have accessible names', async ({ page: p }) => {
await p.goto(page.path)
const buttons = await p.locator('button, [role="button"]').all()
for (const btn of buttons) {
const name = await btn.getAttribute('aria-label')
?? await btn.textContent()
expect(name?.trim()).toBeTruthy()
}
})
test('color contrast meets 4.5:1 ratio', async ({ page: p }) => {
await p.goto(page.path)
const results = await new AxeBuilder({ page: p })
.withRules(['color-contrast'])
.analyze()
expect(results.violations).toEqual([])
})
test('touch targets are at least 24x24px', async ({ page: p }) => {
await p.setViewportSize({ width: 375, height: 812 })
await p.goto(page.path)
const targets = await p.locator(
'button, a, input, select, [role="button"], [role="link"]'
).all()
for (const target of targets) {
const box = await target.boundingBox()
if (box) {
expect(box.width).toBeGreaterThanOrEqual(24)
expect(box.height).toBeGreaterThanOrEqual(24)
}
}
})
})
}
// ダークモードのアクセシビリティ(トークンにダークモードがある場合)
test('dark mode passes color contrast', async ({ page }) => {
await page.emulateMedia({ colorScheme: 'dark' })
await page.goto('/')
const results = await new AxeBuilder({ page })
.withRules(['color-contrast'])
.analyze()
expect(results.violations).toEqual([])
})
Step 5: 視覚的回帰テスト生成
// tests/design/visual-regression.test.ts
import { test, expect } from '@playwright/test'
const viewports = [
{ name: 'mobile', width: 375, height: 812 },
{ name: 'tablet', width: 768, height: 1024 },
{ name: 'desktop', width: 1440, height: 900 },
]
const pages = [
{ name: 'Home', path: '/' },
// Step 1 で検出された主要ページ
]
for (const vp of viewports) {
for (const pg of pages) {
test(`${pg.name} - ${vp.name} visual snapshot`, async ({ page }) => {
await page.setViewportSize({ width: vp.width, height: vp.height })
await page.goto(pg.path)
await page.waitForLoadState('networkidle')
// Gotcha 11 — 空のレンダリングがベースラインと一致して合格するのを防ぐコンテンツ存在アサーション。
// スナップショット比較の「前に」実行することで、空の画面がベースラインとして固定されるのも同時に防ぐ。
const main = page.locator('main, [role="main"]').first()
await expect(main).toBeVisible()
const box = await main.boundingBox()
expect(box?.height ?? 0).toBeGreaterThan(0)
await expect(page).toHaveScreenshot(
`${pg.name}-${vp.name}.png`,
{ maxDiffPixelRatio: 0.01 }
)
})
}
}
// Storybook がある場合 — コンポーネント単位の視覚テスト
// Storybook 検出時のみこのセクションを生成
部分変更(スコープ指定)検証 — 特定の要素の特定の属性のみを変更した変更を検証する際は、対象と周辺を分離する (Gotcha 12)。周辺のスナップショットが失敗した場合は、意図外の領域が変化したことになるため、回帰として扱う。
// tests/design/scoped-change.test.ts
import { test, expect } from '@playwright/test'
test.describe('Scoped visual change — 対象のみ変化、周辺は不変', () => {
test('対象要素スナップショット(変更許容)', async ({ page }) => {
await page.goto('/')
await expect(page.getByTestId('target-card'))
.toHaveScreenshot('target-card.png', { maxDiffPixelRatio: 0.01 })
})
// 周辺領域は「変化してはならない」ため、対象より厳格な threshold を使用する。
// ただし Gotcha 4 に従い 0 は使用しない — アンチエイリアシング・フォントレンダリングの違いで常時失敗する。
test('周辺領域スナップショット(変更禁止 — 失敗時は回帰)', async ({ page }) => {
await page.goto('/')
await expect(page.getByTestId('surrounding-panel'))
.toHaveScreenshot('surrounding-panel.png', { maxDiffPixelRatio: 0.001 })
})
})
Step 5-b: 決定伝播テスト生成(.design/decisions.yaml がある場合のみ)
manifest がない場合はこのステップをスキップするが、「該当なし」ではなく「manifest 不在」として報告する
(チェッカーが NO_MANIFEST + exit 3 を出力する理由と同じ — 対象 0 件と合格は異なる)。
manifest がある場合は、status: approved の decision_id ごとに required_surfaces[] を反復し、surface ごとにテスト 1 つを
生成する。status: superseded(代替済み)の決定はテストを作成しない — その画面の場所は superseded_by が指す
approved 決定が担う。スキーマとカバレッジ規則 4 条は ../../references/visual-change-protocol.md §6 が原本であり
ここで再定義しない。生成ルールは 3 つ:
- surface の
route_or_entry·state·viewport_or_containerをテストセットアップにそのまま移す。 assertions[]を golden 比較より先に 実行する — 空のレンダリングがベースラインとして固定されるのを防ぐ。selectors[]は対象 locator スナップショットに、その外側は周辺領域スナップショットに使用する(Gotcha 12 と対になる)。
// tests/design/decision-DEC-20260813-001.test.ts — manifest から生成
import { test, expect } from '@playwright/test'
test('DEC-20260813-001 → dashboard.desktop.main', async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 }) // viewport_or_container
await page.goto('/dashboard') // route_or_entry
// assertions[] — golden 比較より先に(Gotcha 11·14)
const main = page.locator('main').first()
await expect(main).toBeVisible()
await expect(page.locator("[data-surface='group-list'] > *")).not.toHaveCount(0)
expect((await main.boundingBox())?.height ?? 0).toBeGreaterThan(0)
// golden — 上記 assertion を通過した後にのみ意味を持つ
await expect(main).toHaveScreenshot(
'DEC-20260813-001/dashboard.desktop.main.png', { maxDiffPixelRatio: 0.01 }
)
})
生成後、カバレッジチェッカー(§6)を実行して終了コード 0 を確認する。終了コード 2(SCHEMA_ERROR)は入力の形状が間違っていて判定できなかったものであり、FAIL 行がなく violations=0 と表示されても合格とは見なさない。
終了コード 3(NO_SURFACE · NO_DECISION)は NO_MANIFEST と同様に対象 0 件として報告する。golden のみで assertions が空の surface
が残っている場合は、それは FAIL であり、テストファイルを作成したという事実がカバレッジを代替しない。
excluded_surfaces に挙げられた画面はテストを作成しないが、報告には理由とともに列挙する。
Step 6: レスポンシブレイアウトテスト生成
// tests/design/responsive.test.ts
import { test, expect } from '@playwright/test'
// プロジェクトのデザインシステムのブレークポイントを使用
const breakpoints = [
{ name: 'sm', width: 640 },
{ name: 'md', width: 768 },
{ name: 'lg', width: 1024 },
{ name: 'xl', width: 1280 },
]
test.describe('Responsive Layout', () => {
for (const bp of breakpoints) {
test(`no horizontal overflow at ${bp.name} (${bp.width}px)`, async ({ page }) => {
await page.setViewportSize({ width: bp.width, height: 900 })
await page.goto('/')
const scrollWidth = await page.evaluate(() => document.documentElement.scrollWidth)
const clientWidth = await page.evaluate(() => document.documentElement.clientWidth)
expect(scrollWidth).toBeLessThanOrEqual(clientWidth)
})
}
for (const bp of breakpoints) {
test(`navigation layout is responsive at ${bp.name}`, async ({ page }) => {
await page.setViewportSize({ width: bp.width, height: 900 })
await page.goto('/')
const nav = page.locator('nav')
await expect(nav).toBeVisible()
// モバイルではハンバーガーメニュー、デスクトップでは横並びなどを確認
if (bp.width < 768) {
// モバイル: メニューが隠れているか、ハンバーガーボタンがある
const menuButton = page.locator('[aria-label="Open menu"], .hamburger')
await expect(menuButton).toBeVisible()
} else {
// デスクトップ: メニュー項目が表示されている
const menuItems = nav.locator('a, button')
const count = await menuItems.count()
expect(count).toBeGreaterThan(0)
}
})
}
})
パフォーマンスとカバレッジの注意:
- トークン一貫性テストには vitest、アクセシビリティと視覚的回帰テストには Playwright を使用する。
- 全 AP をカバーするスタンドアロンのテストファイルを生成し、既存のテストスイートに統合する。
- 大規模プロジェクトでは、ページごと、コンポーネントごとにテストファイルを分割する。
- 既存のテストパターン(
.a11y.test.tsなど)が存在する場合は、それに従う。