Files
Gnuboard7/docs/testing/e2e-testing.md
T
HeuJung 6b3903553a fix(e2e,a11y): 확장 E2E 로케일 고정 + 관리자 탭·스위치 접근성 보완
확장 E2E config 의 로케일 미고정을 코어 포함 7개에서 해소했다. 미지정 시 첫 페이지
로드의 Accept-Language 가 en-US 로 나가고 엔진이 그 값을 localStorage 에 굳혀,
인증 후에도 세션 전체가 영어로 렌더돼 한국어 문구를 단언하는 spec 이 전멸한다.
같은 함정을 정적으로 막는 룰을 함께 등록했다.

관리자 공용 탭·스위치는 ARIA 와 키보드 처리가 없어 마우스 없이는 조작할 수 없었고
보조기기에 목록·선택 상태가 전달되지 않았다. WAI-ARIA 규약대로 보완하고, role 변경에
맞춰 낡은 셀렉터를 갱신했다(admin_basic E2E 회귀 1건 포함).

nhnkcp 설정 화면은 저장 후 정규화 결과가 입력칸에 반영되지 않았다. 계측 결과
refetch 와 initLocal 은 정상 동작하며 _local 도 갱신되지만 사용자가 편집한 입력칸만
값을 유지한다. 저장 응답으로 폼을 명시 재바인딩하고 그 기전을 트러블슈팅에 남겼다.

safelist 생성기가 빌드마다 main.css 에 빈 줄을 누적해 매 빌드가 소스를 변경된 것으로
만들던 문제도 함께 고쳤다.
2026-07-31 00:43:56 +09:00

