Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
name: E2E

on:
pull_request:
branches: [develop, main]

concurrency:
group: e2e-${{ github.ref }}
cancel-in-progress: true

jobs:
e2e:
runs-on: ubuntu-latest
timeout-minutes: 20

steps:
- uses: actions/checkout@v4

- name: Setup pnpm
uses: pnpm/action-setup@v4

- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm

- name: Install dependencies
run: pnpm install --frozen-lockfile

- name: Install Playwright browsers
run: pnpm exec playwright install --with-deps chromium

- name: Run E2E tests
run: pnpm e2e

- name: Upload Playwright report
if: failure()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: playwright-report
if-no-files-found: ignore

- name: Upload test results
if: failure()
uses: actions/upload-artifact@v4
with:
name: test-results
path: test-results
if-no-files-found: ignore
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@

# testing
/coverage
/playwright-report
/test-results

# next.js
/.next/
Expand Down
15 changes: 9 additions & 6 deletions apis/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { kyInstance } from '@/apis/ky-instance';
import { addDoc, collection } from 'firebase/firestore';
import { db } from '@/firebase';
import { captureAppError } from '@/lib/error-policy';
import { IS_E2E_TEST_MODE } from '@/lib/e2e-mode';

export interface ChatMessage {
type: 'bot' | 'user' | 'system';
Expand Down Expand Up @@ -81,12 +82,14 @@ export const postBodyResult = async (req: BodyResultRequest) => {
timeout: 28000,
})
.json<BodyResultResponse>();
const colRef = collection(db, 'body_result');
const newReq = {
...req,
createdAt: new Date().toLocaleString().toString(),
};
await addDoc(colRef, newReq);
if (!IS_E2E_TEST_MODE) {
const colRef = collection(db, 'body_result');
const newReq = {
...req,
createdAt: new Date().toLocaleString().toString(),
};
await addDoc(colRef, newReq);
}
return response;
} catch (error) {
captureAppError(error, {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { BodyDiagnosisFormData } from '@/types/body';
import { IS_E2E_TEST_MODE } from '@/lib/e2e-mode';

export const MAX_UPLOAD_IMAGE_COUNT = 3;
export const MAX_UPLOAD_IMAGE_SIZE_MB = 5;
export const MAX_UPLOAD_IMAGE_SIZE_BYTES = MAX_UPLOAD_IMAGE_SIZE_MB * 1024 * 1024;
export const ALLOWED_IMAGE_MIME_TYPES = ['image/jpeg', 'image/png'] as const;

export const SUBMIT_DELAY_MS = 2000;
export const SUBMIT_DELAY_MS = IS_E2E_TEST_MODE ? 0 : 2000;

export const INITIAL_APPLICATION_FORM_DATA: BodyDiagnosisFormData = {
name: '',
Expand Down
5 changes: 3 additions & 2 deletions app/survey/hooks/useSurveyChat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@ import { useChat } from '@/hooks/useChat';
import type { ChatResponse } from '@/apis/chat';
import { useConnectionStatus } from './useConnectionStatus';
import { useSurveyCompletion } from './useSurveyCompletion';
import { IS_E2E_TEST_MODE } from '@/lib/e2e-mode';

const QUESTION_TRANSITION_DELAY_MS = 1500;
const RESPONSE_FAILURE_DELAY_MS = 1000;
const QUESTION_TRANSITION_DELAY_MS = IS_E2E_TEST_MODE ? 0 : 1500;
const RESPONSE_FAILURE_DELAY_MS = IS_E2E_TEST_MODE ? 0 : 1000;

export function useSurveyChat() {
const totalQuestions = surveyQuestions.length;
Expand Down
3 changes: 2 additions & 1 deletion components/auth-guard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,9 @@ import PageBackground from '@/components/common/page-background/page-background'
import PageContainer from '@/components/common/page-container/page-container';
import { setStorageJson, STORAGE_KEYS } from '@/lib/client-storage';
import { captureAppError, USER_ERROR_MESSAGES } from '@/lib/error-policy';
import { IS_E2E_TEST_MODE } from '@/lib/e2e-mode';

const AUTH_SUCCESS_REDIRECT_DELAY_MS = 1500;
const AUTH_SUCCESS_REDIRECT_DELAY_MS = IS_E2E_TEST_MODE ? 0 : 1500;

interface AuthGuardProps {
children: React.ReactNode;
Expand Down
27 changes: 27 additions & 0 deletions docs/e2e.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# E2E Testing (Playwright)

핵심 사용자 플로우를 E2E로 검증합니다.

- 시나리오: `Home -> Apply -> Survey -> Result`
- 테스트 파일: `e2e/core-user-flow.spec.ts`

## 실행

```bash
pnpm e2e
```

추가 옵션:

```bash
pnpm e2e:headed
pnpm e2e:ui
```

## 테스트 모드 동작

E2E 실행 시 dev 서버는 `NEXT_PUBLIC_E2E_TEST_MODE=true`로 실행됩니다.

- Firebase 함수(`applyBodyDiagnosis`, `saveSurveyAnswers`, `valueExists`)는 네트워크 의존 없이 테스트용 분기로 동작
- 일부 UI 지연시간(신청 완료/인증 완료/질문 전환)은 0ms로 단축
- `assistant/chat`, `assistant/body-result`는 Playwright에서 네트워크 응답을 mock 처리
86 changes: 86 additions & 0 deletions e2e/core-user-flow.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { expect, test } from '@playwright/test';

const TOTAL_SURVEY_QUESTIONS = 15;

test('Home -> Apply -> Survey -> Result 핵심 플로우', async ({ page }) => {
let chatRequestCount = 0;
let bodyResultRequestCount = 0;

await page.route('**/assistant/chat**', async (route) => {
chatRequestCount += 1;
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
isSuccess: true,
selected: 'A',
message: '테스트 응답입니다.',
nextQuestion: '',
}),
});
});

await page.route('**/assistant/body-result**', async (route) => {
bodyResultRequestCount += 1;
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
body_type: 'E2E_TEST_TYPE',
type_description: '테스트용 타입 설명',
detailed_features: '테스트 상세 특징',
attraction_points: '테스트 매력 포인트',
recommended_styles: '테스트 추천 스타일',
avoid_styles: '테스트 피해야 할 스타일',
styling_fixes: '테스트 스타일링 보정',
styling_tips: '테스트 스타일링 팁',
}),
});
});

