From da60cb248b3dd87005702b1dcfc78f37f87e4e6d Mon Sep 17 00:00:00 2001 From: electrohyun Date: Wed, 8 Jul 2026 16:59:54 +0900 Subject: [PATCH 1/5] =?UTF-8?q?test:=20(#59)=20=EC=BB=B4=ED=8F=AC=EB=84=8C?= =?UTF-8?q?=ED=8A=B8=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=A3=BC=EC=84=9D=20?= =?UTF-8?q?=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/auth/ui/StartButton.test.tsx | 11 --------- .../ui/ChallengeQuizQuestionPanel.test.tsx | 2 -- .../ui/MiniQuizAnswerForm.test.tsx | 23 +++---------------- src/shared/ui/button/Button.test.tsx | 3 --- src/shared/ui/pagination/Pagination.test.tsx | 10 -------- 5 files changed, 3 insertions(+), 46 deletions(-) diff --git a/src/features/auth/ui/StartButton.test.tsx b/src/features/auth/ui/StartButton.test.tsx index c905f40..2941aed 100644 --- a/src/features/auth/ui/StartButton.test.tsx +++ b/src/features/auth/ui/StartButton.test.tsx @@ -6,38 +6,27 @@ import StartButton from "./StartButton"; describe("StartButton", () => { it("children을 라벨로 가진 버튼을 화면에 그린다", () => { - // render: 컴포넌트를 가짜 브라우저(jsdom) 안에 그린다. render(시작하기); - // screen: 방금 그린 화면을 뒤지는 도구. - // getByRole("button", { name }): "시작하기 라는 이름의 버튼"을 찾는다. 없으면 여기서 에러. const button = screen.getByRole("button", { name: "시작하기" }); - // toBeInTheDocument: 그 버튼이 화면에 실제로 있나? (jest-dom 매처) expect(button).toBeInTheDocument(); }); it("처음에는 모달이 떠 있지 않다", () => { render(시작하기); - // allowGuestEntry 기본값은 false → 열리면 모달 제목이 "로그인". - // 아직 안 눌렀으니 그 제목은 화면에 "없어야" 한다. - // queryByText: 없을 수도 있는 걸 찾을 땐 query* 를 쓴다(없으면 에러 대신 null 반환). expect(screen.queryByText("로그인")).not.toBeInTheDocument(); }); it("버튼을 누르면 모달이 열린다", async () => { - // userEvent.setup(): 가상 사용자(손가락)를 준비. 클릭/입력은 비동기라 await가 필요하다. const user = userEvent.setup(); render(시작하기); - // 누르기 전엔 모달 제목이 없다. expect(screen.queryByText("로그인")).not.toBeInTheDocument(); - // 버튼을 클릭 → isModalOpen이 true → 모달이 열린다. await user.click(screen.getByRole("button", { name: "시작하기" })); - // 이제 모달 제목("로그인")이 화면에 떠 있어야 한다. expect(screen.getByText("로그인")).toBeInTheDocument(); }); }); diff --git a/src/features/daily-quiz/ui/ChallengeQuizQuestionPanel.test.tsx b/src/features/daily-quiz/ui/ChallengeQuizQuestionPanel.test.tsx index 9fcf609..ccf08d9 100644 --- a/src/features/daily-quiz/ui/ChallengeQuizQuestionPanel.test.tsx +++ b/src/features/daily-quiz/ui/ChallengeQuizQuestionPanel.test.tsx @@ -5,8 +5,6 @@ import type { ReactNode } from "react"; import ChallengeQuizQuestionPanel from "./ChallengeQuizQuestionPanel"; -// vi.mock은 파일 위로 끌어올려지므로, mock이 참조할 값도 vi.hoisted로 함께 올린다. -// holder.value를 테스트마다 바꿔 context를 갈아끼운다. const { holder } = vi.hoisted(() => ({ holder: { value: null as unknown } })); vi.mock("./ChallengeQuizProvider", () => ({ diff --git a/src/features/mini-quiz-stage/ui/MiniQuizAnswerForm.test.tsx b/src/features/mini-quiz-stage/ui/MiniQuizAnswerForm.test.tsx index 4ec6fc9..76cda8f 100644 --- a/src/features/mini-quiz-stage/ui/MiniQuizAnswerForm.test.tsx +++ b/src/features/mini-quiz-stage/ui/MiniQuizAnswerForm.test.tsx @@ -4,13 +4,6 @@ import { describe, it, expect, vi } from "vitest"; import MiniQuizAnswerForm from "./MiniQuizAnswerForm"; -// ── 컨텍스트 세팅(방법 B) ───────────────────────────────────────── -// MiniQuizAnswerForm은 useMiniQuizStageContext()에서 값을 받는다. -// 진짜 Provider(라우터·스토어·타이머 덩어리) 대신, 그 훅만 가짜로 바꿔치기해서 -// "우리가 정한 컨텍스트 값"을 뱉게 한다. → 이 컴포넌트의 렌더링만 격리해서 본다. -// -// vi.hoisted: vi.mock은 파일 맨 위로 끌어올려지므로, mock이 참조할 값도 -// 같이 끌어올려 둔다. holder.value를 테스트마다 바꿔 컨텍스트를 갈아끼운다. const { holder } = vi.hoisted(() => ({ holder: { value: null as unknown } })); vi.mock("./MiniQuizStageProvider", () => ({ @@ -36,14 +29,12 @@ function makeContext(overrides: Record = {}) { selectedAnswer: null, submittedAnswer: null, setCommandAnswer: vi.fn(), - selectAnswer: vi.fn(), // 가짜 함수: "불렸나 / 뭘 넣고 불렸나"를 기록한다. + selectAnswer: vi.fn(), ...overrides, }; } -// ────────────────────────────────────────────────────────────────── describe("MiniQuizAnswerForm", () => { - // 케이스 1: command 타입이면 명령어 입력창을 그린다. it("command 타입이면 입력창(textbox)을 그린다", () => { holder.value = makeContext({ currentQuestion: { @@ -60,13 +51,11 @@ describe("MiniQuizAnswerForm", () => { render(); expect(screen.getByRole("textbox")).toBeInTheDocument(); - // 객관식 옵션 버튼은 없어야 한다. expect(screen.queryByRole("button")).not.toBeInTheDocument(); }); - // 케이스 2: mcq 타입이면 옵션 버튼들을 그린다. it("mcq 타입이면 옵션 버튼들을 그린다", () => { - holder.value = makeContext(); // 기본값이 옵션 2개짜리 mcq + holder.value = makeContext(); render(); @@ -78,7 +67,6 @@ describe("MiniQuizAnswerForm", () => { ).toBeInTheDocument(); }); - // 케이스 3: options가 없으면 아무것도 그리지 않는다(null). it("mcq인데 options가 없으면 아무것도 그리지 않는다", () => { holder.value = makeContext({ currentQuestion: { @@ -94,11 +82,9 @@ describe("MiniQuizAnswerForm", () => { const { container } = render(); - // 화면에 아무 요소도 없다. expect(container).toBeEmptyDOMElement(); }); - // 케이스 4: isFeedback(채점 후)이면 옵션 버튼이 비활성이다. it("isFeedback이면 옵션 버튼이 비활성(disabled)이다", () => { holder.value = makeContext({ isFeedback: true }); @@ -107,18 +93,15 @@ describe("MiniQuizAnswerForm", () => { expect(screen.getByRole("button", { name: "선택지 A" })).toBeDisabled(); }); - // 케이스 5: 옵션을 누르면 그 id로 selectAnswer가 불린다. (제일 핵심 상호작용) it("옵션을 누르면 그 옵션 id로 selectAnswer가 호출된다", async () => { const user = userEvent.setup(); const context = makeContext(); - holder.value = context; // 이번 테스트가 쓸 컨텍스트를 꽂는다. + holder.value = context; render(); - // "선택지 A" 버튼을 클릭. await user.click(screen.getByRole("button", { name: "선택지 A" })); - // selectAnswer가 그 옵션 id("a")로 호출됐어야 한다. expect(context.selectAnswer).toHaveBeenCalledWith("a"); }); }); diff --git a/src/shared/ui/button/Button.test.tsx b/src/shared/ui/button/Button.test.tsx index 88cdd56..e53aca9 100644 --- a/src/shared/ui/button/Button.test.tsx +++ b/src/shared/ui/button/Button.test.tsx @@ -13,9 +13,6 @@ describe("Button", () => { it("leftIcon을 주면 렌더된다", () => { render(); expect(screen.getByText("아이콘")).toBeInTheDocument(); - - // const { container } = render(); - // expect(container.querySelector("svg")).toBeInTheDocument(); }); it("loading이면 스피너가 뜬다", () => { diff --git a/src/shared/ui/pagination/Pagination.test.tsx b/src/shared/ui/pagination/Pagination.test.tsx index f5e18db..06991da 100644 --- a/src/shared/ui/pagination/Pagination.test.tsx +++ b/src/shared/ui/pagination/Pagination.test.tsx @@ -4,8 +4,6 @@ import type { ComponentProps } from "react"; import Pagination from "./Pagination"; -// Pagination은 내부에서 SoundLink를 쓴다 → SoundLink가 의존하는 것들을 함께 mock. -// (SoundLink.test.tsx에서 배운 3종 세트 재사용) vi.mock("@/shared/lib/sound/soundPlayer", () => ({ playClickSound: vi.fn(), })); @@ -13,14 +11,12 @@ vi.mock("@/shared/model/sound/soundStore", () => ({ useSoundStore: (selector: (state: { soundSettings: unknown }) => unknown) => selector({ soundSettings: { muted: false, volume: 1 } }), })); -// 여기선 href를 "그대로 유지"한다 — Pagination의 핵심이 "어느 페이지를 가리키나"라서. vi.mock("next/link", () => ({ default: ({ children, ...props }: ComponentProps<"a">) => ( {children} ), })); -// 매번 같은 기본 props로 렌더하는 헬퍼. 필요한 것만 덮어쓴다. type RenderProps = { currentPage?: number; totalPages?: number; @@ -37,7 +33,6 @@ const renderPagination = (props: RenderProps = {}) => ); describe("Pagination", () => { - // 1. totalPages 수만큼 번호 버튼이 렌더된다. (5는 있고 6은 없다 = 정확히 5개) it("totalPages 수만큼 페이지 번호가 렌더된다", () => { renderPagination({ totalPages: 5 }); @@ -45,7 +40,6 @@ describe("Pagination", () => { expect(screen.queryByRole("link", { name: "6" })).not.toBeInTheDocument(); }); - // 2. 현재 페이지 번호에 aria-current="page"가 붙는다. it("currentPage 번호에 aria-current가 표시된다", () => { renderPagination({ currentPage: 2, totalPages: 5 }); @@ -55,7 +49,6 @@ describe("Pagination", () => { ); }); - // 3. ⭐ 경계(하한): 1페이지에서 "이전"은 0이 아니라 1로 clamp된다. it("첫 페이지에서 '이전'은 1페이지로 clamp된다", () => { renderPagination({ currentPage: 1, totalPages: 5 }); @@ -65,7 +58,6 @@ describe("Pagination", () => { ); }); - // 4. ⭐ 경계(상한): 마지막 페이지에서 "다음"은 초과하지 않고 마지막으로 clamp된다. it("마지막 페이지에서 '다음'은 마지막 페이지로 clamp된다", () => { renderPagination({ currentPage: 5, totalPages: 5 }); @@ -75,7 +67,6 @@ describe("Pagination", () => { ); }); - // 5. "첫 페이지" 버튼은 현재 위치와 무관하게 항상 1을 가리킨다. it("'첫 페이지' 버튼은 1페이지를 가리킨다", () => { renderPagination({ currentPage: 3, totalPages: 5 }); @@ -85,7 +76,6 @@ describe("Pagination", () => { ); }); - // 6. "마지막 페이지" 버튼은 항상 마지막(totalPages)을 가리킨다. it("'마지막 페이지' 버튼은 마지막 페이지를 가리킨다", () => { renderPagination({ currentPage: 3, totalPages: 5 }); From 0397ed0b0fb7002b8471ae94558ad77ebf694aec Mon Sep 17 00:00:00 2001 From: electrohyun Date: Wed, 8 Jul 2026 17:42:36 +0900 Subject: [PATCH 2/5] =?UTF-8?q?test:=20(#60)=20Playwright=20E2E=20?= =?UTF-8?q?=EC=84=B8=ED=8C=85=20=EB=B0=8F=20=EA=B2=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EB=A1=9C=EB=B9=84=20=EC=A7=84=EC=9E=85=20=ED=9D=90=EB=A6=84=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 6 +++++ e2e/lobby-entry.spec.ts | 22 +++++++++++++++++ package.json | 4 ++- playwright.config.ts | 31 +++++++++++++++++++++++ pnpm-lock.yaml | 55 +++++++++++++++++++++++++++++++++++------ 5 files changed, 109 insertions(+), 9 deletions(-) create mode 100644 e2e/lobby-entry.spec.ts create mode 100644 playwright.config.ts diff --git a/.gitignore b/.gitignore index d4f5416..a0481f4 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,9 @@ supabase/* # Local quiz content data src/features/study-quiz/mock/miniQuizData.ts + +# Playwright +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ diff --git a/e2e/lobby-entry.spec.ts b/e2e/lobby-entry.spec.ts new file mode 100644 index 0000000..4984ad8 --- /dev/null +++ b/e2e/lobby-entry.spec.ts @@ -0,0 +1,22 @@ +import { expect, test } from "@playwright/test"; + +// 흐름 1: (게스트) 로비 진입 +test("게스트가 홈에서 로비까지 진입한다", async ({ page }) => { + await page.goto("/"); + + // 히어로 영역의 시작 버튼("시작하기!")을 눌러 시작 모달을 연다. + await page.getByRole("button", { name: "시작하기!" }).click(); + await expect( + page.getByRole("heading", { name: "꼬깃 시작하기" }), + ).toBeVisible(); + + // 게스트로 입장하면 /auth/guest 가 쿠키를 심고 /lobby 로 리다이렉트한다. + await page.getByRole("button", { name: "게스트로 둘러보기" }).click(); + await expect(page).toHaveURL(/\/lobby$/); + + // 로비의 개인화 카드가 실제로 렌더되는지 확인한다(단순 이동이 아니라 화면이 뜨는가). + await expect(page.getByRole("heading", { name: "이어하기" })).toBeVisible(); + await expect( + page.getByRole("heading", { name: "데일리 퀘스트" }), + ).toBeVisible(); +}); diff --git a/package.json b/package.json index af2057f..343f2a6 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,8 @@ "lint": "eslint", "typecheck": "tsc --noEmit", "test": "vitest", - "test:run": "vitest run" + "test:run": "vitest run", + "test:e2e": "playwright test" }, "dependencies": { "@supabase/ssr": "^0.10.3", @@ -31,6 +32,7 @@ "zustand": "^5.0.14" }, "devDependencies": { + "@playwright/test": "^1.61.1", "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..c078077 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,31 @@ +import { defineConfig, devices } from "@playwright/test"; + +// E2E는 Vitest와 완전히 분리된 도구다. 유닛/컴포넌트(src/**)는 Vitest가, +// 핵심 사용자 흐름(e2e/**)은 Playwright가 실제 브라우저로 검증한다. +export default defineConfig({ + testDir: "./e2e", + // 흐름끼리 게스트 쿠키/localStorage가 섞이지 않도록 파일 단위 격리는 Playwright 기본값에 맡긴다. + fullyParallel: true, + forbidOnly: Boolean(process.env.CI), + retries: process.env.CI ? 1 : 0, + reporter: "list", + use: { + // 다른 프로젝트(route-151 등)가 :3000을 쓰므로 E2E는 전용 포트 :3100에 띄운다. + baseURL: "http://localhost:3100", + // 실패한 흐름만 원인 추적할 수 있게 최초 재시도에서 trace를 남긴다. + trace: "on-first-retry", + }, + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + ], + // 테스트 전 앱을 자동으로 띄운다. 이미 dev 서버가 떠 있으면 재사용한다. + webServer: { + command: "pnpm dev --port 3100", + url: "http://localhost:3100", + reuseExistingServer: !process.env.CI, + timeout: 120_000, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d7d9f28..fa3c5cf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -16,10 +16,10 @@ importers: version: 2.107.0 '@vercel/analytics': specifier: ^2.0.1 - version: 2.0.1(next@16.2.6(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4) + version: 2.0.1(next@16.2.6(@babel/core@7.29.0)(@playwright/test@1.61.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4) '@vercel/speed-insights': specifier: ^2.0.0 - version: 2.0.0(next@16.2.6(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4) + version: 2.0.0(next@16.2.6(@babel/core@7.29.0)(@playwright/test@1.61.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4) howler: specifier: ^2.2.4 version: 2.2.4 @@ -28,7 +28,7 @@ importers: version: 1.16.0(react@19.2.4) next: specifier: 16.2.6 - version: 16.2.6(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + version: 16.2.6(@babel/core@7.29.0)(@playwright/test@1.61.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) react: specifier: 19.2.4 version: 19.2.4 @@ -39,6 +39,9 @@ importers: specifier: ^5.0.14 version: 5.0.14(@types/react@19.2.14)(react@19.2.4) devDependencies: + '@playwright/test': + specifier: ^1.61.1 + version: 1.61.1 '@testing-library/dom': specifier: ^10.4.1 version: 10.4.1 @@ -533,6 +536,11 @@ packages: '@oxc-project/types@0.138.0': resolution: {integrity: sha512-1a7ZKmrRTCoN1XMZ4L0PyyqrMnrNlLyPuOkdSX2MZg7IiIGRUyurNhAm73ptDOraoBcIordsIGKNPKUzy3ZmfA==} + '@playwright/test@1.61.1': + resolution: {integrity: sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==} + engines: {node: '>=18'} + hasBin: true + '@rolldown/binding-android-arm64@1.1.4': resolution: {integrity: sha512-EZLpf/8y7GXkkra90ML47kzik/GMP3EMcE9bPyHmRfxLC6z9+aW5A8poCsoxjrT5GfEcNAAvWwUHjvP1pUQkfw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1458,6 +1466,11 @@ packages: resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} engines: {node: '>= 0.4'} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -2018,6 +2031,16 @@ packages: resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} + playwright-core@1.61.1: + resolution: {integrity: sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.61.1: + resolution: {integrity: sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==} + engines: {node: '>=18'} + hasBin: true + possible-typed-array-names@1.1.0: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} @@ -2952,6 +2975,10 @@ snapshots: '@oxc-project/types@0.138.0': {} + '@playwright/test@1.61.1': + dependencies: + playwright: 1.61.1 + '@rolldown/binding-android-arm64@1.1.4': optional: true @@ -3271,14 +3298,14 @@ snapshots: '@unrs/resolver-binding-win32-x64-msvc@1.11.1': optional: true - '@vercel/analytics@2.0.1(next@16.2.6(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)': + '@vercel/analytics@2.0.1(next@16.2.6(@babel/core@7.29.0)(@playwright/test@1.61.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)': optionalDependencies: - next: 16.2.6(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + next: 16.2.6(@babel/core@7.29.0)(@playwright/test@1.61.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) react: 19.2.4 - '@vercel/speed-insights@2.0.0(next@16.2.6(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)': + '@vercel/speed-insights@2.0.0(next@16.2.6(@babel/core@7.29.0)(@playwright/test@1.61.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)': optionalDependencies: - next: 16.2.6(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + next: 16.2.6(@babel/core@7.29.0)(@playwright/test@1.61.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) react: 19.2.4 '@vitest/expect@4.1.10': @@ -3961,6 +3988,9 @@ snapshots: dependencies: is-callable: 1.2.7 + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -4386,7 +4416,7 @@ snapshots: natural-compare@1.4.0: {} - next@16.2.6(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + next@16.2.6(@babel/core@7.29.0)(@playwright/test@1.61.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: '@next/env': 16.2.6 '@swc/helpers': 0.5.15 @@ -4405,6 +4435,7 @@ snapshots: '@next/swc-linux-x64-musl': 16.2.6 '@next/swc-win32-arm64-msvc': 16.2.6 '@next/swc-win32-x64-msvc': 16.2.6 + '@playwright/test': 1.61.1 sharp: 0.34.5 transitivePeerDependencies: - '@babel/core' @@ -4508,6 +4539,14 @@ snapshots: picomatch@4.0.4: {} + playwright-core@1.61.1: {} + + playwright@1.61.1: + dependencies: + playwright-core: 1.61.1 + optionalDependencies: + fsevents: 2.3.2 + possible-typed-array-names@1.1.0: {} postcss@8.4.31: From dd0de70c773448e10a3039cc3d68110a61c50ce1 Mon Sep 17 00:00:00 2001 From: electrohyun Date: Wed, 8 Jul 2026 17:48:59 +0900 Subject: [PATCH 3/5] =?UTF-8?q?test:=20(#60)=20=EB=AF=B8=EB=8B=88=20?= =?UTF-8?q?=ED=80=B4=EC=A6=88=20=EC=8A=A4=ED=85=8C=EC=9D=B4=EC=A7=80=20?= =?UTF-8?q?=ED=81=B4=EB=A6=AC=EC=96=B4=20=ED=9D=90=EB=A6=84=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- e2e/lobby-entry.spec.ts | 3 --- e2e/mini-quiz-clear.spec.ts | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 3 deletions(-) create mode 100644 e2e/mini-quiz-clear.spec.ts diff --git a/e2e/lobby-entry.spec.ts b/e2e/lobby-entry.spec.ts index 4984ad8..eff3082 100644 --- a/e2e/lobby-entry.spec.ts +++ b/e2e/lobby-entry.spec.ts @@ -4,17 +4,14 @@ import { expect, test } from "@playwright/test"; test("게스트가 홈에서 로비까지 진입한다", async ({ page }) => { await page.goto("/"); - // 히어로 영역의 시작 버튼("시작하기!")을 눌러 시작 모달을 연다. await page.getByRole("button", { name: "시작하기!" }).click(); await expect( page.getByRole("heading", { name: "꼬깃 시작하기" }), ).toBeVisible(); - // 게스트로 입장하면 /auth/guest 가 쿠키를 심고 /lobby 로 리다이렉트한다. await page.getByRole("button", { name: "게스트로 둘러보기" }).click(); await expect(page).toHaveURL(/\/lobby$/); - // 로비의 개인화 카드가 실제로 렌더되는지 확인한다(단순 이동이 아니라 화면이 뜨는가). await expect(page.getByRole("heading", { name: "이어하기" })).toBeVisible(); await expect( page.getByRole("heading", { name: "데일리 퀘스트" }), diff --git a/e2e/mini-quiz-clear.spec.ts b/e2e/mini-quiz-clear.spec.ts new file mode 100644 index 0000000..265958c --- /dev/null +++ b/e2e/mini-quiz-clear.spec.ts @@ -0,0 +1,36 @@ +import { expect, test } from "@playwright/test"; + +// 흐름 2: (게스트) 미니 퀴즈 스테이지 클리어 → 별 반영 +// 게스트 문제는 결정론적: 객관식은 첫 보기가 정답, 명령어는 정답이 placeholder("예: <명령어>")에 있다. +test("게스트가 미니 퀴즈 스테이지를 클리어하고 별을 받는다", async ({ page }) => { + await page.goto("/auth/guest"); + await expect(page).toHaveURL(/\/lobby$/); + await page.goto("/study"); + + await page.locator('button[data-status="available"]').first().click(); + await page.getByRole("link", { name: "시작하기" }).click(); + await expect(page).toHaveURL(/\/study\/[^/]+\/\d+$/); + + for (let questionIndex = 0; questionIndex < 5; questionIndex += 1) { + const commandInput = page.getByRole("textbox"); + + if (await commandInput.count()) { + const placeholder = await commandInput.getAttribute("placeholder"); + await commandInput.fill((placeholder ?? "").replace(/^예:\s*/, "")); + } else { + await page.locator("button[data-status]").first().click(); + } + + await page.getByRole("button", { name: "제출하기" }).click(); + await page + .getByRole("button", { + name: questionIndex === 4 ? "결과 보기" : "다음 문제 풀기", + }) + .click(); + } + + await expect( + page.getByRole("heading", { name: "스테이지를 클리어했어요!" }), + ).toBeVisible(); + await expect(page.getByLabel("별 3개")).toBeVisible(); +}); From bc362c836c03d116d633accaae026c2e67f99199 Mon Sep 17 00:00:00 2001 From: electrohyun Date: Thu, 9 Jul 2026 17:00:42 +0900 Subject: [PATCH 4/5] =?UTF-8?q?test:=20(#60)=20=EB=8D=B0=EC=9D=BC=EB=A6=AC?= =?UTF-8?q?=20=ED=80=B4=EC=A6=88=20=EC=99=84=EC=A3=BC=20=E2=86=92=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EC=9C=A0=EB=8F=84=20=ED=9D=90?= =?UTF-8?q?=EB=A6=84=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + e2e/daily-quiz.spec.ts | 38 ++++++++++++++++++++++++++++++++++++++ playwright.config.ts | 36 ++++++++++++++++++++++++------------ 3 files changed, 63 insertions(+), 12 deletions(-) create mode 100644 e2e/daily-quiz.spec.ts diff --git a/.gitignore b/.gitignore index a0481f4..01d7576 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,7 @@ out *.sw? .env +.env.e2e next-env.d.ts tsconfig.tsbuildinfo diff --git a/e2e/daily-quiz.spec.ts b/e2e/daily-quiz.spec.ts new file mode 100644 index 0000000..adf867b --- /dev/null +++ b/e2e/daily-quiz.spec.ts @@ -0,0 +1,38 @@ +import { expect, test } from "@playwright/test"; + +// 흐름 3: (게스트) 데일리 퀴즈 도전 → 완주 후 로그인 유도 +// 게스트 결과는 랭킹에 기록되지 않고 소셜 로그인을 유도한다(의도된 동작). +// 실제 문제라 정답을 모르지만, 완주 흐름만 검증하므로 아무 답이나 제출한다. +test("게스트가 데일리 퀴즈를 완주하면 로그인 유도 안내가 뜬다", async ({ page }) => { + await page.goto("/auth/guest"); + await expect(page).toHaveURL(/\/lobby$/); + + await page.goto("/challenge"); + await page.getByRole("button", { name: "도전 시작하기" }).click(); + await page.getByRole("link", { name: "시작하기" }).click(); + await expect(page).toHaveURL(/\/challenge\/play$/); + + for (let questionIndex = 0; questionIndex < 5; questionIndex += 1) { + const commandInput = page.getByRole("textbox"); + + if (await commandInput.count()) { + await commandInput.fill("git status"); + } else { + await page.locator("button[data-status]").first().click(); + } + + await page.getByRole("button", { name: "제출하기" }).click(); + await page + .getByRole("button", { + name: questionIndex === 4 ? "결과 보기" : "다음 문제 풀기", + }) + .click(); + } + + await expect( + page.getByRole("heading", { name: "오늘의 도전을 마쳤어요!" }), + ).toBeVisible(); + await expect( + page.getByText("지금 결과는 저장되지 않았어요. 로그인하면 랭킹에 도전할 수 있어요."), + ).toBeVisible(); +}); diff --git a/playwright.config.ts b/playwright.config.ts index c078077..473e799 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,31 +1,43 @@ +import { readFileSync } from "node:fs"; + import { defineConfig, devices } from "@playwright/test"; -// E2E는 Vitest와 완전히 분리된 도구다. 유닛/컴포넌트(src/**)는 Vitest가, -// 핵심 사용자 흐름(e2e/**)은 Playwright가 실제 브라우저로 검증한다. +// E2E 전용 환경변수(.env.e2e)를 읽어 dev 서버로 넘긴다. +// 테스트 전용 Supabase를 가리켜 운영 DB 오염을 막는다. +const loadE2eEnv = (): Record => { + try { + return Object.fromEntries( + readFileSync(".env.e2e", "utf8") + .split("\n") + .map((line) => line.trim()) + .filter((line) => line && !line.startsWith("#")) + .map((line) => { + const index = line.indexOf("="); + return [line.slice(0, index), line.slice(index + 1)]; + }), + ); + } catch { + return {}; + } +}; + export default defineConfig({ testDir: "./e2e", - // 흐름끼리 게스트 쿠키/localStorage가 섞이지 않도록 파일 단위 격리는 Playwright 기본값에 맡긴다. fullyParallel: true, forbidOnly: Boolean(process.env.CI), retries: process.env.CI ? 1 : 0, reporter: "list", use: { - // 다른 프로젝트(route-151 등)가 :3000을 쓰므로 E2E는 전용 포트 :3100에 띄운다. + // route-151 등이 :3000을 쓰므로 E2E는 전용 포트 :3100에 띄운다. baseURL: "http://localhost:3100", - // 실패한 흐름만 원인 추적할 수 있게 최초 재시도에서 trace를 남긴다. trace: "on-first-retry", }, - projects: [ - { - name: "chromium", - use: { ...devices["Desktop Chrome"] }, - }, - ], - // 테스트 전 앱을 자동으로 띄운다. 이미 dev 서버가 떠 있으면 재사용한다. + projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }], webServer: { command: "pnpm dev --port 3100", url: "http://localhost:3100", reuseExistingServer: !process.env.CI, timeout: 120_000, + env: loadE2eEnv(), }, }); From aedab23ff239ac547b9cb1b84bd138a7b459b3e7 Mon Sep 17 00:00:00 2001 From: electrohyun Date: Thu, 9 Jul 2026 17:03:44 +0900 Subject: [PATCH 5/5] =?UTF-8?q?test:=20(#60)=20CI=EC=97=90=EC=84=9C=20E2E?= =?UTF-8?q?=20=EC=9E=90=EB=8F=99=20=EC=8B=A4=ED=96=89=20=EC=9B=8C=ED=81=AC?= =?UTF-8?q?=ED=94=8C=EB=A1=9C=EC=9A=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/e2e.yml | 41 +++++++++++++++++++++++++++++++++++++++ playwright.config.ts | 2 +- 2 files changed, 42 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/e2e.yml diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 0000000..a90fb4f --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,41 @@ +name: E2E (Playwright) + +on: + push: + branches: [main] + pull_request: + +jobs: + e2e: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + + - run: pnpm install --frozen-lockfile + + - run: pnpm exec playwright install --with-deps chromium + + - name: Create .env.e2e from secrets + run: | + { + echo "NEXT_PUBLIC_SUPABASE_URL=${{ secrets.E2E_SUPABASE_URL }}" + echo "NEXT_PUBLIC_SUPABASE_ANON_KEY=${{ secrets.E2E_SUPABASE_ANON_KEY }}" + echo "NEXT_PUBLIC_SITE_URL=${{ secrets.E2E_SITE_URL }}" + } > .env.e2e + + - run: pnpm test:e2e + + - uses: actions/upload-artifact@v4 + if: ${{ !cancelled() }} + with: + name: playwright-report + path: playwright-report/ + retention-days: 7 diff --git a/playwright.config.ts b/playwright.config.ts index 473e799..9fe7fd5 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -26,7 +26,7 @@ export default defineConfig({ fullyParallel: true, forbidOnly: Boolean(process.env.CI), retries: process.env.CI ? 1 : 0, - reporter: "list", + reporter: process.env.CI ? [["list"], ["html", { open: "never" }]] : "list", use: { // route-151 등이 :3000을 쓰므로 E2E는 전용 포트 :3100에 띄운다. baseURL: "http://localhost:3100",