주의사항 (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 주의사항 #3
- axe-core 룰셋 버전 확인 —
@axe-core/playwright또는axe-core사용 시 프로젝트에 설치된 버전의 룰셋을 확인하라. 메이저 버전 간에 룰 차이가 있다. - 시각 회귀 테스트의 임계값 미설정 금지 — 픽셀 단위 비교는 안티앨리어싱, 폰트 렌더링 차이로 인해 false positive가 빈번하다.
maxDiffPixelRatio: 0.01과 같은 임계값 필수. - 토큰 검증에서 하드코딩 탐지 범위 명확히 — CSS에서
#ff0000,16px,400같은 원시 값이 있다고 무조건 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 주의사항 #10
- 첫 실행 기준선 생성을 "테스트 통과"로 간주하여 보고 금지 (음성 대조군 필수) — Playwright는 스냅샷이 없으면 실제 화면을 기준선으로 자동 기록하고 그 실행을 통과 처리한다. 따라서
--update-snapshots직후의 green은 아무것도 입증하지 않는다 — 무엇과도 비교되지 않았기 때문이다(증거 유효성 검사 2 활성화 실패). 기준선을 만든 뒤에는 반드시 음성 대조군을 1회 수행하라: 대상 요소에 의도적 변형 (예: 배경색 1단계 변경)을 준 상태로 테스트를 돌려 실패하는 것을 확인하고, 되돌린 후 통과를 확인한다. 이 두 실행의 출력을 증거로 인용하기 전에는 시각 회귀 테스트가 동작한다고 보고하지 마라. 출처: Playwright 시각적 비교 (첫 실행 시 "A snapshot doesn't exist ... writing actual"),harness/docs/guides/qa-evaluation-guide.md§증거 유효성 게이트 검사 2·3. - 빈 스냅샷/빈 페이지는 PASS 증거가 아니다 — 렌더링이 실패하여 빈 화면이 캡처되어도 시각 회귀 테스트는 기준선과 동일하면 통과한다. 시각 테스트에는 콘텐츠 존재 단언문을 함께 생성하라 (핵심 요소 visible, 목록 항목 수 ≥ 1, 컨테이너 높이 > 0). 스냅샷 비교만 있는 테스트 파일은 무제한 높이 축소(unbounded-height collapse)와 같은 결함을 통과시킨다 — 실제 사고 사례다. 상세:
../../references/visual-change-protocol.md§3. - 부분 변경 검증은 범위 제한 스냅샷으로 — 의도 외 영역 변화 감지 — "테두리만 바꿨다"를 검증할 때 전체 페이지 스냅샷 하나만 쓰면 배경까지 변한 것을 구분하지 못한다. 변경 대상 요소의 locator 단위 스냅샷과 주변 영역 스냅샷을 분리 생성하여, 대상은 변하고 주변은 변하지 않았음을 각각 판정하라. 주변 영역 스냅샷이 실패하면 그것은 회귀다. 상세:
../../references/visual-change-protocol.md§2. prefers-reduced-motion: reduce는 "애니메이션 제거" 가 아니다 — 이 설정은 전정 감각 유발 요소(scale·pan 등 이동감을 주는 모션)를 더 온건한 대안으로 교체하라는 의미이며 모든 전환을 없애라는 뜻이 아니다. 따라서 reduced-motion 테스트를 "애니메이션 개수 0" 으로 단언하지 마라.transform: scale/translate기반 모션이 사라지거나 opacity/dissolve 로 대체되었는지를 검증하라. 출처: MDN prefers-reduced-motion (Baseline 2020-01).- 골든만 있는 결정 전파 테스트는 FAIL — 결정 전파 매니페스트 커버리지 — 확정된 디자인 결정이 어느 표면에 반영되어야 하는지는
.design/decisions.yaml이 열거한다 (decision_id→required_surfaces[]→ golden + assertions). 이 manifest가 있으면 커버리지 규칙 4조를 따라 테스트를 생성하라. 핵심은 golden만 있고 핵심 요소의 visible / count / height assertion이 없으면 FAIL 이라는 것 — 빈 화면도 baseline과 같으면 통과하므로 스냅샷 단독은 "사용자가 본다"를 증명하지 못한다 (주의사항 11 과 같은 뿌리).excluded_surfaces에 이유 없이 빠진 표면은 검토된 것이 아니라 커버리지 공백이다. 스키마·규칙·실행 가능한 체커:../../references/visual-change-protocol.md§6 결정 전파 매니페스트. - 증거에 채널 이름을 붙여라 — 스냅샷 존재는 사용자 관측이 아니다 —
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 증거 채널. 사용자가 실패를 보고했을 때의 우선순위 규약은harness/docs/guides/skill-design-guide.md§3.8 이 정본이다 — 테스트 통과를 근거로 반박하지 마라.
프로세스
단계 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 custom properties (--) - Storybook:
.storybook/,*.stories.* - Tailwind:
tailwind.config.* - CSS-in-JS: styled-components, emotion, vanilla-extract
단계 1: 대상 분석
$ARGUMENTS 에서 대상과 테스트 유형을 파싱한다.
유형 미지정 시 자동 추론:
| 대상 특성 | 테스트 유형 |
|---|---|
| 토큰 파일, 테마 설정 | token (일관성 검증) |
| 컴포넌트, 페이지 | a11y (접근성) + visual (시각 회귀) |
| 레이아웃, 그리드 | responsive (반응형) |
| 전체 프로젝트 | 모든 유형 |
단계 2: 기존 패턴 탐색
- 기존 접근성 테스트:
*.a11y.test.*, axe-core import - 기존 시각 테스트:
*.visual.test.*,__snapshots__/,.loki/ - Playwright 설정:
playwright.config.* - Storybook 테스트:
*.stories.*존재 여부 - 디자인 토큰 검증: 기존 lint 규칙, stylelint 설정
단계 3: 토큰 일관성 테스트 생성
디자인 토큰 파일을 분석하여 일관성을 검증하는 테스트를 생성한다.
// tests/design/tokens.test.ts
import { describe, it, expect } from 'vitest'
import fs from 'fs'
describe('Design Token Consistency', () => {
// CSS custom properties 파싱
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([])
})
})
단계 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' },
// 단계 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([])
})
단계 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: '/' },
// 단계 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')
// 주의사항 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 감지 시에만 이 섹션 생성
부분 변경(범위 제한) 검증 — 특정 요소의 특정 속성만 바꾼 변경을 검증할 때는 대상과 주변을 분리한다 (주의사항 12). 주변 스냅샷이 실패하면 의도 외 영역이 변한 것이므로 회귀로 처리한다.
// tests/design/scoped-change.test.ts
import { test, expect } from '@playwright/test'
test.describe('범위 제한 시각 변경 — 대상만 변하고 주변은 불변', () => {
test('대상 요소 스냅샷 (변경 허용)', async ({ page }) => {
await page.goto('/')
await expect(page.getByTestId('target-card'))
.toHaveScreenshot('target-card.png', { maxDiffPixelRatio: 0.01 })
})
// 주변 영역은 "변하지 않아야" 하므로 대상보다 엄격한 임계값을 쓴다.
// 단 주의사항 4 대로 0은 쓰지 마라 — 안티앨리어싱·폰트 렌더링 차이로 항상 실패한다.
test('주변 영역 스냅샷 (변경 금지 — 실패 시 회귀)', async ({ page }) => {
await page.goto('/')
await expect(page.getByTestId('surrounding-panel'))
.toHaveScreenshot('surrounding-panel.png', { maxDiffPixelRatio: 0.001 })
})
})
단계 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 이 정본이며
여기서 재정의하지 않는다. 생성 규칙은 셋이다:
- surface 의
route_or_entry·state·viewport_or_container를 테스트 셋업에 그대로 옮긴다. assertions[]를 golden 비교보다 먼저 실행한다 — 빈 렌더가 기준선으로 굳는 것을 막는다.selectors[]는 대상 locator 스냅샷에, 그 바깥은 주변 영역 스냅샷에 쓴다 (주의사항 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 비교보다 먼저 (주의사항 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 에 올라온 표면은 테스트를 만들지 않되 보고에는 이유와 함께 열거한다.
단계 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(`${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 + 1) // 스크롤바 여유 1px 허용
})
}
})
// 그리드/레이아웃 변경이 각 중단점에서 올바른지 추가 테스트를 생성할 수 있음
test.describe('Grid Layout at Breakpoints', () => {
for (const bp of breakpoints) {
test(`${bp.name} (${bp.width}px) 에서 올바른 열 수`, async ({ page }) => {
await page.setViewportSize({ width: bp.width, height: 900 })
await page.goto('/')
const grid = page.locator('.grid, [class*="grid"]').first()
await expect(grid).toBeVisible()
// 열 수는 디자인 시스템 및 CSS에 따라 다름; 예시로 간단한 검증
const computedStyle = await page.evaluate((el) => {
return window.getComputedStyle(el).getPropertyValue('grid-template-columns')
}, await grid.elementHandle())
expect(computedStyle).not.toBe('none') // 그리드가 확실히 정의되어 있음
})
}
})
다크 모드 시각 회귀 — 디자인 토큰에 다크 모드 매핑이 있을 때 생성
// tests/design/visual-regression-dark.test.ts (토큰에 다크 모드 매핑이 있을 때만 생성)
import { test, expect } from '@playwright/test'
const viewports = [
{ name: 'mobile', width: 375, height: 812 },
{ name: 'desktop', width: 1440, height: 900 },
]
const pages = [
{ name: 'Home', path: '/' },
]
for (const vp of viewports) {
for (const pg of pages) {
test(`${pg.name} - ${vp.name} dark mode snapshot`, async ({ page }) => {
await page.emulateMedia({ colorScheme: 'dark' })
await page.setViewportSize({ width: vp.width, height: vp.height })
await page.goto(pg.path)
await page.waitForLoadState('networkidle')
// 주의사항 11 — 빈 화면 방지 단언문
const main = page.locator('main, [role="main"]').first()
await expect(main).toBeVisible()
await expect(page).toHaveScreenshot(
`${pg.name}-${vp.name}-dark.png`,
{ maxDiffPixelRatio: 0.01 }
)
})
}
}
단계 7: 보고서 생성
테스트 실행 후 다음을 포함한 보고서를 생성한다:
- 통과/실패한 각 테스트 유형 (토큰, 접근성, 시각, 반응형) 요약
- 각 주의사항이 적용되었는지 확인
- 증거 채널이 올바르게 명명되었는지 확인
- 결정 전파 매니페스트가 있는 경우 커버리지 상태 보고 (각 표면별)
- 발견된 문제가 있으면 구체적인 수정 권장 사항 포함