-
Notifications
You must be signed in to change notification settings - Fork 0
chore: Playwright E2E 테스트 환경 구축 #319
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: dev
Are you sure you want to change the base?
Changes from all commits
ebf43f0
9e654cc
b36968c
a5cd256
920eb28
8c3f799
6b07b33
bec89ed
34307b3
baa9f4a
a2c90d9
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| /** | ||
| * SSR 목 스텁 서버 포트. | ||
| * playwright.config.ts 가 NEXT_PUBLIC_API_URL 을 이 주소로 강제해 | ||
| * SSR(serverApi/rewrites) 요청이 실서버 대신 스텁으로 향한다. | ||
| */ | ||
| export const MOCK_API_PORT = 4010; | ||
|
|
||
| export const MOCK_API_URL = `http://127.0.0.1:${MOCK_API_PORT}`; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,117 @@ | ||
| import { expect, test as base } from '@playwright/test'; | ||
|
|
||
| import { ENDPOINTS } from '@/consts/api'; | ||
|
|
||
| import { createApiError, createApiSuccess } from '../helpers/apiResponse'; | ||
|
|
||
| type MockEntryT = { | ||
| method: string; | ||
| path: string; | ||
| status: number; | ||
| body: unknown; | ||
| }; | ||
|
|
||
| type ApiErrorOptionsT = { | ||
| status?: number; | ||
| detail?: string; | ||
| code?: string; | ||
| }; | ||
|
|
||
| export type ApiMockT = { | ||
| get: <T>(path: string, data: T) => void; | ||
| post: <T>(path: string, data: T) => void; | ||
| error: (method: 'GET' | 'POST' | 'PATCH' | 'DELETE', path: string, options?: ApiErrorOptionsT) => void; | ||
| }; | ||
|
|
||
| /** | ||
| * page.route 기반 API 목킹 fixture. | ||
| * | ||
| * - `**\/api/v1/**` 라우트 하나를 선점하고 pathname(쿼리스트링 무시) + method 로 매칭한다. | ||
| * - 사용 규칙: `api.get(...)` 등록 후 `page.goto(...)` 호출. | ||
| * - 목킹되지 않은 /api/v1 요청은 500 으로 실패시키고 테스트 말미에 단언한다 — 누락 목 즉시 감지. | ||
| * - SSR(serverApi·RSC 레이아웃) 요청은 브라우저 발이 아니라 여기서 못 잡는다. | ||
| * globalSetup 이 띄우는 목 스텁 서버(e2e/setup/mockApiServer.ts)가 대신 응답한다. | ||
| */ | ||
| export const test = base.extend<{ api: ApiMockT }>({ | ||
| /** 2번째 인자는 Playwright fixture 의 use — react-hooks lint 오인을 피해 provide 로 명명 */ | ||
| api: async ({ page }, provide) => { | ||
| const entries: MockEntryT[] = []; | ||
| const unmocked: string[] = []; | ||
|
|
||
| /** 401 시 axios interceptor 의 토큰 갱신 요청이 실패 루프를 타지 않도록 기본 제공 */ | ||
| entries.push({ | ||
| method: 'POST', | ||
| path: ENDPOINTS.AUTH_TOKEN_REFRESH, | ||
| status: 200, | ||
| body: createApiSuccess({ access_token: null, refresh_token: null }), | ||
| }); | ||
|
|
||
| /** 헤더 알림 아이콘(AlarmHeaderIcon)이 전 페이지에서 호출 — 빈 알림 기본 제공 */ | ||
| entries.push({ | ||
| method: 'GET', | ||
| path: ENDPOINTS.NOTIFICATIONS, | ||
| status: 200, | ||
| body: { | ||
| ...createApiSuccess({ items: [], unreadCount: 0 }), | ||
| pageResponse: { nextCursor: null, hasNext: false }, | ||
| }, | ||
| }); | ||
|
|
||
| await page.route('**/api/v1/**', route => { | ||
| const { pathname } = new URL(route.request().url()); | ||
| const method = route.request().method(); | ||
| const entry = [...entries].reverse().find(e => e.method === method && e.path === pathname); | ||
|
|
||
| if (!entry) { | ||
| unmocked.push(`${method} ${pathname}`); | ||
| return route.fulfill({ | ||
| status: 500, | ||
| contentType: 'application/json', | ||
| body: JSON.stringify( | ||
| createApiError({ | ||
| status: 500, | ||
| code: 'E2E_UNMOCKED', | ||
| detail: `목킹되지 않은 요청: ${method} ${pathname}`, | ||
| }) | ||
| ), | ||
| }); | ||
| } | ||
|
|
||
| return route.fulfill({ | ||
| status: entry.status, | ||
| contentType: 'application/json', | ||
| body: JSON.stringify(entry.body), | ||
| }); | ||
| }); | ||
|
|
||
| /** 알림 SSE(Next Route Handler — /api/v1 아님): 빈 스트림으로 응답해 upstream 접근 차단 */ | ||
| await page.route('**/api/notifications/subscribe', route => | ||
| route.fulfill({ status: 200, headers: { 'content-type': 'text/event-stream' }, body: '' }) | ||
| ); | ||
|
|
||
| /** | ||
| * dev 전용 react-grab 오버레이(layout.tsx의 unpkg 스크립트) 차단. | ||
| * - 주입된 오버레이가 trace 스냅샷 페인트를 통째로 막아 뷰어가 빈 화면이 된다 | ||
| * - 테스트 중 외부 CDN 의존 제거 (결정성). 프로덕션 빌드(CI)에는 원래 없음 | ||
| */ | ||
| await page.route('https://unpkg.com/**', route => route.abort()); | ||
|
|
||
| await provide({ | ||
| get: (path, data) => | ||
| entries.push({ method: 'GET', path, status: 200, body: createApiSuccess(data) }), | ||
| post: (path, data) => | ||
| entries.push({ method: 'POST', path, status: 200, body: createApiSuccess(data) }), | ||
|
Comment on lines
+99
to
+103
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. patch, delete가 없는데 이건 e2e 테스트 생성하면서 추가하면 될까? error는 get, post, delete, patch까지 다 대응되어 있는 것 같아서! |
||
| error: (method, path, options) => | ||
| entries.push({ | ||
| method, | ||
| path, | ||
| status: options?.status ?? 400, | ||
| body: createApiError(options), | ||
| }), | ||
| }); | ||
|
|
||
| expect(unmocked, `목킹되지 않은 API 요청:\n${unmocked.join('\n')}`).toEqual([]); | ||
| }, | ||
| }); | ||
|
|
||
| export { expect }; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,27 @@ | ||
| import type { ApiErrorResponseT, ApiResponseT } from '@/types/api'; | ||
|
|
||
| /** 팀 응답 규약 `{ status, data, detail, code }` 성공 래핑 */ | ||
| export const createApiSuccess = <T>(data: T, status = 200): ApiResponseT<T> => ({ | ||
| status, | ||
| data, | ||
| detail: '요청이 정상적으로 처리되었습니다.', | ||
| code: 'COMMON_SUCCESS', | ||
| }); | ||
|
|
||
| type CreateApiErrorOptionsT = { | ||
| status?: number; | ||
| detail?: string; | ||
| code?: string; | ||
| }; | ||
|
|
||
| /** 팀 응답 규약 에러 래핑 */ | ||
| export const createApiError = ({ | ||
| status = 400, | ||
| detail = 'E2E 목 에러 응답입니다.', | ||
| code = 'E2E_ERROR', | ||
| }: CreateApiErrorOptionsT = {}): ApiErrorResponseT => ({ | ||
| status, | ||
| data: null, | ||
| detail, | ||
| code, | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,13 @@ | ||
| const toBase64Url = (value: object) => Buffer.from(JSON.stringify(value)).toString('base64url'); | ||
|
|
||
| /** | ||
| * 미들웨어(src/proxy.ts)의 isTokenValid는 서명 검증 없이 payload.exp만 확인한다. | ||
| * exp가 미래인 JWT 형태 문자열이면 미들웨어를 통과하고, | ||
| * 서버사이드 게스트 로그인(postGuestLoginServer)이 발생하지 않는다 — 네트워크 0회. | ||
| */ | ||
| export const createFakeJwt = (expiresInSeconds = 60 * 60) => | ||
| [ | ||
| toBase64Url({ alg: 'HS256', typ: 'JWT' }), | ||
| toBase64Url({ sub: 'e2e-guest', exp: Math.floor(Date.now() / 1000) + expiresInSeconds }), | ||
| 'e2e-fake-signature', | ||
| ].join('.'); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,15 @@ | ||
| import { ENDPOINTS } from '@/consts/api'; | ||
|
|
||
| import { expect, test } from './fixtures/mockApiFixture'; | ||
| import { MOCK_GUEST_ME } from './mocks/me'; | ||
| import { MOCK_TOURNAMENT_LIST } from './mocks/tournament'; | ||
|
|
||
| test('홈에 진입하면 진행 중인 토너먼트 목록이 렌더링된다', async ({ page, api }) => { | ||
| api.get(ENDPOINTS.TOURNAMENTS, MOCK_TOURNAMENT_LIST); | ||
| api.get(ENDPOINTS.USER, MOCK_GUEST_ME); | ||
|
|
||
| await page.goto('/home'); | ||
|
|
||
| await expect(page.getByRole('heading', { name: '진행 중인 토너먼트' })).toBeVisible(); | ||
| await expect(page.getByText('E2E 토너먼트')).toBeVisible(); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| import type { GuestUserT } from '@/types/user'; | ||
|
|
||
| export const MOCK_GUEST_ME: GuestUserT = { | ||
| id: 'e2e-guest-id', | ||
| nickname: '피키게스트', | ||
| /** 외부 이미지 URL 금지 — next/image 가 서버사이드에서 fetch 해 목킹이 불가능하다 */ | ||
| profileImage: '', | ||
|
Comment on lines
+6
to
+7
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 아님 이런건 e2e 테스트 실제로 생성하면서 작업하는 게 나을까! |
||
| identityType: 'GUEST', | ||
| email: null, | ||
| }; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,32 @@ | ||
| import type { GetTournamentPendingResponseT } from '@/app/tournament/[id]/_common/_types/tournamentResponse'; | ||
| import type { TournamentT } from '@/types/tournament'; | ||
|
|
||
| export const MOCK_TOURNAMENT_LIST: TournamentT[] = [ | ||
| { | ||
| tournamentId: 1, | ||
| name: 'E2E 토너먼트', | ||
| status: 'PENDING', | ||
| createdAt: '2026-01-01T00:00:00Z', | ||
| /** 외부 이미지 URL 금지 — next/image 가 서버사이드에서 fetch 해 목킹이 불가능하다 */ | ||
| participantProfileImages: [], | ||
| }, | ||
| ]; | ||
|
|
||
| export const MOCK_TOURNAMENT_PENDING: GetTournamentPendingResponseT = { | ||
| tournamentId: 1, | ||
| name: 'E2E 토너먼트', | ||
| isOwner: true, | ||
| isRoot: true, | ||
| status: 'PENDING', | ||
| pending: { | ||
| ownerStarted: false, | ||
| inviteCode: 'E2ECODE', | ||
| /** 미래 시각으로 고정해 초대 만료 카운트다운이 항상 유효하도록 */ | ||
| inviteExpiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), | ||
| /** status 가 PENDING/PROCESSING 인 아이템을 두지 않아 SSE 폴백 폴링이 돌지 않게 함 */ | ||
| items: [], | ||
| participants: [ | ||
| { userId: 'e2e-guest-id', nickname: '피키게스트', itemCount: 0, profileImage: '' }, | ||
| ], | ||
| }, | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,35 @@ | ||
| import { test as setup } from '@playwright/test'; | ||
| import fs from 'node:fs'; | ||
| import path from 'node:path'; | ||
|
|
||
| import { createFakeJwt } from '../helpers/fakeJwt'; | ||
|
|
||
| const AUTH_FILE = path.join(__dirname, '../../playwright/.auth/guest.json'); | ||
|
|
||
| const TOKEN_TTL_SECONDS = 60 * 60; | ||
|
|
||
| setup('게스트 storageState 생성', async () => { | ||
| const token = createFakeJwt(TOKEN_TTL_SECONDS); | ||
| const expires = Math.floor(Date.now() / 1000) + TOKEN_TTL_SECONDS; | ||
|
|
||
| const createCookie = (name: string) => ({ | ||
| name, | ||
| value: token, | ||
| domain: 'localhost', | ||
| path: '/', | ||
| expires, | ||
| httpOnly: false, | ||
| secure: false, | ||
| sameSite: 'Lax' as const, | ||
| }); | ||
|
|
||
| fs.mkdirSync(path.dirname(AUTH_FILE), { recursive: true }); | ||
| fs.writeFileSync( | ||
| AUTH_FILE, | ||
| JSON.stringify( | ||
| { cookies: [createCookie('access_token'), createCookie('refresh_token')], origins: [] }, | ||
| null, | ||
| 2 | ||
| ) | ||
| ); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| import { MOCK_API_PORT } from '../consts'; | ||
| import { startMockApiServer } from './mockApiServer'; | ||
|
|
||
| /** 테스트 러너 프로세스에서 SSR 목 스텁 서버를 띄우고, 종료 시 함께 내린다 */ | ||
| const globalSetup = async () => { | ||
| /** null 이면 다른 세션(UI 모드 등)이 띄운 기존 스텁을 재사용 — 내리지 않는다 */ | ||
| const server = await startMockApiServer(MOCK_API_PORT); | ||
|
Comment on lines
+6
to
+7
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '\n== Files ==\n'
git ls-files 'apps/web/e2e/setup/*' 'apps/web/e2e/**/*mock*' 'apps/web/e2e/**/*setup*' | sort
printf '\n== Outline: globalSetup ==\n'
ast-grep outline apps/web/e2e/setup/globalSetup.ts --view expanded
printf '\n== Outline: mockApiServer ==\n'
ast-grep outline apps/web/e2e/setup/mockApiServer.ts --view expanded
printf '\n== Relevant excerpts ==\n'
sed -n '1,220p' apps/web/e2e/setup/globalSetup.ts | cat -n
printf '\n--- mockApiServer.ts ---\n'
sed -n '1,260p' apps/web/e2e/setup/mockApiServer.ts | cat -nRepository: TeamPiKi/PiKi-Client Length of output: 3846 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '\n== MOCK_API_PORT references ==\n'
rg -n --hidden --glob '!**/node_modules/**' --glob '!**/.next/**' 'MOCK_API_PORT|startMockApiServer\(' apps/web
printf '\n== consts file ==\n'
sed -n '1,220p' apps/web/e2e/consts.ts | cat -n
printf '\n== e2e setup files ==\n'
sed -n '1,220p' apps/web/e2e/setup/auth.setup.ts | cat -n
printf '\n--- fixture ---\n'
sed -n '1,240p' apps/web/e2e/fixtures/mockApiFixture.ts | cat -n
printf '\n== package scripts mentioning e2e or ui mode ==\n'
sed -n '1,240p' package.json | cat -nRepository: TeamPiKi/PiKi-Client Length of output: 8127 기존 서버 재사용 전에 신원을 확인하세요 🤖 Prompt for AI Agents |
||
|
|
||
| return () => | ||
| new Promise<void>(resolve => { | ||
| if (!server) return resolve(); | ||
| server.close(() => resolve()); | ||
| }); | ||
| }; | ||
|
|
||
| export default globalSetup; | ||

There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
failure 발생하면 디스코드로 알림 보내주면 어떨까??
이거는 근데 discord-pr-bot 쪽 워크플로우를 건드려야하는 거긴 해