306 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 그누보드7 Playwright E2E 테스트 가이드
## TL;DR (5초 요약)
```text
- 도구: Playwright 1.49+ (TypeScript, Vitest 와 동일 스택)
- 인증: PlaywrightIssueToken artisan 커맨드가 Sanctum 토큰 발급 (CLI + G7_PLAYWRIGHT_BYPASS=1 + APP_DEBUG 3중 가드)
- Base URL: PLAYWRIGHT_BASE_URL 환경변수 우선, .env APP_URL 차순위 (하드코딩 회피)
- 로케일: 모든 config 에 use.locale = 'ko-KR' 고정 (미지정 시 Accept-Language 가 en-US 로 나가 화면이 영어로 렌더 → 한국어 단언 전멸)
- 코어/확장 분리: 코어 = tests/Playwright/, 확장 = {확장 디렉토리}/tests/Playwright/
- 데이터 생성: 데이터를 소유한 영역에 시드 커맨드 배치 (코어 ↔ 모듈 의존 역전 회피)
- 외부 의존: mock-first 전략 (page.route() 로 결제창/외부 API 가로채기)
```
## §1. 도구 선택 정당화
| 기준 | Playwright | Laravel Dusk | Selenium | Claude MCP |
|---|---|---|---|---|
| Windows 11 + Apache + MySQL 호환 | ✅ 1급 | ChromeDriver 이슈 | 무거움 | ✅ |
| TypeScript (resources/js 동일 스택) | ✅ | PHP 전용 | 부진 | TS 아님 |
| Sanctum 토큰 fixture | `request.newContext()` | 가능 | 가능 | 가능 |
| 결정론적 | auto-wait + fixture isolation | 보통 | 보통 | ❌ (LLM 변동) |
| 개발자 학습 곡선 | ≈ 0 (Vitest 동일 언어) | PHP 별도 | 가파름 | 자연어 |
| CI/커밋 게이트 | ✅ | ✅ | ✅ | ❌ |
**선택**: Playwright (TypeScript). Vitest 와 동일 언어 → 학습 비용 0. Claude MCP 는 **디버깅 도구**로 보존.
## §2. 도구 설치 + 기본 사용법
```powershell
# 설치 (devDependency)
npm install -D @playwright/test
npx playwright install chromium
```
**`playwright.config.ts`** (코어 루트 — 모듈/플러그인/템플릿은 각자 위치):
```typescript
import { defineConfig, devices } from '@playwright/test';
function resolveBaseUrl(): string {
if (process.env.PLAYWRIGHT_BASE_URL) return process.env.PLAYWRIGHT_BASE_URL;
// .env 의 APP_URL (단 localhost 류 제외)
// 그 외 — Error
}
export default defineConfig({
testDir: './tests/Playwright/specs',
fullyParallel: true,
use: { baseURL: resolveBaseUrl(), trace: 'retain-on-failure', ignoreHTTPSErrors: true },
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
});
```
**최소 spec 구조**:
```typescript
import { test, expect } from '@playwright/test';
test('@smoke 홈페이지 마운트', async ({ page }) => {
await page.goto('/');
await expect(page.getByTestId('nav-home')).toBeVisible({ timeout: 15_000 });
});
```
## §3. 코어/확장 분리 원칙 (CRITICAL)
```text
모듈/플러그인/템플릿 E2E spec 을 코어 디렉토리(tests/Playwright/) 에 작성 금지
✅ 모듈 E2E: modules/_bundled/{id}/tests/Playwright/specs/
✅ 플러그인 E2E: plugins/_bundled/{id}/tests/Playwright/specs/
✅ 템플릿 E2E: templates/_bundled/{id}/tests/Playwright/specs/
✅ 코어 E2E: tests/Playwright/specs/ (코어 엔진/관리자 API 검증만)
```
기존 PHPUnit testsuite (`Unit`/`Feature`/`Module`/`Plugin`) 및 Vitest 의 코어/확장 분리 원칙과 동일.
확장은 `tests/Playwright/playwright.config.ts` 에 자체 config 를 가지므로 **확장 디렉토리에서 직접 실행**한다.
config 가 확장 루트가 아니라 `tests/Playwright/` 아래에 있으므로 `npx playwright test` 를 인자 없이 부르면
config 를 찾지 못한다 — `npm run test:e2e` (config 경로가 이미 묶여 있음) 를 쓴다.
```powershell
cd modules/_bundled/sirsoft-ecommerce
npm run test:e2e # 확장 전체
npm run test:e2e -- specs/admin/some.spec.ts # 단일 spec
npm run test:e2e:ui # UI 디버깅 모드
```
base URL 은 코어 `.env` 의 `APP_URL` 에서 자동 해석된다 (`PLAYWRIGHT_BASE_URL` 로 덮어쓸 수 있다).
### 로케일 고정 (`locale: 'ko-KR'`)
코어와 확장의 모든 config 은 `use.locale` 을 `'ko-KR'` 로 고정한다. 생략하면 한국어 문구를 단언하는
spec 이 전부 실패한다.
엔진의 로케일 우선순위는 `localStorage.g7_locale` → 서버가 내려준 값 → `'ko'` 이고
([TemplateApp.ts](../../resources/js/core/TemplateApp.ts) 생성자), 서버값은 `SetLocale` 미들웨어가
**미로그인 요청에서 `Accept-Language` 헤더로** 결정한다. Playwright 의 `locale` 옵션이 그 헤더를
만들므로, 지정하지 않으면 첫 페이지 로드가 `en-US` 로 나가 화면이 영어로 렌더되고 엔진이 그 값을
`localStorage` 에 저장한다. 이후 인증해도 저장된 값이 최우선이라 세션 전체가 영어로 고정된다.
`getByText('전체회원수')` 처럼 한국어만 단언하는 곳은 물론이고, `getByRole('button', { name: /저장/ })`
같은 접근 가능한 이름 조회도 함께 깨진다.
### 실행 시 유의 (경험칙)
| 항목 | 내용 |
| --- | --- |
| 산출물 위치 | 리포트·trace 는 **코어 루트**의 `test-results/{type}/{id}/`, `playwright-report/{type}/{id}/` 에 쌓인다. 확장 디렉토리 안에 쌓으면 Windows 에서 `{module\|template}:update` 의 디렉토리 이동이 열린 핸들에 걸려 실패한다 |
| 레이아웃 수정 후 | 레이아웃 JSON 을 고쳤으면 `{type}:update {id} --force` **+ `template:cache-clear {템플릿}`** 까지 해야 브라우저에 반영된다. 레이아웃은 템플릿 캐시(`/api/layouts/{template}/...`)로 서빙되므로 `cache:clear` 만으로는 갱신되지 않는다 |
| 공유 상태 | 같은 관리자 설정 화면을 건드리는 spec 이 병렬로 돌면 서로의 저장 상태를 덮어써 실패할 수 있다. 실행 옵션에 맡기지 않고 그 `describe` 에 `test.describe.configure({ mode: 'serial' })` 를 둔다 |
| 워커 수 | 관리자 SPA 는 번들이 크고 레이아웃을 여러 번 받아온다. 개발 머신에서 2워커 이상이면 `page.waitForLoadState` 가 30초를 넘겨 **비결정적으로** 실패한다(실측: 같은 스위트가 회차마다 다른 5~7건 실패, 테스트당 8초 → 25초). 판정은 `--workers=1` 결과로 한다 |
## §4. 데이터 생성 위치 — 책임 분리 매트릭스
| 데이터 종류 | 책임 영역 | 위치 | 호출 |
|---|---|---|---|
| 코어 권한/역할/유저/Sanctum 토큰 | 코어 | `app/Console/Commands/PlaywrightIssueToken.php` | `php artisan playwright:issue-token --permissions=core.xxx` |
| 모듈 권한 (`sirsoft-ecommerce.*`) | 모듈 | 코어 커맨드의 `--permissions=` 임의 식별자 | 동일 (Permission::firstOrCreate 자동 생성) |
| 모듈 도메인 데이터 (상품/주문) | 모듈 | `modules/_bundled/{id}/src/Console/Commands/PlaywrightSeed{id}.php` | `php artisan playwright:seed-{id}` |
| 플러그인 도메인 데이터 (결제 키) | 플러그인 | `plugins/_bundled/{id}/src/Console/Commands/PlaywrightSeed{id}.php` | 동일 |
| 외부 의존 (토스 결제창 응답) | spec 안 mock | `page.route(...)` | 호출 없음 |
**핵심 원칙**: 코어는 모듈 도메인을 모른다. 모듈 도메인 시드를 코어에 두면 의존 역전.
## §5. fixture 패턴
### 5.1 코어 fixture (`tests/Playwright/fixtures/auth.ts`)
```typescript
export function issueToken(...permissions: string[]): string {
return execSync(`php artisan playwright:issue-token ${permissions.map(p => `--permissions=${p}`).join(' ')}`, {
cwd: process.env.G7_ROOT || process.cwd(),
env: { ...process.env, G7_PLAYWRIGHT_BYPASS: '1' }, // ② 옵트인 자동 부착
}).toString().trim();
}
export async function authenticatePage(page: Page, token: string): Promise<void> {
await page.addInitScript((t) => localStorage.setItem('auth_token', t), token);
}
export const test = base.extend<AuthFixtures>({
editToken: async ({}, use) => use(issueToken('core.templates.layouts.edit')),
readOnlyToken: async ({}, use) => use(issueToken('core.templates.read')),
});
```
### 5.2 확장 fixture — 권한 + 시드 분리
```typescript
// modules/_bundled/sirsoft-ecommerce/tests/Playwright/fixtures/ecommerce-auth.ts
import { issueToken, authenticatePage } from '../../../../../../tests/Playwright/fixtures/auth';
export const test = base.extend<EcommerceAuthFixtures>({
settingsToken: async ({}, use) =>
use(issueToken('sirsoft-ecommerce.settings.read', 'sirsoft-ecommerce.settings.update')),
});
```
```typescript
// 권한 + 시드 조합 — mergeTests 사용
import { mergeTests } from '@playwright/test';
import { test as authTest } from '../../fixtures/ecommerce-auth';
import { test as seedTest } from '../../fixtures/ecommerce-seed';
const test = mergeTests(authTest, seedTest);
test('이커머스 상품 목록', async ({ page, settingsToken, seededEcommerce }) => {
await authenticatePage(page, settingsToken);
await page.goto('/admin/ecommerce/products');
await expect(page.getByTestId('product-list-row')).toHaveCount(seededEcommerce.productIds.length);
});
```
## §6. 도메인 매트릭스 (4 카테고리)
### 6.1 인증 가드 매트릭스 (access_outcome × user_permission)
`access-check` 응답을 page.route() 로 mock → 토큰 실제 권한과 무관하게 모든 분기 cover.
```typescript
await page.route('**/api/admin/templates/layouts/access-check', route =>
route.fulfill({ status: 401, body: JSON.stringify({ message: 'Unauthenticated.' }) })
);
await page.goto('/?mode=edit&template=sirsoft-basic');
await expect(page.getByTestId('wysiwyg-access-denied-unauthenticated')).toBeVisible();
```
### 6.2 UI 인터랙션 매트릭스 (anchor × modifier_key)
PreviewCanvas 의 `data-testid="preview-canvas-container"` 안에 동적 anchor 주입 + 클릭 시뮬레이션:
```typescript
await page.evaluate(({ href, modifier }) => {
const host = document.querySelector('[data-testid="preview-canvas-container"]');
const a = document.createElement('a');
a.setAttribute('href', href);
host.appendChild(a);
a.dispatchEvent(new MouseEvent('click', {
bubbles: true, cancelable: true,
ctrlKey: modifier === 'ctrl',
shiftKey: modifier === 'shift',
button: modifier === 'middle_button' ? 1 : 0,
}));
}, { href, modifier });
```
검증 신호: `evt.defaultPrevented === true` (intercept) / `false` (allow).
### 6.3 핸들러 동작 매트릭스 (suppressed_handler)
ActionDispatcher 의 `setPreviewMode(true)` + `setPreviewSuppressedHandlerCallback` 으로 분기 진입 검증:
```typescript
await page.evaluate(({ handler }) => {
const dispatcher = (window as any).__templateApp.getActionDispatcher();
let captured: any = null;
dispatcher.setPreviewMode(true);
dispatcher.setPreviewSuppressedHandlerCallback((name) => { captured = name; });
return dispatcher.dispatchAction({ handler }, {}).then(() => captured);
}, { handler: 'navigate' });
```
### 6.4 외부 의존 시나리오 — mock-first
토스페이먼츠 SDK / API mock 패턴은 `tests/Playwright/README.md` "외부 의존 시나리오" 참조.
## §7. 시나리오 매니페스트와 1:1 매핑
기존 PHPUnit/Vitest 와 동일하게 `tests/scenarios/<feature>.yaml` 의 `cross_product` axis 가 spec 의 `test.describe.parallel(axisName)` 으로 변환되어야 한다.
| YAML axis | spec 파일 | 케이스 |
|---|---|---|
| `cross_product[0]` (access_outcome × user_permission) | `auth-guard.spec.ts` | 12 |
| `cross_product[1]` (anchor_kind × modifier_key) | `anchor-intercept.spec.ts` | 45 |
| `cross_product[2]` (suppressed_handler) | `handler-suppression.spec.ts` | 6 |
| `cross_product[3]` (url_template_param × anchor_kind) | `url-template-param.spec.ts` | 27 |
각 test 케이스의 docblock 에 마킹:
```typescript
test('unauthenticated_401 × no_token', async ({ page }) => {
// @scenario access_outcome=unauthenticated_401, user_permission=no_token
// @effects access_denied_screen_renders, editor_does_not_mount_on_denial
// ...
});
```
정적 검사가 매니페스트 axes ↔ docblock 매칭을 자동 검증.
## §8. 회귀 테스트 4단계
버그 수정 시 다음 4단계를 스킵 불가:
1. **실패하는 회귀 spec 작성** — 버그 재현 케이스 spec 작성
2. **baseline fail 확인** — 수정 전 실제 fail 확인 (테스트가 의도된 분기를 cover 함을 입증)
3. **코드 수정**
4. **green 전환** — 회귀 spec PASS 확인
`testing-guide.md` 의 PHPUnit/Vitest 4단계와 동일 원칙.
## §9. 무관 에러 처리 분기
E2E spec 작성 중 발견한 무관 에러는 같은 세션에서 처리:
- **테스트 stale** (logic 정상, 테스트가 오래됨) → 테스트 수정
- **로직 회귀** (테스트 정상, 로직이 의도와 어긋남) → 코드 수정
- **데이터 구조 불일치** (양쪽 모두 오래됨) → 보고 + 보류
상세: `testing-guide.md` "무관 에러 처리 분기".
## §10. 트러블슈팅
| 증상 | 원인 | 해결 |
|---|---|---|
| `Tests timed out — networkidle` | Reverb WebSocket 지속 연결 | `waitForLoadState('domcontentloaded')` + `waitForFunction` 사용 |
| 401 무한 redirect (`/login?redirect=...`) | 토큰이 testing DB(`g7_testing`) 에만 있고 production 서버(`g7`)는 못 찾음 | `G7_PLAYWRIGHT_BYPASS=1` 로 호출 (production DB 에 토큰 발급) |
| `preview-canvas-container 없음` | 위지윅 편집기 마운트 실패 (access-check 거부) | `page.route()` 로 access-check 200 mock |
| `defaultPrevented` 가 항상 false | onClickCapture 가 도달 안 함 | anchor 가 `preview-canvas-container` 자손인지 확인 |
| `location.href` setter override 실패 | Chromium 에서 `window.location` 은 non-configurable | best-effort 캡처. SSoT 신호는 `defaultPrevented` |
| 외부 origin navigation 으로 trace 복잡 | spec 이 외부 도메인을 로드 | `page.route('https://example.com/**', route => route.fulfill(...))` |
| Sanctum 토큰 누적 DB 오염 | 매 spec 마다 새 user/role 생성 | `globalTeardown.ts` 에서 `php artisan playwright:cleanup-tokens` (별도 커맨드 신설) |
| Windows 라인엔딩 | git autocrlf 충돌 | `.gitattributes` 에 `*.spec.ts text eol=lf` |
## §11. 디버깅 — Claude MCP 역할
Playwright = **결정론적 회귀 게이트** / Claude `chrome-devtools-mcp` = **라이브 진단**.
워크플로우:
1. Playwright spec 이 fail
2. `npx playwright show-trace test-results/<...>/trace.zip` 으로 timeline 확인
3. 단계별 DOM 스냅샷 + 네트워크 분석으로도 원인 불명 시 → Claude MCP `chrome-devtools-mcp` 로 동일 URL 라이브 진단
4. 진단 결과로 spec 보강 또는 코드 수정 → 다시 Playwright 로 결정론 검증
Claude MCP 는 단독 게이트가 아닌 **진단 보조 도구**. 모든 회귀는 Playwright spec 으로 승격되어야 CI/커밋 게이트에서 효력 발생.
## 참조
- 빠른 시작: `tests/Playwright/README.md`
- 모듈 sample skeleton: `modules/_bundled/sirsoft-ecommerce/tests/Playwright/README.md`
- 시나리오 매니페스트: `tests/scenarios/wysiwyg-editor-access-guard.yaml`
- PHPUnit/Vitest 가이드: `docs/testing-guide.md`, `docs/frontend/layout-testing.md`
- 가드 구현: `app/Console/Commands/PlaywrightIssueToken.php`, `app/Providers/SettingsServiceProvider.php::applyDebugConfig`
- 회귀 테스트: `tests/Unit/Providers/SettingsServiceProviderDebugConfigTest.php`