|
| 1 | +# Playwright 테스트 작성 가이드 |
| 2 | + |
| 3 | +이 문서는 csereal-web-v2 프로젝트의 Playwright E2E 테스트 작성 시 따라야 할 중요한 의사결정과 컨벤션을 담고 있습니다. |
| 4 | + |
| 5 | +## 테스트 파일 구조 |
| 6 | + |
| 7 | +### 폴더 구조 |
| 8 | +테스트 파일은 **실제 라우트 구조를 따라** 구성합니다. `-edit` 같은 접미사는 사용하지 않습니다. |
| 9 | + |
| 10 | +``` |
| 11 | +tests/ |
| 12 | +├── helpers/ # 재사용 가능한 헬퍼 함수들 |
| 13 | +│ ├── auth.ts # 로그인/로그아웃 |
| 14 | +│ ├── form-components.ts # Form 컴포넌트 조작 |
| 15 | +│ └── test-assets.ts # 테스트용 이미지/파일 생성 |
| 16 | +├── about/ # /about/* 경로 테스트 |
| 17 | +│ ├── overview.spec.ts |
| 18 | +│ ├── greetings.spec.ts |
| 19 | +│ └── ... |
| 20 | +├── academics/ |
| 21 | +│ ├── undergraduate/ |
| 22 | +│ └── graduate/ |
| 23 | +└── ... |
| 24 | +``` |
| 25 | + |
| 26 | +**예시:** |
| 27 | +- 라우트: `/about/overview` → 테스트: `tests/about/overview.spec.ts` |
| 28 | +- 라우트: `/academics/undergraduate/guide` → 테스트: `tests/academics/undergraduate/guide.spec.ts` |
| 29 | + |
| 30 | +## 아키텍처 패턴 |
| 31 | + |
| 32 | +### Helper 함수 방식 채택 (POM 미사용) |
| 33 | +- **Page Object Model (POM)을 사용하지 않습니다** |
| 34 | +- 대신 **함수형 헬퍼 패턴**을 사용합니다 |
| 35 | +- 이유: 간단하고 직관적이며, Form 컴포넌트 재사용에 적합 |
| 36 | + |
| 37 | +### Form 컴포넌트 헬퍼 |
| 38 | +`app/components/form`의 컴포넌트들이 여러 페이지에서 재사용되므로, 테스트도 동일하게 재사용 가능한 헬퍼 함수로 작성합니다. |
| 39 | + |
| 40 | +**중요 원칙:** |
| 41 | +- 각 Form 컴포넌트마다 대응하는 헬퍼 함수 제공 |
| 42 | +- 헬퍼 함수는 **단일 책임**만 수행 |
| 43 | +- 복합 동작(예: 언어 전환 + 입력)은 테스트 코드에서 조합 |
| 44 | + |
| 45 | +## 테스트용 데이터 생성 |
| 46 | + |
| 47 | +### 타임스탬프 사용 규칙 |
| 48 | +모든 테스트 데이터에는 **locale date time string**을 사용합니다. |
| 49 | + |
| 50 | +**중요:** 타임스탬프는 **테스트 시작 시 한 번만 생성**하여 모든 데이터에서 공유합니다. |
| 51 | + |
| 52 | +```typescript |
| 53 | +// ✅ 좋은 예: 타임스탬프를 한 번만 생성 |
| 54 | +const dateTimeString = getKoreanDateTime(); |
| 55 | +const koText = `Playwright KO ${dateTimeString}`; |
| 56 | +const { imagePath } = await createTestImage(testInfo, dateTimeString); |
| 57 | +const { filePath } = createTestTextFile(testInfo, dateTimeString); |
| 58 | + |
| 59 | +// ❌ 나쁜 예: 각 함수에서 개별 생성 (시점 차이로 불일치 발생) |
| 60 | +const { imagePath } = await createTestImage(testInfo); // 내부에서 생성 |
| 61 | +const { filePath } = createTestTextFile(testInfo); // 내부에서 생성 |
| 62 | +``` |
| 63 | + |
| 64 | +**적용 위치:** |
| 65 | +1. 본문 텍스트: `Playwright KO 2024. 12. 27. 15:30:45` |
| 66 | +2. 파일명: `test-file-2024-12-27_15-30-45.txt` |
| 67 | +3. 이미지: placehold.co URL의 텍스트 |
| 68 | +4. 첨부파일 내용 |
| 69 | + |
| 70 | +### 이미지 생성 |
| 71 | +**placehold.co를 사용**하여 매번 고유한 이미지를 동적으로 생성합니다. |
| 72 | + |
| 73 | +```typescript |
| 74 | +const randomColor = Math.floor(Math.random() * 16777215).toString(16).padStart(6, '0'); |
| 75 | +const imageUrl = `https://placehold.co/600x400/${randomColor}/white/png?text=${encodeURIComponent(dateTimeString)}`; |
| 76 | +``` |
| 77 | + |
| 78 | +**장점:** |
| 79 | +- 매번 다른 색상 + 타임스탬프로 확실한 고유성 |
| 80 | +- 실제 PNG 이미지 생성 |
| 81 | +- 뷰어에서 시각적으로 확인 가능 |
| 82 | + |
| 83 | +**단점:** |
| 84 | +- 네트워크 요청 필요 |
| 85 | +- 오프라인 환경에서 작동 불가 |
| 86 | + |
| 87 | +## 헬퍼 함수 설계 원칙 |
| 88 | + |
| 89 | +### 1. `helpers/auth.ts` |
| 90 | +인증 관련 동작을 담당합니다. |
| 91 | + |
| 92 | +```typescript |
| 93 | +loginAsStaff(page) // STAFF 로그인 |
| 94 | +logout(page) // 로그아웃 |
| 95 | +``` |
| 96 | + |
| 97 | +### 2. `helpers/form-components.ts` |
| 98 | +Form 컴포넌트별 조작 함수를 제공합니다. |
| 99 | + |
| 100 | +**중요: 각 함수는 하나의 책임만 가집니다** |
| 101 | + |
| 102 | +```typescript |
| 103 | +// ✅ 좋은 예: 단일 책임 |
| 104 | +fillHTMLEditor(page, content) // 현재 언어 에디터에만 입력 |
| 105 | +switchLanguage(page, lang) // 언어 전환만 |
| 106 | + |
| 107 | +// ❌ 나쁜 예: 복합 책임 |
| 108 | +fillHTMLEditor(page, { ko, en }) // 언어 전환 + 입력 동시 수행 |
| 109 | +``` |
| 110 | + |
| 111 | +**제공하는 함수들:** |
| 112 | +- `fillHTMLEditor(page, content)` - 현재 언어의 에디터에 내용 입력 |
| 113 | +- `switchEditorLanguage(page, lang)` - 폼 에디터의 한/영 언어 전환 (300ms 대기 포함) |
| 114 | +- `uploadImage(page, imagePath)` - Form.Image 업로드 |
| 115 | +- `clearAllFiles(page, fieldsetName?)` - Form.File 모두 삭제 |
| 116 | +- `uploadFiles(page, paths, fieldsetName?)` - Form.File 업로드 |
| 117 | +- `submitForm(page)` - 저장하기 버튼 클릭 후 load 대기 |
| 118 | +- `cancelForm(page)` - 취소 버튼 클릭 |
| 119 | + |
| 120 | +### 3. `helpers/test-assets.ts` |
| 121 | +테스트용 에셋 생성을 담당합니다. |
| 122 | + |
| 123 | +```typescript |
| 124 | +createTestImage(testInfo) // placehold.co 이미지 생성 |
| 125 | +createTestTextFile(testInfo) // 텍스트 파일 생성 |
| 126 | +``` |
| 127 | + |
| 128 | +### 4. `helpers/navigation.ts` |
| 129 | +페이지 네비게이션 관련 헬퍼 함수를 제공합니다. |
| 130 | + |
| 131 | +```typescript |
| 132 | +switchPageLanguage(page, lang) // 페이지 상단의 KO/ENG 버튼 클릭 후 load 대기 |
| 133 | +``` |
| 134 | + |
| 135 | +**주의:** `switchPageLanguage`는 폼 에디터의 언어 전환이 아닌, 페이지 전체의 언어를 전환합니다. |
| 136 | + |
| 137 | +### 5. `helpers/utils.ts` |
| 138 | +공통 유틸리티 함수를 제공합니다. |
| 139 | + |
| 140 | +```typescript |
| 141 | +getKoreanDateTime() // "2024. 12. 27. 15:30:45" 형식 반환 |
| 142 | +getFileNameDateTime() // "2024-12-27_15-30-45" 형식 반환 |
| 143 | +``` |
| 144 | + |
| 145 | +## 설정 |
| 146 | + |
| 147 | +### baseURL 사용 |
| 148 | +`playwright.config.ts`에 baseURL이 설정되어 있으므로, 테스트에서는 **상대 경로**만 사용합니다. |
| 149 | + |
| 150 | +```typescript |
| 151 | +// ✅ 좋은 예 |
| 152 | +await page.goto('/about/overview'); |
| 153 | + |
| 154 | +// ❌ 나쁜 예 |
| 155 | +await page.goto('http://localhost:3000/about/overview'); |
| 156 | +``` |
| 157 | + |
| 158 | +**장점:** |
| 159 | +- vite.config.ts의 port와 자동 동기화 |
| 160 | +- 하드코딩 제거 |
| 161 | +- 환경별 baseURL 설정 가능 |
| 162 | + |
| 163 | +## 테스트 작성 패턴 |
| 164 | + |
| 165 | +### 기본 구조 |
| 166 | +모든 편집 테스트는 다음 흐름을 따릅니다: |
| 167 | + |
| 168 | +```typescript |
| 169 | +test('edit [페이지명] and verify ko/en content', async ({ page }, testInfo) => { |
| 170 | + // 1. 테스트 데이터 준비 |
| 171 | + const dateTimeString = getKoreanDateTime(); |
| 172 | + const koText = `Playwright KO ${dateTimeString}`; |
| 173 | + const enText = `Playwright EN ${dateTimeString}`; |
| 174 | + const { imagePath } = await createTestImage(testInfo); |
| 175 | + const { filePath, fileName } = createTestTextFile(testInfo); |
| 176 | + |
| 177 | + // 2. 로그인 |
| 178 | + await page.goto('/...'); // baseURL 사용하므로 상대 경로만 |
| 179 | + await loginAsStaff(page); |
| 180 | + |
| 181 | + // 3. 편집 페이지로 이동 |
| 182 | + await page.getByRole('link', { name: '편집' }).click(); |
| 183 | + await page.waitForURL('**/edit'); |
| 184 | + |
| 185 | + // 4. 폼 입력 (헬퍼 함수 조합) |
| 186 | + await fillHTMLEditor(page, koText); |
| 187 | + await switchEditorLanguage(page, 'en'); |
| 188 | + await fillHTMLEditor(page, enText); |
| 189 | + await switchEditorLanguage(page, 'ko'); |
| 190 | + await uploadImage(page, imagePath); |
| 191 | + await clearAllFiles(page); |
| 192 | + await uploadFiles(page, filePath); |
| 193 | + |
| 194 | + // 5. 제출 |
| 195 | + await submitForm(page); |
| 196 | + // 뷰어 페이지로 돌아왔는지 명시적으로 확인 |
| 197 | + await page.waitForURL('**/about/overview'); |
| 198 | + |
| 199 | + // 6. 검증 - 한글 페이지 |
| 200 | + await expect(page.getByText(koText)).toBeVisible(); |
| 201 | + await expect(page.getByText(fileName)).toBeVisible(); |
| 202 | + |
| 203 | + // 7. 검증 - 영문 페이지 |
| 204 | + await switchPageLanguage(page, 'en'); |
| 205 | + // 영문 페이지로 이동했는지 명시적으로 확인 |
| 206 | + await page.waitForURL('**/en/about/overview'); |
| 207 | + await expect(page.getByText(enText)).toBeVisible(); |
| 208 | +}); |
| 209 | +``` |
| 210 | + |
| 211 | +## Suneditor 특이사항 |
| 212 | + |
| 213 | +프로젝트는 **suneditor**를 사용합니다 (toast-ui가 아님). |
| 214 | + |
| 215 | +**에디터 선택자:** |
| 216 | +```typescript |
| 217 | +page.locator('.sun-editor-editable') |
| 218 | +``` |
| 219 | + |
| 220 | +**언어 전환 방식:** |
| 221 | +- 라디오 버튼은 `appearance-none`으로 숨겨져 있음 |
| 222 | +- **label을 클릭**해야 함: |
| 223 | +```typescript |
| 224 | +page.locator('label[for="ko"]').click() |
| 225 | +page.locator('label[for="en"]').click() |
| 226 | +``` |
| 227 | + |
| 228 | +## 파일 업로드 처리 |
| 229 | + |
| 230 | +### 기존 파일 삭제 |
| 231 | +Form.File의 기존 파일을 삭제할 때 주의사항: |
| 232 | + |
| 233 | +```typescript |
| 234 | +// ✅ 올바른 방법: 항상 첫 번째 항목 삭제 (리스트가 재정렬되므로) |
| 235 | +for (let i = 0; i < fileCount; i++) { |
| 236 | + const deleteButton = fileFieldset.locator('ol li button').first(); |
| 237 | + await deleteButton.click(); |
| 238 | +} |
| 239 | + |
| 240 | +// ❌ 잘못된 방법: nth(i) 사용 시 오류 |
| 241 | +for (let i = 0; i < fileCount; i++) { |
| 242 | + await fileFieldset.locator('ol li button').nth(i).click(); // 동작 안 함 |
| 243 | +} |
| 244 | +``` |
| 245 | + |
| 246 | +## 중요한 대기(wait) 처리 |
| 247 | + |
| 248 | +### 네비게이션 대기 원칙 |
| 249 | +**명시적으로 URL 확인**을 우선합니다. `waitForLoadState`보다 `waitForURL`이 더 안정적입니다. |
| 250 | + |
| 251 | +```typescript |
| 252 | +// ✅ 좋은 예: 명시적으로 URL 확인 |
| 253 | +await submitForm(page); |
| 254 | +await page.waitForURL('**/about/overview'); |
| 255 | + |
| 256 | +await switchPageLanguage(page, 'en'); |
| 257 | +await page.waitForURL('**/en/about/overview'); |
| 258 | + |
| 259 | +// ❌ 나쁜 예: loadState만 의존 |
| 260 | +await submitForm(page); // 내부에서 waitForLoadState만 수행 |
| 261 | +// 바로 다음 동작 수행 - 네비게이션이 완료되지 않았을 수 있음 |
| 262 | +``` |
| 263 | + |
| 264 | +### 필수 대기가 필요한 경우: |
| 265 | +1. **에디터 언어 전환**: `switchEditorLanguage()` - 내부에서 300ms 대기 |
| 266 | +2. **페이지 이동**: `waitForURL('**/path')` - 네비게이션 완료 확인 |
| 267 | + |
| 268 | +### 대기가 불필요한 경우: |
| 269 | +- **파일 업로드**: `setInputFiles()`는 await만으로 충분 |
| 270 | +- **폼 제출/페이지 언어 전환**: 헬퍼 함수가 `load` 상태까지 자동 대기 |
| 271 | +- **일반 요소 상호작용**: Playwright의 auto-waiting이 처리 |
| 272 | + |
| 273 | +### 헬퍼 함수의 자동 대기: |
| 274 | +- `submitForm()`: `waitForLoadState('load')` 포함 |
| 275 | +- `switchPageLanguage()`: `waitForLoadState('load')` 포함 |
| 276 | +- `switchEditorLanguage()`: `waitForTimeout(300)` 포함 (에디터 전환용) |
| 277 | + |
| 278 | +## 테스트 실행 |
| 279 | + |
| 280 | +```bash |
| 281 | +# 단일 브라우저 |
| 282 | +npx playwright test tests/about/overview.spec.ts --project=chromium |
| 283 | + |
| 284 | +# 모든 브라우저 |
| 285 | +npx playwright test tests/about/overview.spec.ts |
| 286 | + |
| 287 | +# 헤드풀 모드 (디버깅용) |
| 288 | +npx playwright test tests/about/overview.spec.ts --headed |
| 289 | +``` |
| 290 | + |
| 291 | +## 향후 고려사항 |
| 292 | + |
| 293 | +현재는 Helper 함수 방식을 사용하지만, 프로젝트가 커지면 다음을 고려할 수 있습니다: |
| 294 | + |
| 295 | +1. **하이브리드 방식**: Form 컴포넌트는 helper로, 페이지별 복잡한 로직은 POM으로 |
| 296 | +2. **Fixture 확장**: 공통 setup을 Playwright fixture로 추출 |
| 297 | +3. **데이터 분리**: 테스트 데이터를 별도 파일로 관리 |
| 298 | + |
| 299 | +--- |
| 300 | + |
| 301 | +**마지막 업데이트:** 2024-12-27 |
| 302 | +**작성자:** Claude (Sonnet 4.5) |
0 commit comments