await page.goto('/');

await page.locator('a[href="/apply"]').first().click();
await expect(page).toHaveURL(/\/apply/);

await page.fill('#name', '홍길동');
await page.fill('#phone', '01012345678');
await page.fill('#email', 'e2e@example.com');
await page.click('#female', { force: true });
await page.fill('#height', '165');
await page.fill('#weight', '55');
await page.click('#agreePrivacy', { force: true });
await page.click('#agreeService', { force: true });
await page.click('#card', { force: true });

await page.locator('button.w-full').last().click();
await expect(page).toHaveURL(/\/complete/);

await page.locator('a[href="/survey"]').first().click();
await expect(page).toHaveURL(/\/survey/);

await page.fill('#phone', '01012345678');
await page.keyboard.press('Enter');

await expect(page.locator('#phone')).toHaveCount(0);
const chatInput = page.locator('input').first();

for (let i = 0; i < TOTAL_SURVEY_QUESTIONS; i += 1) {
await expect(chatInput).toBeEnabled();
await chatInput.fill('A');
await chatInput.press('Enter');
}

await expect(page).toHaveURL(/\/result/);
await expect
.poll(() => chatRequestCount, { timeout: 20_000 })
.toBeGreaterThanOrEqual(TOTAL_SURVEY_QUESTIONS);
await expect.poll(() => bodyResultRequestCount, { timeout: 20_000 }).toBeGreaterThan(0);
await expect
.poll(
async () => page.evaluate(() => window.localStorage.getItem('body-result-storage') ?? ''),
{ timeout: 20_000 },
)
.toContain('E2E_TEST_TYPE');
await expect(page.locator('h2', { hasText: 'E2E_TEST_TYPE' })).toBeVisible({ timeout: 20_000 });
});
13 changes: 13 additions & 0 deletions firebase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { initializeApp } from 'firebase/app';
import { doc, getDoc, getFirestore, setDoc, serverTimestamp } from 'firebase/firestore';
import { BodyDiagnosisFormData } from '@/types/body';
import { getAnalytics, isSupported } from 'firebase/analytics';
import { IS_E2E_TEST_MODE } from '@/lib/e2e-mode';
// TODO: Add SDKs for Firebase products that you want to use
// https://firebase.google.com/docs/web/setup#available-libraries

Expand Down Expand Up @@ -31,6 +32,10 @@ if (typeof window !== 'undefined') {
const normalizePhone = (raw: string) => raw.replace(/\D/g, '');

export const applyBodyDiagnosis = async (req: BodyDiagnosisFormData) => {
if (IS_E2E_TEST_MODE) {
return;
}

const phoneId = normalizePhone(req.phone);
const newReq = {
...req,
Expand All @@ -49,6 +54,10 @@ const assertPhoneId = (raw: string) => {

// 설문 답변 저장
export async function saveSurveyAnswers(phone: string, answers: string[]): Promise<string> {
if (IS_E2E_TEST_MODE) {
return normalizePhone(phone);
}

const phoneId = assertPhoneId(phone);
const ref = doc(db, 'surveys', phoneId);
await setDoc(
Expand All @@ -64,6 +73,10 @@ export async function saveSurveyAnswers(phone: string, answers: string[]): Promi
}

export async function valueExists(collectionName: string, phone: string): Promise<boolean> {
if (IS_E2E_TEST_MODE) {
return collectionName === 'apply' && normalizePhone(phone).length >= 10;
}

const phoneId = assertPhoneId(phone);
const snap = await getDoc(doc(db, collectionName, phoneId));
return snap.exists();
Expand Down
1 change: 1 addition & 0 deletions lib/e2e-mode.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const IS_E2E_TEST_MODE = process.env.NEXT_PUBLIC_E2E_TEST_MODE === 'true';
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@
"build": "next build",
"start": "next start",
"lint": "eslint .",
"e2e": "pnpm exec playwright test",
"e2e:headed": "pnpm exec playwright test --headed",
"e2e:ui": "pnpm exec playwright test --ui",
"prepare": "husky"
},
"dependencies": {
Expand Down Expand Up @@ -36,6 +39,7 @@
},
"devDependencies": {
"@eslint/eslintrc": "^3",
"@playwright/test": "^1.58.2",
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
Expand Down
31 changes: 31 additions & 0 deletions playwright.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { defineConfig, devices } from '@playwright/test';

const devCommand = process.platform === 'win32' ? 'pnpm.cmd dev' : 'pnpm dev';

export default defineConfig({
testDir: './e2e',
timeout: 120_000,
fullyParallel: false,
retries: 0,
workers: 1,
reporter: 'list',
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
},
webServer: {
command: devCommand,
url: 'http://localhost:3000',
reuseExistingServer: true,
env: {
NEXT_PUBLIC_E2E_TEST_MODE: 'true',
NEXT_PUBLIC_API_URL: 'http://localhost:3000',
},
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
});
Loading