공개 번들 엔드포인트가 캐시 파일이 있어도 요청마다 다시 병합했다. 캐시 키는 (type, kind, version) 인자만으로 계산되는데 "병합 결과가 비면 파일을 만들지 않는다" 는 규칙을 먼저 두느라 빌드를 앞세운 것이 원인이다. 그래서 캐시 적중 경로에도 활성 확장 열거와 산출물 전량 읽기가 붙었고, 원본이 소실되면 멀쩡한 캐시를 두고 빈 경로가 반환되어 503 이 됐다. 응답은 정상 200 이라 타이밍 말고는 드러나는 증상이 없다. 프로덕션에서 캐시 존재를 병합보다 먼저 확인하고, 캐시 미스는 같은 키의 잠금으로 1회 빌드에 수렴시킨 뒤 잠금 뒤 캐시를 재확인한다. 잠금 대기 초과·저장소 장애는 실패로 바꾸지 않고 각자 빌드로 폴백한다. 병합 결과가 비어도 선언 산출물이 전부 존재하거나 선언이 0이면 0바이트 캐시를 만들어 정적 게시까지 보낸다. 만들지 않으면 그 구성의 자산 URL 이 API 로 폴백해 방문자의 모든 페이지 로드가 PHP 를 거치고, 그 요청마다 컨트롤러가 열거를 세 번 반복한다. 캐시하지 않는 것은 산출물 소실(503 판정 보존)과 디스크 쓰기 실패뿐이라 응답 계약은 바뀌지 않는다 — 컨트롤러와 트레이트는 손대지 않았다. 같은 결의 결함이 검색봇 캐시에도 있었다. 봇 판정은 User-Agent 문자열뿐인데 캐시 키가 경로 + 전체 쿼리여서, 물음표 뒤 값만 바꾼 반복 요청이 매번 미스가 되고 그 미스마다 레이아웃 병합·표현식 평가·자기 API 루프백 호출이 일어나며 결과가 무제한 저장됐다. 키를 정규화하고(시스템 파라미터 제외·개수/길이 상한), IP 당 분당 미스 렌더 예산과 저장 규모 상한을 뒀다. 초과분은 차단이 아니라 일반 SPA 응답을 받는다 — 봇에게 오류를 주면 그 URL 이 색인에서 빠지기 때문이다. 저장 상한은 만료 항목을 걷어낸 뒤 판정한다. 인덱스는 페이지보다 오래 살아 (30일 vs 기본 2시간) 정리 없이 세면 상한이 "지금 저장된 양"이 아니라 "과거에 저장한 적이 있는 양"을 재게 되어 일방향 래치가 된다. 정리는 인덱스 전체를 훑으므로 최소 60초 간격으로만 수행한다. put 과 putWithLayout 은 같은 자원을 쓰므로 단일 저장 경로로 합쳤다 — 한쪽만 상한 밖이면 그쪽이 우회로가 되고, 인터페이스는 확장에 열려 있어 "지금 호출부가 없다" 는 방어가 되지 않는다. 캐시 적중·미적중을 기록하는 호출처가 없어 관리자 SEO 통계와 seo:stats 가 항상 0 이었던 것도 함께 고쳤다. 기록 자체에도 IP 당 상한을 둬 통계 테이블이 새 증식 축이 되지 않게 했다. (KISA 측에서 제보해주셨습니다 — KVE-2026-2191)
31 KiB
그누보드7 Playwright E2E 테스트 가이드
TL;DR (5초 요약)
- 도구: 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/
- 데이터 생성: 데이터를 소유한 영역에 시드 커맨드 배치 (코어 ↔ 모듈 의존 역전 회피)
- 편집기 저장 spec: 제품 화면 대신 전용 시드 화면(e2e_sandbox) 대상 — globalSetup 이 매 실행 원본으로 덮어씀 (§4.1)
- 외부 의존: mock-first 전략 (page.route() 로 결제창/외부 API 가로채기)
- 브라우저: 일상 E2E 는 Chromium 전용 — 확대 조건은 §3 브라우저 범위
§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. 도구 설치 + 기본 사용법
# 설치 (devDependency)
npm install -D @playwright/test
npx playwright install chromium
playwright.config.ts (코어 루트 — 모듈/플러그인/템플릿은 각자 위치):
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 구조:
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)
모듈/플러그인/템플릿 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 경로가 이미 묶여 있음) 를 쓴다.
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 생성자), 서버값은 SetLocale 미들웨어가
미로그인 요청에서 Accept-Language 헤더로 결정한다. Playwright 의 locale 옵션이 그 헤더를
만들므로, 지정하지 않으면 첫 페이지 로드가 en-US 로 나가 화면이 영어로 렌더되고 엔진이 그 값을
localStorage 에 저장한다. 이후 인증해도 저장된 값이 최우선이라 세션 전체가 영어로 고정된다.
getByText('전체회원수') 처럼 한국어만 단언하는 곳은 물론이고, getByRole('button', { name: /저장/ })
같은 접근 가능한 이름 조회도 함께 깨진다.
브라우저 UA 고정 (userAgent)
코어와 확장의 모든 config 은 use.userAgent 에 실제 데스크탑 Chrome UA 를 지정한다.
Playwright 의 기본 UA 에는 HeadlessChrome 이 들어 있고, SeoMiddleware 의 봇 판정이 그것을
검색엔진 크롤러로 본다. 그러면 공개 사용자 경로 요청이 SPA 가 아니라 검색엔진용 정적 HTML 을
받는다 — window.G7Core 도 엔진 스크립트도 없는 화면이다.
이 상태가 위험한 이유는 실패가 아니라 통과로 나타나기 때문이다. 서버가 심은 글꼴·아이콘은 그대로 정상이라 "페이지가 잘 뜬다" 로 보이고, 정작 재려던 SPA 동작(테마 적용·핸들러 등록·확장 번들 로드·상태 바인딩)은 한 번도 실행되지 않은 채 단언이 통과한다. 실측(2026-08-26)에서 사용자 홈을 재던 spec 들이 전부 이 경로였다.
봇 화면을 의도적으로 검증하는 spec 은 UA 가 아니라 ?_escaped_fragment_= 로 그 경로를 유발하므로,
UA 를 실제 브라우저 값으로 고정해도 그 검증은 그대로 동작한다.
측정으로 확인하려면 typeof window.G7Core 가 'object' 인지 본다 — 'undefined' 면 SPA 가 아니라
봇 화면을 재고 있는 것이다.
실행 시 유의 (경험칙)
| 항목 | 내용 |
|---|---|
| 산출물 위치 | 리포트·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 결과로 한다 |
| 편집기 spec 만 몰아 실행할 때 | 레이아웃 편집기 spec 만 골라 돌리면 워커 전부가 동시에 편집기 페이지를 연다 — 전체 스위트에서는 가벼운 spec 이 섞여 그 집중이 생기지 않는다. 실측: 편집기 7파일 27건을 7워커로 돌리면 g7le-preview-frame 대기가 전부 30초 타임아웃(27/27 실패), 같은 코드로 2워커는 26/27 통과. 편집기만 선택 실행할 때는 --workers=2 이하로 둔다 |
| 봇 렌더 spec 의 IP 예산 | 검색봇 화면의 미스 렌더는 IP 당 분당 상한(G7_SEO_RENDER_MISSES_PER_MINUTE, 기본 60)을 쓴다. 자동 검사는 전부 같은 IP 에서 나가므로 봇 경로 spec 을 많이 몰아 돌리면 예산을 넘긴 요청이 SEO HTML 대신 SPA(X-SEO-Cache: BYPASS)를 받아 본문 단언이 실패한다. 오류가 아니라 200 이라 원인이 드러나지 않으므로, 봇 spec 이 간헐 실패하면 응답 헤더가 BYPASS 인지 먼저 본다 (실측: seo-bot-rendering 12건 + page-og-image 2건은 기본값에서 여유가 있다) |
브라우저 범위
일상 E2E 는 Chromium 전용이 정책이다. 코어 config 와 확장 config 모두 projects 에
chromium 하나만 두며, npx playwright install chromium 만 안내하는 것(§2)이 곧 이 정책이다.
문서상의 지원 브라우저 선언(docs/requirements.md §7)은 이 자동 검증 범위와 구분해서 읽는다 —
Firefox/Safari 는 프론트엔드 의존성(React 19, Tailwind CSS 4)의 호환 범위 기준 지원이며
상시 자동 테스트 대상이 아니다.
이 정책의 근거는 위험 표면의 위치다. 엔진과 레이아웃에는 브라우저별 분기 코드가 없어 JS 레벨의 브라우저 편차 위험이 낮고, 실질 위험은 CSS 렌더링과 contenteditable(위지윅) 거동에 몰려 있다. 그 두 축은 브라우저 프로젝트를 늘리는 것보다 해당 spec 을 두껍게 쓰는 편이 비용 대비 회수가 크다.
Firefox/WebKit 프로젝트를 상시 스위트에 넣지 않은 이유:
- 프로젝트 간 병렬 실행이 공유 서버 상태를 경합시킨다 — 시드 화면 PUT 저장(§4.1)과 관리자 설정 저장 spec 은 같은 레코드를 건드리므로 브라우저별 실행이 서로의 저장을 덮어쓴다.
- 시나리오 매니페스트에 브라우저 축을 추가하면 cross product 가 브라우저 수만큼 배가된다.
- 총 실행 시간이 프로젝트 수에 비례해 늘어, 개발 머신의 워커 예산(위 "실행 시 유의")과 정면으로 충돌한다.
향후 도입할 경우 아래 4가지를 조건으로 한다:
- 상시 스위트가 아니라 별도 opt-in 스위트로만 실행한다 (기본 실행 대상에 넣지 않는다).
- 대상은
@smoke로 한정한다 — 레이아웃 편집기 저장 spec 을 배제해 시드 화면 경합을 피한다. --workers=1로 고정한다.- CDP 를 쓰는 spec(
specs/smoke/localinit-multi-progressive-datasource.spec.ts의newCDPSession)은test.skip(browserName !== 'chromium')로 제외한다.
§4. 데이터 생성 위치 — 책임 분리 매트릭스
| 데이터 종류 | 책임 영역 | 위치 | 호출 |
|---|---|---|---|
| 코어 권한/역할/유저/Sanctum 토큰 | 코어 | app/Console/Commands/PlaywrightIssueToken.php |
php artisan playwright:issue-token --permissions=core.xxx (권한 경계 검증은 --no-admin-role 추가 — §5.1) |
| 편집기 저장 spec 대상 시드 화면 | 코어 | app/Console/Commands/PlaywrightSeedLayout.php |
php artisan playwright:seed-layout [--remove] (globalSetup/globalTeardown 자동 호출) |
| 로그인 2단계 인증 계정·인증번호 | 코어 | app/Console/Commands/PlaywrightSeedTwoFactor.php |
php artisan playwright:seed-two-factor --ensure-user=… | --plant=<challenge_id> (§4.2) |
모듈 권한 (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(...) |
호출 없음 |
핵심 원칙: 코어는 모듈 도메인을 모른다. 모듈 도메인 시드를 코어에 두면 의존 역전.
4.2 로그인 2단계 인증 픽스처
2단계 인증은 사이트 설정과 메일 발송에 의존하므로 브라우저만으로는 재현할 수 없다.
tests/Playwright/fixtures/two-factor.ts 가 세 가지를 가역적으로 준비한다.
| 준비 항목 | 방법 | 원복 |
|---|---|---|
보안 설정 security.two_factor_auth |
관리자 토큰으로 /api/admin/settings GET → POST (탭 전체를 되돌려 보낸다) |
읽어 둔 탭 전체 값을 그대로 되쓴다 |
| 메일 발송 | 받아서 버리는 로컬 SMTP 싱크를 띄우고 mail.json 의 host/port/encryption 을 줄 단위 치환 |
백업 파일에서 바이트 그대로 복원 |
| 인증번호 | php artisan playwright:seed-two-factor --plant=<challenge_id> --code=135790 |
없음 (challenge 는 일회성) |
| 테스트 계정 | --ensure-user= 로 생성 |
--purge-users 로 종료 시 즉시 제거 |
메일을 log 메일러로 돌리지 않는 이유: log 는 이 제품의 설정 스키마에 없다. 저장 검증이
등록된 메일 드라이버(smtp·mailgun·ses)만 허용하고, 설정 파일을 직접 고쳐도 드라이버 해석
단계에서 smtp 로 되돌아간다(실측 확인). 그래서 tests/Playwright/fixtures/smtp-sink.ts 가
Node 기본 net 만으로 최소 SMTP 서버를 127.0.0.1 에 잠깐 띄우고 그쪽으로 돌린다 —
인증도 TLS 도 요구하지 않으며 받은 메일은 어디에도 남기지 않는다.
메일 설정만 파일을 직접 다루는 이유: 원래 값(빈 SMTP 호스트)은 저장 검증(smtp 이면 host
필수)을 통과하지 못해 API 로는 되돌릴 수 없다. 보안 설정은 API 로, 메일 설정은 파일
백업·복원으로 왕복한다.
playwright:seed-two-factor 계약:
| 옵션 | 하는 일 |
|---|---|
--ensure-user=<접미사> |
playwright_2fa_<접미사>@example.test 계정을 알려진 비밀번호로 생성/갱신하고 이메일을 출력. 잠금·실패 카운트도 초기화한다 |
--admin |
위 계정에 admin 역할 부여 (없으면 기존 역할을 떼어 관리자 거부 경로를 재현할 수 있게 한다) |
--password= |
--ensure-user 가 설정할 비밀번호 (기본 Passw0rd!2fa) |
--plant=<uuid> |
그 challenge 에 알려진 인증번호의 해시를 심고 코드를 출력 |
--code= |
--plant 가 심을 인증번호 (기본 135790) |
--gc-hours= |
이 시간보다 오래된 테스트 계정 정리 (기본 6, 0 이면 정리 안 함) |
--purge-users |
나이와 무관하게 테스트 계정을 전부 제거 — 실측 종료 직후 호출한다. 이 계정들은 알려진 비밀번호를 갖고 관리자용은 관리자 역할까지 가지므로, 나이 기준 정리를 기다리는 사이가 그대로 열린 문이 된다 |
가드는 playwright:issue-token 과 동형이다 — CLI 한정 + G7_PLAYWRIGHT_BYPASS=1 + APP_DEBUG 강제.
인증번호는 해시로만 저장되어 되읽을 수 없다. 메일함을 실제로 여는 대신 알려진 값을 심는 이유이며,
방식은 PHPUnit TwoFactorAuthTest::issuedCode() 와 같다 — 검증 대상은 코드 생성이 아니라
로그인 흐름(코드 확인 전 토큰 미발급 / 확인 후 발급)이다.
원복 실패를 삼키지 않는다. 되돌리지 못한 채 끝나면 사이트가 2단계 인증이 켜진 상태로 남아
이후 모든 로그인이 막힌다. 그래서 이 spec 은 test.describe.configure({ mode: 'serial' })
로 한 워커에서만 실행하고, afterAll 의 원복 실패는 그대로 던진다.
4.1 저장(PUT)하는 spec 은 제품 화면을 대상으로 두지 않는다
레이아웃 편집기 spec 이 저장까지 수행하면 그 편집 결과는 그대로 영속된다. 대상이 제품 화면
(home / admin_dashboard 등)이면 실행할 때마다 노드가 누적돼 개발 사이트에 그대로 노출된다.
실측(2026-07-30): home 에 빈 표 7개가 쌓여 20,321 → 33,696 bytes, 관리자 대시보드에 빈
DonutChart 5개. 누적되면 캔버스 구조가 회차마다 달라져 같은 파일의 다른 테스트도 간헐 실패한다.
spec 안에 "추가한 노드를 삭제하고 다시 저장" 원복을 넣는 것으로는 해결되지 않는다 — 원복 실행 후에도 레이아웃이 오염 시점과 정확히 같은 크기로 되돌아왔다(편집기가 그 시점에 들고 있던 문서를 통째로 다시 저장). 원복은 그 자체가 또 한 번의 저장이라, 실패하면 잔여물이 남는다.
그래서 저장 대상 자체를 전용 시드 화면으로 분리한다.
| 항목 | 값 |
|---|---|
| 레이아웃 이름 | e2e_sandbox |
| 라우트 | 사용자 템플릿 /e2e-sandbox, 관리자 템플릿 */admin/e2e-sandbox |
| fixture 원본 | tests/Playwright/fixtures/seed-layouts/{템플릿}.e2e_sandbox.json |
| 설치 위치 | 활성 템플릿 디렉토리만 (_bundled 배포 원본 무변경, 활성 디렉토리는 Git 무시 → 릴리스 미포함) |
| 설치/제거 | php artisan playwright:seed-layout [--remove] (CLI + G7_PLAYWRIGHT_BYPASS=1 가드) |
| 자동 호출 | globalSetup 설치 / globalTeardown 제거 |
| spec 헬퍼 | tests/Playwright/fixtures/seed-layout.ts — sandboxRouteParam(), SANDBOX_ROOT_ID |
설치는 3종을 함께 처리한다: 레이아웃 파일 + routes.json 라우트(마커 기반 멱등) + DB 행 upsert.
편집기의 라우트 트리는 routes.json 에서, 조회/저장은 DB 행을 대상으로 하므로 셋 중 하나만
빠지면 ?route= 로 열 수 없거나 404 가 된다.
routes.json 은 재직렬화하면 원본의 주석 그룹 사이 빈 줄 같은 서식이 사라지므로, 설치 시 원본을
routes.json.playwright-backup 으로 보관하고 제거 때 그 파일을 그대로 되돌린다(바이트 동일 복원).
백업이 이미 있으면 덮어쓰지 않는다 — 비정상 종료로 시드가 남은 상태의 파일을 "원본" 으로 굳히지
않기 위함이다.
시드 설치는 시드 행 하나만 건드린다. template:refresh-layout(전체 재동기화)을 쓰지 않는
이유는 그 경로가 파일에 없는 DB 레이아웃을 지우고 모든 레이아웃을 파일 기준으로 되돌리기
때문이다 — 편집기 UI 로 저장한 변경은 파일이 아니라 DB 에만 있으므로 E2E 를 돌릴 때마다 사람이
편집기로 만든 결과가 사라진다.
시드 화면은 매 실행 fixture 원본으로 덮어써지므로 회차 간 누적이 성립하지 않는다. 따라서 저장 spec 에 원복 절차를 둘 필요가 없다.
import { SANDBOX_ROOT_ID, sandboxRouteParam } from '../../fixtures/seed-layout';
import { editorPath } from '../../fixtures/layout-editor';
test('편집 후 저장 → PUT 200', async ({ page }) => {
await gotoEditor(page, sandboxRouteParam()); // 사용자 템플릿
const container = await editorPath(page, '', SANDBOX_ROOT_ID); // 고정 id 컨테이너
// ... 편집 + 저장
});
저장하지 않는(읽기 전용) spec 은 계속 제품 화면을 대상으로 둔다 — 오염 위험이 없고, 실제 제품 레이아웃에 대한 검증이 유지되는 편이 낫다.
예외: 팔레트로 노드를 추가한 뒤 그 노드를 선택해야 하는 spec 은 저장하지 않아도 시드 화면을
쓴다. 제품 화면에서는 삽입 위치가 "선택 가능한 Div 후보 순회" 로 정해지는데, 그 위치가 모듈이 주입한
잠금 서브트리 안이면 선택이 조상 노드로 escalate 되어 추가한 노드를 지목할 수 없다(실측: 관리자
대시보드에 BarChart 추가 시 오버레이 타입 라벨이 ↑Div + ⓘ 미표시, 같은 절차를 시드 컨테이너에서
하면 ↑BarChart + ⓘ 표시). 시드 화면의 컨테이너는 고정 id 라 삽입 위치가 결정적이다.
4.2 편집기 spec 작성 시 자주 틀리는 측정 기준
전수 실행에서 드러난 실패의 상당수가 제품 결함이 아니라 측정 방법의 문제였다. 아래는 실측으로 확인된 것들이다.
| 하지 말 것 | 이유 (실측) | 대신 |
|---|---|---|
전환 오버레이 가림을 toBeVisible() 로 판정 |
Playwright 의 가시성 판정은 가림(occlusion)을 보지 않는다. 오버레이는 타겟 안/head 에 덧붙는 방식이고 콘텐츠는 DOM 에 남으므로, 덮여 있어도 visible 로 판정된다 | 오버레이 엘리먼트의 attach/detach 시각으로 측정 (#g7-skeleton-overlay 또는 style#g7-transition-overlay) |
| "캔버스 텍스트 길이가 늘어난다" 로 본체 렌더 판정 | 탭/상태를 바꾸면 줄어들 수도 있다. 실측: my_comments 서브탭이 정상 렌더되는데 881 → 669 로 감소(항목당 길이가 짧아서) | 그 데이터에만 있는 고유 문구 존재로 판정 |
| 상태/옵션의 총 개수를 단언 | 상태 그룹은 편집기 스펙에서 계속 늘어난다. 체크아웃 상태가 3 → 4 로 늘면서 toHaveCount(3) 이 깨졌다 |
그 테스트가 실제로 쓰는 값의 존재로 판정 |
waitForLoadState('networkidle') |
관리자 SPA 는 실시간 연결·주기 폴링이 붙어 500ms 무통신 구간이 오지 않을 수 있다. 실측 30초 타임아웃 | 다음 단계에 필요한 구체 신호(클릭할 버튼의 가시성 등) |
/admin/layout-editor/{id} 에 모듈/플러그인 식별자 |
그 세그먼트는 템플릿 식별자 자리다. 모듈 라우트는 해당 타입 템플릿 트리에 병합되므로 템플릿으로 진입해야 한다 | 템플릿 식별자 (모듈 admin 라우트 → admin 템플릿) |
| 테스트 예산과 내부 대기를 같은 값으로 | test 기본 예산 30초 안에서 30초 waitForResponse 를 걸면 앞 단계가 조금만 늦어도 구조적으로 완주 불가 |
내부 대기를 줄이거나 test.setTimeout() 상향 |
§5. fixture 패턴
5.1 코어 fixture (tests/Playwright/fixtures/auth.ts)
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')),
});
권한 경계를 검증할 때는 issueScopedToken
issueToken 은 커맨드 기본 동작대로 사이트의 admin 역할을 함께 부여한다. admin 역할은 전체
권한을 보유하므로, --permissions 로 권한을 좁혀 넘겨도 화면은 항상 최대 권한으로 렌더된다.
읽기 전용 분기·권한 미보유 분기처럼 권한 경계 자체를 검증하려면 issueScopedToken 을 쓴다 —
--no-admin-role 을 붙여 지정한 권한만 가진 계정을 만든다.
// ❌ 읽기 전용 분기를 만들 수 없다 — admin 역할이 update 권한까지 함께 부여된다
await authenticatePage(page, issueToken('sirsoft-ecommerce.settings.read'));
await expect(page.locator('input[name="..."]')).toBeDisabled(); // 실패: enabled
// ✅ 지정한 권한만 가진 계정
await authenticatePage(page, issueScopedToken('sirsoft-ecommerce.settings.read'));
await expect(page.locator('input[name="..."]')).toBeDisabled(); // 통과
권한을 좁힌 계정은 코어 메뉴·알림 데이터소스에도 접근하지 못해 콘솔에 403 이 남을 수 있다. 그 화면 자체의 검증과 무관한 잡음이므로, 콘솔 에러 0 을 단언하는 테스트에는 이 토큰을 쓰지 않는다. 권한 밖 탭·라우트로 이동하면 403 에러 페이지로 전환되므로, 왕복 시나리오는 그 권한으로 접근 가능한 대상만 경유해야 한다.
5.2 확장 fixture — 권한 + 시드 분리
// 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')),
});
// 권한 + 시드 조합 — 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.
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 주입 + 클릭 시뮬레이션:
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 으로 분기 진입 검증:
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 에 마킹:
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단계를 스킵 불가:
- 실패하는 회귀 spec 작성 — 버그 재현 케이스 spec 작성
- baseline fail 확인 — 수정 전 실제 fail 확인 (테스트가 의도된 분기를 cover 함을 입증)
- 코드 수정
- 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 = 라이브 진단.
워크플로우:
- Playwright spec 이 fail
npx playwright show-trace test-results/<...>/trace.zip으로 timeline 확인- 단계별 DOM 스냅샷 + 네트워크 분석으로도 원인 불명 시 → Claude MCP
chrome-devtools-mcp로 동일 URL 라이브 진단 - 진단 결과로 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