Skip to content

Commit 80e910f

Browse files
committed
test /about/overview
1 parent b4b562f commit 80e910f

File tree

15 files changed

+594
-107
lines changed

15 files changed

+594
-107
lines changed

CLAUDE.md

Lines changed: 302 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,302 @@
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)

app/components/form/Action.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ export default function Action({
5656
</Button>
5757
)}
5858
<Button
59+
type="submit"
5960
variant="solid"
6061
tone="inverse"
6162
disabled={isSubmitting}

app/components/form/Form.tsx

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
11
import type { ReactNode } from 'react';
2-
2+
import HTMLEditor from '~/components/form/html/HTMLEditor';
33
import Action from './Action';
44
import Checkbox from './Checkbox';
55
import DatePicker from './DatePicker';
66
import Dropdown from './Dropdown';
77
import FilePicker from './File';
8-
import LazyHTMLEditor from './html/LazyHTMLEditor';
98
import ImagePicker from './Image';
109
import Radio from './Radio';
1110
import Section from './Section';
@@ -29,6 +28,6 @@ export default Object.assign(Form, {
2928
Dropdown,
3029
Section,
3130
TextArea,
32-
HTML: LazyHTMLEditor,
31+
HTML: HTMLEditor,
3332
Date: DatePicker,
3433
});

0 commit comments

Comments
 (0)