diff --git a/e2e/home.e2e.spec.ts b/e2e/home.e2e.spec.ts new file mode 100644 index 00000000..6a403a7c --- /dev/null +++ b/e2e/home.e2e.spec.ts @@ -0,0 +1,75 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Homepage E2E Tests', () => { + test.beforeEach(async ({ page }) => { + // 모든 테스트 전에 홈페이지로 이동 + await page.goto('/'); + }); + + test('should load the homepage and display the correct title', async ({ page }) => { + // 페이지 타이틀 확인 (Next.js 의 태그 기준) + // 실제 프로젝트의 타이틀로 변경해야 함 + await expect(page).toHaveTitle(/3D Blog/i); // 프로젝트 이름에 맞게 수정 + }); + + test('should display a canvas for 3D content', async ({ page }) => { + // react-three-fiber Canvas는 <canvas> 태그를 렌더링함 + // HomeCanvas.tsx 또는 유사한 컴포넌트가 <canvas>를 포함한다고 가정 + const canvasElement = page.locator('canvas'); + await expect(canvasElement).toBeVisible(); + // 추가적으로 canvas의 특정 속성 (예: data-testid, class)으로 더 정확히 식별 가능 + // 예: const mainCanvas = page.locator('canvas[data-testid="home-canvas"]'); + }); + + test('should have a navigation bar with a "Blog" link and navigate to blog page', async ({ page }) => { + // src/shared/ui/Navbar.tsx의 menuItems 배열과 로그인 상태에 따른 버튼 텍스트 참고 + // 데스크톱 네비게이션을 기준으로 테스트 (hidden md:block) + // 모바일 메뉴는 별도의 테스트 케이스나 헬퍼 필요 가능성 + const blogLink = page.locator('header nav').getByRole('link', { name: 'Blog' }); + await expect(blogLink).toBeVisible(); + + await blogLink.click(); + + // URL이 블로그 페이지로 변경되었는지 확인 + await expect(page).toHaveURL(/.*\/blog/); + // 블로그 페이지의 특정 제목이나 요소가 로드되었는지 추가로 확인 가능 + // 예: await expect(page.getByRole('heading', { name: /All Posts/i })).toBeVisible(); // 실제 블로그 페이지 제목으로 변경 + }); + + test('should have a navigation bar with a "Portfolio" link and handle coming soon', async ({ page }) => { + // "Portfolio" 링크는 isComingSoon: true 이므로 클릭 시 alert가 떠야 함. + const portfolioLink = page.locator('header nav').getByRole('link', { name: 'Portfolio' }); + await expect(portfolioLink).toBeVisible(); + + // alert를 처리하기 위한 핸들러 등록 + let alertMessage = ''; + page.on('dialog', async dialog => { + alertMessage = dialog.message(); + await dialog.dismiss(); // 또는 dialog.accept() + }); + + await portfolioLink.click(); + + // Portfolio는 isComingSoon = true 이므로 페이지 이동은 일어나지 않음 + await expect(page).toHaveURL('/'); // 현재 페이지 URL 유지 확인 + // alert 메시지 확인 + expect(alertMessage).toBe('서비스 준비 중입니다...'); + }); + + // 추가: 로그인 페이지로 이동하는 링크/버튼 테스트 (옵션) + test('should navigate to login page from "Log In" link', async ({ page }) => { + // Navbar.tsx에 따르면 로그아웃 상태일 때 "Log In" 링크가 표시됨 + const loginLink = page.locator('header').getByRole('link', { name: 'Log In' }); + // E2E 테스트는 일반적으로 로그아웃된 상태에서 시작하므로 이 링크가 보여야 함 + await expect(loginLink).toBeVisible(); + + await loginLink.click(); + await expect(page).toHaveURL(/.*\/login/); + // 로그인 폼의 특정 요소가 보이는지 확인 + await expect(page.getByRole('heading', { name: /로그인/i, level: 1 })).toBeVisible(); + } else { + // 로그인 링크가 없다면 테스트 스킵 또는 다른 방식으로 처리 + console.log('Login link not found on homepage, skipping navigation to login page test.'); + } + }); +}); diff --git a/package.json b/package.json index 79dcd139..c759462d 100644 --- a/package.json +++ b/package.json @@ -80,6 +80,7 @@ "devDependencies": { "@biomejs/biome": "1.9.4", "@eslint/eslintrc": "^3", + "@playwright/test": "^1.53.2", "@react-three/test-renderer": "^9.0.1", "@testing-library/dom": "^10.4.0", "@testing-library/jest-dom": "^6.6.3", @@ -108,6 +109,7 @@ "openapi-typescript": "^7.6.1", "postcss": "^8", "prisma": "^6.1.0", + "resize-observer-polyfill": "^1.5.1", "tailwindcss": "^3.4.1", "three-stdlib": "^2.36.0", "ts-jest": "^29.2.5", @@ -115,6 +117,7 @@ "typedoc": "^0.27.6", "typescript": "~5.3.3", "vitest": "^3.1.4", + "vitest-canvas-mock": "^0.3.3", "whatwg-fetch": "^3.6.20" }, "msw": { diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 00000000..e2bf0535 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,77 @@ +import { defineConfig, devices } from '@playwright/test'; + +/** + * Read environment variables from file. + * https://github.com/motdotla/dotenv + */ +// require('dotenv').config(); + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + testDir: './e2e', // E2E 테스트 파일이 위치할 디렉토리 (또는 'tests/e2e') + /* Run tests in files in parallel */ + fullyParallel: true, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: process.env.CI ? 2 : 0, + /* Opt out of parallel tests on CI. */ + workers: process.env.CI ? 1 : undefined, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: 'html', + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Base URL to use in actions like `await page.goto('/')`. */ + baseURL: 'http://localhost:3000', // Next.js 개발 서버 주소 + + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: 'on-first-retry', + }, + + /* Configure projects for major browsers */ + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + + { + name: 'firefox', + use: { ...devices['Desktop Firefox'] }, + }, + + { + name: 'webkit', + use: { ...devices['Desktop Safari'] }, + }, + + /* Test against mobile viewports. */ + // { + // name: 'Mobile Chrome', + // use: { ...devices['Pixel 5'] }, + // }, + // { + // name: 'Mobile Safari', + // use: { ...devices['iPhone 12'] }, + // }, + + /* Test against branded browsers. */ + // { + // name: 'Microsoft Edge', + // use: { ...devices['Desktop Edge'], channel: 'msedge' }, + // }, + // { + // name: 'Google Chrome', + // use: { ...devices['Desktop Chrome'], channel: 'chrome' }, + // }, + ], + + /* Run your local dev server before starting the tests */ + // webServer: { + // command: 'npm run dev', + // url: 'http://127.0.0.1:3000', + // reuseExistingServer: !process.env.CI, + // }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a482291e..e461efa6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -127,13 +127,13 @@ importers: version: 2.8.6(@types/node@20.17.57)(typescript@5.3.3) next: specifier: ^15.2.4 - version: 15.3.3(@babel/core@7.27.4)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + version: 15.3.3(@babel/core@7.27.4)(@playwright/test@1.53.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) next-auth: specifier: ^5.0.0-beta.25 - version: 5.0.0-beta.28(next@15.3.3(@babel/core@7.27.4)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0) + version: 5.0.0-beta.28(next@15.3.3(@babel/core@7.27.4)(@playwright/test@1.53.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0) next-swagger-doc: specifier: ^0.4.1 - version: 0.4.1(next@15.3.3(@babel/core@7.27.4)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(openapi-types@12.1.3) + version: 0.4.1(next@15.3.3(@babel/core@7.27.4)(@playwright/test@1.53.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(openapi-types@12.1.3) next-themes: specifier: ^0.4.6 version: 0.4.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0) @@ -174,6 +174,9 @@ importers: '@eslint/eslintrc': specifier: ^3 version: 3.3.1 + '@playwright/test': + specifier: ^1.53.2 + version: 1.53.2 '@react-three/test-renderer': specifier: ^9.0.1 version: 9.1.0(@react-three/fiber@9.1.2(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(three@0.173.0))(react@19.1.0)(three@0.173.0) @@ -215,10 +218,10 @@ importers: version: 8.57.1 eslint-config-airbnb: specifier: ^19.0.4 - version: 19.0.4(eslint-plugin-import@2.31.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.3.3))(eslint@8.57.1))(eslint-plugin-jsx-a11y@6.10.2(eslint@8.57.1))(eslint-plugin-react-hooks@4.6.2(eslint@8.57.1))(eslint-plugin-react@7.37.5(eslint@8.57.1))(eslint@8.57.1) + version: 19.0.4(eslint-plugin-import@2.31.0)(eslint-plugin-jsx-a11y@6.10.2(eslint@8.57.1))(eslint-plugin-react-hooks@4.6.2(eslint@8.57.1))(eslint-plugin-react@7.37.5(eslint@8.57.1))(eslint@8.57.1) eslint-config-airbnb-typescript: specifier: ^17.1.0 - version: 17.1.0(@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.3.3))(eslint@8.57.1)(typescript@5.3.3))(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.3.3))(eslint-plugin-import@2.31.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.3.3))(eslint@8.57.1))(eslint@8.57.1) + version: 17.1.0(@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.3.3))(eslint@8.57.1)(typescript@5.3.3))(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.3.3))(eslint-plugin-import@2.31.0)(eslint@8.57.1) eslint-config-next: specifier: 14.1.3 version: 14.1.3(eslint@8.57.1)(typescript@5.3.3) @@ -258,6 +261,9 @@ importers: prisma: specifier: ^6.1.0 version: 6.8.2(typescript@5.3.3) + resize-observer-polyfill: + specifier: ^1.5.1 + version: 1.5.1 tailwindcss: specifier: ^3.4.1 version: 3.4.17(ts-node@10.9.2(@types/node@20.17.57)(typescript@5.3.3)) @@ -279,6 +285,9 @@ importers: vitest: specifier: ^3.1.4 version: 3.1.4(@types/debug@4.1.12)(@types/node@20.17.57)(jiti@2.4.2)(jsdom@20.0.3)(msw@2.8.6(@types/node@20.17.57)(typescript@5.3.3))(yaml@2.8.0) + vitest-canvas-mock: + specifier: ^0.3.3 + version: 0.3.3(vitest@3.1.4(@types/debug@4.1.12)(@types/node@20.17.57)(jiti@2.4.2)(jsdom@20.0.3)(msw@2.8.6(@types/node@20.17.57)(typescript@5.3.3))(yaml@2.8.0)) whatwg-fetch: specifier: ^3.6.20 version: 3.6.20 @@ -561,6 +570,7 @@ packages: '@biomejs/cli-win32-x64@1.9.4': resolution: {integrity: sha512-8Y5wMhVIPaWe6jw2H+KlEm4wP/f7EW3810ZLmDlrEEy5KvBsb9ECEfu/kMWD484ijfQ8+nIi0giMgu9g1UAuuA==} engines: {node: '>=14.21.3'} + cpu: [x64] os: [win32] '@bundled-es-modules/cookie@2.0.1': @@ -1187,6 +1197,11 @@ packages: resolution: {integrity: sha512-ROFF39F6ZrnzSUEmQQZUar0Jt4xVoP9WnDRdWwF4NNcXs3xBTLgBUDoOwW141y1jP+S8nahIbdxbFC7IShw9Iw==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + '@playwright/test@1.53.2': + resolution: {integrity: sha512-tEB2U5z74ebBeyfGNZ3Jfg29AnW+5HlWhvHtb/Mqco9pFdZU1ZLNdVb2UtB5CvmiilNr2ZfVH/qMmAROG/XTzw==} + engines: {node: '>=18'} + hasBin: true + '@pmndrs/msdfonts@0.8.19': resolution: {integrity: sha512-O+86cpGBPeEg2cD+HPViv+hombzqgmSlH047X/w9NnYd0r5ZmIvyDNRXSb4G2yx6y5YRQYOEs0vNN7aqxo8zgw==} @@ -3286,6 +3301,9 @@ packages: engines: {node: '>=4'} hasBin: true + cssfontparser@1.2.1: + resolution: {integrity: sha512-6tun4LoZnj7VN6YeegOVb67KBX/7JJsqvj+pv3ZA7F878/eN33AbGa5b/S/wXxS/tcp8nc40xRUrsPlxIyNUPg==} + cssom@0.3.8: resolution: {integrity: sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg==} @@ -3892,6 +3910,11 @@ packages: fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + 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} @@ -4394,6 +4417,9 @@ packages: engines: {node: '>=10'} hasBin: true + jest-canvas-mock@2.5.2: + resolution: {integrity: sha512-vgnpPupjOL6+L5oJXzxTxFrlGEIbHdZqFU+LFNdtLxZ3lRDCl17FlTMM7IatoRQkrcyOTMlDinjUguqmQ6bR2A==} + jest-changed-files@29.7.0: resolution: {integrity: sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -4946,6 +4972,9 @@ packages: resolution: {integrity: sha512-WRoDn//mXBiJ1H40rqa3vH0toePwSsGb45iInWlTySa+Uu4k3tYUSxa2v1KqAiLtvlrSzaExqS1gtk96A9zvEA==} engines: {node: '>=0.10.0'} + moo-color@1.0.3: + resolution: {integrity: sha512-i/+ZKXMDf6aqYtBhuOcej71YSlbjT3wCO/4H1j8rPvxDJEifdwgg5MaFyu6iYAT8GBZJg2z0dkgK4YMzvURALQ==} + motion-dom@12.15.0: resolution: {integrity: sha512-D2ldJgor+2vdcrDtKJw48k3OddXiZN1dDLLWrS8kiHzQdYVruh0IoTwbJBslrnTXIPgFED7PBN2Zbwl7rNqnhA==} @@ -5300,6 +5329,16 @@ packages: resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==} engines: {node: '>=8'} + playwright-core@1.53.2: + resolution: {integrity: sha512-ox/OytMy+2w1jcYEYlOo1Hhp8hZkLCximMTUTMBXjGUA1KoFfiSZ+DU+3a739jsPY0yoKH2TFy9S2fsJas8yAw==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.53.2: + resolution: {integrity: sha512-6K/qQxVFuVQhRQhFsVZ9fGeatxirtrpPgxzBYWyZLEXJzqYwuL4fuNmfOfD5et1tJE4GScKyPNeLhZeRwuTU3A==} + engines: {node: '>=18'} + hasBin: true + pluralize@8.0.0: resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} engines: {node: '>=4'} @@ -5752,6 +5791,9 @@ packages: reselect@5.1.1: resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==} + resize-observer-polyfill@1.5.1: + resolution: {integrity: sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==} + resolve-cwd@3.0.0: resolution: {integrity: sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==} engines: {node: '>=8'} @@ -6559,6 +6601,11 @@ packages: yaml: optional: true + vitest-canvas-mock@0.3.3: + resolution: {integrity: sha512-3P968tYBpqYyzzOaVtqnmYjqbe13576/fkjbDEJSfQAkHtC5/UjuRHOhFEN/ZV5HVZIkaROBUWgazDKJ+Ibw+Q==} + peerDependencies: + vitest: '*' + vitest@3.1.4: resolution: {integrity: sha512-Ta56rT7uWxCSJXlBtKgIlApJnT6e6IGmTYxYcmxjJ4ujuZDI59GUQgVDObXXJujOmPDBYXHK1qmaGtneu6TNIQ==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} @@ -7711,6 +7758,10 @@ snapshots: '@pkgr/core@0.2.4': {} + '@playwright/test@1.53.2': + dependencies: + playwright: 1.53.2 + '@pmndrs/msdfonts@0.8.19': {} '@pmndrs/uikit@0.8.19(three@0.173.0)(ts-node@10.9.2(@types/node@20.17.57)(typescript@5.3.3))': @@ -10293,6 +10344,8 @@ snapshots: cssesc@3.0.0: {} + cssfontparser@1.2.1: {} + cssom@0.3.8: {} cssom@0.5.0: {} @@ -10619,7 +10672,7 @@ snapshots: optionalDependencies: source-map: 0.6.1 - eslint-config-airbnb-base@15.0.0(eslint-plugin-import@2.31.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.3.3))(eslint@8.57.1))(eslint@8.57.1): + eslint-config-airbnb-base@15.0.0(eslint-plugin-import@2.31.0)(eslint@8.57.1): dependencies: confusing-browser-globals: 1.0.11 eslint: 8.57.1 @@ -10628,18 +10681,18 @@ snapshots: object.entries: 1.1.9 semver: 6.3.1 - eslint-config-airbnb-typescript@17.1.0(@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.3.3))(eslint@8.57.1)(typescript@5.3.3))(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.3.3))(eslint-plugin-import@2.31.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.3.3))(eslint@8.57.1))(eslint@8.57.1): + eslint-config-airbnb-typescript@17.1.0(@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.3.3))(eslint@8.57.1)(typescript@5.3.3))(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.3.3))(eslint-plugin-import@2.31.0)(eslint@8.57.1): dependencies: '@typescript-eslint/eslint-plugin': 6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.3.3))(eslint@8.57.1)(typescript@5.3.3) '@typescript-eslint/parser': 6.21.0(eslint@8.57.1)(typescript@5.3.3) eslint: 8.57.1 - eslint-config-airbnb-base: 15.0.0(eslint-plugin-import@2.31.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.3.3))(eslint@8.57.1))(eslint@8.57.1) + eslint-config-airbnb-base: 15.0.0(eslint-plugin-import@2.31.0)(eslint@8.57.1) eslint-plugin-import: 2.31.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.3.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1) - eslint-config-airbnb@19.0.4(eslint-plugin-import@2.31.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.3.3))(eslint@8.57.1))(eslint-plugin-jsx-a11y@6.10.2(eslint@8.57.1))(eslint-plugin-react-hooks@4.6.2(eslint@8.57.1))(eslint-plugin-react@7.37.5(eslint@8.57.1))(eslint@8.57.1): + eslint-config-airbnb@19.0.4(eslint-plugin-import@2.31.0)(eslint-plugin-jsx-a11y@6.10.2(eslint@8.57.1))(eslint-plugin-react-hooks@4.6.2(eslint@8.57.1))(eslint-plugin-react@7.37.5(eslint@8.57.1))(eslint@8.57.1): dependencies: eslint: 8.57.1 - eslint-config-airbnb-base: 15.0.0(eslint-plugin-import@2.31.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.3.3))(eslint@8.57.1))(eslint@8.57.1) + eslint-config-airbnb-base: 15.0.0(eslint-plugin-import@2.31.0)(eslint@8.57.1) eslint-plugin-import: 2.31.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.3.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1) eslint-plugin-jsx-a11y: 6.10.2(eslint@8.57.1) eslint-plugin-react: 7.37.5(eslint@8.57.1) @@ -10693,7 +10746,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.3.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.31.0)(eslint@8.57.1))(eslint@8.57.1): + eslint-module-utils@2.12.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.3.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1): dependencies: debug: 3.2.7 optionalDependencies: @@ -10715,7 +10768,7 @@ snapshots: doctrine: 2.1.0 eslint: 8.57.1 eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.3.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.31.0)(eslint@8.57.1))(eslint@8.57.1) + eslint-module-utils: 2.12.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.3.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -11020,6 +11073,9 @@ snapshots: fs.realpath@1.0.0: {} + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -11532,6 +11588,11 @@ snapshots: filelist: 1.0.4 minimatch: 3.1.2 + jest-canvas-mock@2.5.2: + dependencies: + cssfontparser: 1.2.1 + moo-color: 1.0.3 + jest-changed-files@29.7.0: dependencies: execa: 5.1.1 @@ -12353,6 +12414,10 @@ snapshots: for-in: 1.0.2 is-extendable: 1.0.1 + moo-color@1.0.3: + dependencies: + color-name: 1.1.4 + motion-dom@12.15.0: dependencies: motion-utils: 12.12.1 @@ -12421,18 +12486,18 @@ snapshots: neotraverse@0.6.18: {} - next-auth@5.0.0-beta.28(next@15.3.3(@babel/core@7.27.4)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0): + next-auth@5.0.0-beta.28(next@15.3.3(@babel/core@7.27.4)(@playwright/test@1.53.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0): dependencies: '@auth/core': 0.39.1 - next: 15.3.3(@babel/core@7.27.4)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + next: 15.3.3(@babel/core@7.27.4)(@playwright/test@1.53.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) react: 19.1.0 - next-swagger-doc@0.4.1(next@15.3.3(@babel/core@7.27.4)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(openapi-types@12.1.3): + next-swagger-doc@0.4.1(next@15.3.3(@babel/core@7.27.4)(@playwright/test@1.53.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(openapi-types@12.1.3): dependencies: '@types/swagger-jsdoc': 6.0.4 cleye: 1.3.2 isarray: 2.0.5 - next: 15.3.3(@babel/core@7.27.4)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + next: 15.3.3(@babel/core@7.27.4)(@playwright/test@1.53.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) swagger-jsdoc: 6.2.8(openapi-types@12.1.3) transitivePeerDependencies: - openapi-types @@ -12442,7 +12507,7 @@ snapshots: react: 19.1.0 react-dom: 19.1.0(react@19.1.0) - next@15.3.3(@babel/core@7.27.4)(react-dom@19.1.0(react@19.1.0))(react@19.1.0): + next@15.3.3(@babel/core@7.27.4)(@playwright/test@1.53.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0): dependencies: '@next/env': 15.3.3 '@swc/counter': 0.1.3 @@ -12462,6 +12527,7 @@ snapshots: '@next/swc-linux-x64-musl': 15.3.3 '@next/swc-win32-arm64-msvc': 15.3.3 '@next/swc-win32-x64-msvc': 15.3.3 + '@playwright/test': 1.53.2 sharp: 0.34.2 transitivePeerDependencies: - '@babel/core' @@ -12743,6 +12809,14 @@ snapshots: dependencies: find-up: 4.1.0 + playwright-core@1.53.2: {} + + playwright@1.53.2: + dependencies: + playwright-core: 1.53.2 + optionalDependencies: + fsevents: 2.3.2 + pluralize@8.0.0: {} possible-typed-array-names@1.1.0: {} @@ -13259,6 +13333,8 @@ snapshots: reselect@5.1.1: {} + resize-observer-polyfill@1.5.1: {} + resolve-cwd@3.0.0: dependencies: resolve-from: 5.0.0 @@ -14272,6 +14348,11 @@ snapshots: jiti: 2.4.2 yaml: 2.8.0 + vitest-canvas-mock@0.3.3(vitest@3.1.4(@types/debug@4.1.12)(@types/node@20.17.57)(jiti@2.4.2)(jsdom@20.0.3)(msw@2.8.6(@types/node@20.17.57)(typescript@5.3.3))(yaml@2.8.0)): + dependencies: + jest-canvas-mock: 2.5.2 + vitest: 3.1.4(@types/debug@4.1.12)(@types/node@20.17.57)(jiti@2.4.2)(jsdom@20.0.3)(msw@2.8.6(@types/node@20.17.57)(typescript@5.3.3))(yaml@2.8.0) + vitest@3.1.4(@types/debug@4.1.12)(@types/node@20.17.57)(jiti@2.4.2)(jsdom@20.0.3)(msw@2.8.6(@types/node@20.17.57)(typescript@5.3.3))(yaml@2.8.0): dependencies: '@vitest/expect': 3.1.4 diff --git a/src/entities/post/ui/__tests__/PostCard.test.tsx b/src/entities/post/ui/__tests__/PostCard.test.tsx new file mode 100644 index 00000000..a850d7b2 --- /dev/null +++ b/src/entities/post/ui/__tests__/PostCard.test.tsx @@ -0,0 +1,90 @@ +import { render, screen } from '@testing-library/react'; +import { vi } from 'vitest'; +// 가상의 PostCard 컴포넌트 경로. 실제 경로에 맞게 수정 필요. +// import PostCard, { PostCardProps } from '../PostCard'; + +// ---- 가상 PostCard 컴포넌트 시작 ---- +// 실제 파일이 없으므로 테스트 파일 내에 임시로 정의합니다. +// 실제로는 별도 파일에서 import 해야 합니다. +import React from 'react'; +// import Link from 'next/link'; // setup.ts 에서 mock 된 Link를 사용 + +export type PostCardProps = { + id: string; + title: string; + excerpt: string; + authorName: string; + slug: string; +}; + +const PostCard: React.FC<PostCardProps> = ({ id, title, excerpt, authorName, slug }) => { + // next/link가 mock되어 <a>로 렌더링된다고 가정 + // 실제 Link 컴포넌트를 사용한다면 import Link from 'next/link'; 필요 + const LinkComponent = 'a'; // Mock된 Link가 a 태그로 동작한다고 가정 + + return ( + <article data-testid={`post-card-${id}`}> + <h2 data-testid="post-title">{title}</h2> + <p data-testid="post-excerpt">{excerpt}</p> + <p data-testid="post-author">By: {authorName}</p> + <LinkComponent href={`/blog/${slug}`} data-testid="post-link"> + Read more + </LinkComponent> + </article> + ); +}; +// ---- 가상 PostCard 컴포넌트 끝 ---- + + +// next/link 에 대한 mock이 setup.ts 에 정의되어 있다고 가정합니다. +// vi.mock('next/link', () => ({ +// default: ({ children, href, ...props }) => <a href={href} {...props}>{children}</a>, +// })); + + +describe('PostCard (Entity Component)', () => { + const mockPost: PostCardProps = { + id: '1', + title: 'Test Post Title', + excerpt: 'This is a short excerpt for the test post.', + authorName: 'John Doe', + slug: 'test-post-title', + }; + + beforeEach(() => { + // 필요시 mock 초기화 + }); + + it('renders post data correctly', () => { + render(<PostCard {...mockPost} />); + + expect(screen.getByTestId('post-title')).toHaveTextContent(mockPost.title); + expect(screen.getByTestId('post-excerpt')).toHaveTextContent(mockPost.excerpt); + expect(screen.getByTestId('post-author')).toHaveTextContent(`By: ${mockPost.authorName}`); + }); + + it('renders the "Read more" link with the correct href', () => { + render(<PostCard {...mockPost} />); + + const linkElement = screen.getByTestId('post-link'); + expect(linkElement).toBeInTheDocument(); + expect(linkElement).toHaveAttribute('href', `/blog/${mockPost.slug}`); + expect(linkElement).toHaveTextContent('Read more'); + }); + + it('applies the main data-testid correctly', () => { + render(<PostCard {...mockPost} />); + expect(screen.getByTestId(`post-card-${mockPost.id}`)).toBeInTheDocument(); + }); + + // 스냅샷 테스트 (선택 사항) + it('matches snapshot', () => { + const { container } = render(<PostCard {...mockPost} />); + // data-testid가 동적이므로 스냅샷에서 id 부분을 고정하거나, + // 스냅샷에서 article 태그 자체를 검사하는 것이 더 안정적일 수 있습니다. + // expect(container.firstChild).toMatchSnapshot(); + // 또는 + const articleElement = screen.getByTestId(`post-card-${mockPost.id}`); + expect(articleElement).toMatchSnapshot(); + }); +}); diff --git a/src/features/auth/ui/__tests__/LoginForm.test.tsx b/src/features/auth/ui/__tests__/LoginForm.test.tsx new file mode 100644 index 00000000..f392f748 --- /dev/null +++ b/src/features/auth/ui/__tests__/LoginForm.test.tsx @@ -0,0 +1,127 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { vi } from 'vitest'; +import LoginForm from '../LoginForm'; // 경로 확인 +import * as AuthApi from '../../api/auth-api'; // login 함수를 mock 하기 위해 import + +// next/navigation (useRouter)는 tests/setup.ts 에서 이미 mock 되어 있음 +const mockRouterPush = vi.fn(); +vi.mock('next/navigation', async () => { + const actual = await vi.importActual('next/navigation'); + return { + ...actual, + useRouter: () => ({ + push: mockRouterPush, + replace: vi.fn(), + refresh: vi.fn(), + back: vi.fn(), + forward: vi.fn(), + prefetch: vi.fn(), + }), + }; +}); + +// auth-api.ts의 login 함수를 mock +const mockLoginApi = vi.spyOn(AuthApi, 'login'); + +describe('LoginForm', () => { + beforeEach(() => { + vi.clearAllMocks(); // 모든 mock 초기화 + // login API mock의 기본 성공 응답 설정 + mockLoginApi.mockResolvedValue(undefined); // 성공 시 반환값이 없다고 가정 + }); + + // mounted 상태를 위한 helper 함수 (선택 사항) + const renderAndWaitForMount = async () => { + render(<LoginForm />); + // useEffect가 실행되고 mounted가 true가 될 때까지 기다림 + // "로그인" 텍스트가 있는 헤더가 나타날 때까지 기다림 + await screen.findByRole('heading', { name: /로그인/i }); + }; + + it('renders email and password fields and a submit button', async () => { + await renderAndWaitForMount(); + + expect(screen.getByLabelText(/이메일/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/비밀번호/i)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /로그인/i })).toBeInTheDocument(); + }); + + it('shows validation errors for empty fields on submit', async () => { + await renderAndWaitForMount(); + const loginButton = screen.getByRole('button', { name: /로그인/i }); + + fireEvent.click(loginButton); + + // zod 스키마에 따라 에러 메시지가 달라질 수 있음. + // loginSchema를 확인하여 실제 에러 메시지와 일치시켜야 함. + // 예시: "필수 입력 항목입니다." 또는 "유효한 이메일을 입력해주세요." + expect(await screen.findByText(/유효한 이메일을 입력해주세요./i)).toBeInTheDocument(); // Zod 기본 메시지 또는 커스텀 메시지 + expect(await screen.findByText(/비밀번호는 최소 6자 이상이어야 합니다./i)).toBeInTheDocument(); // Zod 스키마에 정의된 메시지 가정 + expect(mockLoginApi).not.toHaveBeenCalled(); + }); + + it('shows validation error for invalid email format', async () => { + await renderAndWaitForMount(); + const emailInput = screen.getByLabelText(/이메일/i); + const loginButton = screen.getByRole('button', { name: /로그인/i }); + + fireEvent.change(emailInput, { target: { value: 'invalid-email' } }); + fireEvent.click(loginButton); + + expect(await screen.findByText(/유효한 이메일을 입력해주세요./i)).toBeInTheDocument(); + expect(mockLoginApi).not.toHaveBeenCalled(); + }); + + it('calls login API and redirects on successful login', async () => { + await renderAndWaitForMount(); + const emailInput = screen.getByLabelText(/이메일/i); + const passwordInput = screen.getByLabelText(/비밀번호/i); + const loginButton = screen.getByRole('button', { name: /로그인/i }); + + const testEmail = 'test@example.com'; + const testPassword = 'password123'; + + fireEvent.change(emailInput, { target: { value: testEmail } }); + fireEvent.change(passwordInput, { target: { value: testPassword } }); + fireEvent.click(loginButton); + + await waitFor(() => { + expect(mockLoginApi).toHaveBeenCalledTimes(1); + expect(mockLoginApi).toHaveBeenCalledWith({ email: testEmail, password: testPassword }); + }); + + await waitFor(() => { + expect(mockRouterPush).toHaveBeenCalledTimes(1); + expect(mockRouterPush).toHaveBeenCalledWith('/'); + }); + + // 성공 시 에러 메시지가 없어야 함 + expect(screen.queryByText(/로그인에 실패했습니다!/i)).not.toBeInTheDocument(); + }); + + it('shows root error message on login API failure', async () => { + mockLoginApi.mockRejectedValueOnce(new Error('Login failed')); // API 실패 mock + + await renderAndWaitForMount(); + const emailInput = screen.getByLabelText(/이메일/i); + const passwordInput = screen.getByLabelText(/비밀번호/i); + const loginButton = screen.getByRole('button', { name: /로그인/i }); + + fireEvent.change(emailInput, { target: { value: 'test@example.com' } }); + fireEvent.change(passwordInput, { target: { value: 'password123' } }); + fireEvent.click(loginButton); + + expect(await screen.findByText(/로그인에 실패했습니다!/i)).toBeInTheDocument(); + expect(mockRouterPush).not.toHaveBeenCalled(); + }); + + // mounted 상태 관련 테스트 (선택 사항) + it('initially renders null and then the form due to useEffect mounting', async () => { + const { container } = render(<LoginForm />); + // 초기 렌더링 시에는 폼이 없어야 함 (mounted가 false) + expect(container.firstChild).toBeNull(); // 또는 expect(screen.queryByRole('form')).not.toBeInTheDocument(); + + // useEffect 실행 후 폼이 렌더링 되어야 함 + expect(await screen.findByRole('heading', { name: /로그인/i })).toBeInTheDocument(); + }); +}); diff --git a/src/shared/ui/__tests__/Cube.test.tsx b/src/shared/ui/__tests__/Cube.test.tsx new file mode 100644 index 00000000..d88e338c --- /dev/null +++ b/src/shared/ui/__tests__/Cube.test.tsx @@ -0,0 +1,169 @@ +import { create, act } from '@react-three/test-renderer'; +import { vi } from 'vitest'; +import { CubeModel } from '../Cube'; // 경로 확인 +import * as drei from '@react-three/drei'; // useGLTF를 mock하기 위해 + +// useGLTF mock 설정 +const mockUseGLTF = vi.fn(); +// useGLTF.preload도 mock 해야 함 +mockUseGLTF.preload = vi.fn(); + +// @react-three/drei 모듈에서 useGLTF만 mock하도록 설정 +vi.mock('@react-three/drei', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useGLTF: mockUseGLTF, + }; +}); + +// document.body.style.cursor를 mock (jsdom 환경에서는 실제 body.style이 없음) +const mockCursorSet = vi.fn(); +Object.defineProperty(document.body.style, 'cursor', { + get: () => mockCursorSet.mock.calls.length > 0 ? mockCursorSet.mock.calls[mockCursorSet.mock.calls.length - 1][0] : 'auto', + set: mockCursorSet, +}); + + +describe('CubeModel with @react-three/test-renderer', () => { + const mockOnClick = vi.fn(); + const defaultProps = { + position: [1, 2, 3] as [number, number, number], + onClick: mockOnClick, + }; + + const mockGLTFResult = { + nodes: { + Cube_Material_0: { // geometry를 포함하는 mock 객체 + geometry: { uuid: 'mockGeometry-uuid' }, // 실제 geometry 객체 대신 고유 식별자나 간단한 객체 + }, + }, + materials: { + Material: { uuid: 'mockMaterial-uuid' }, // 실제 material 객체 대신 고유 식별자나 간단한 객체 + }, + }; + + beforeEach(() => { + vi.clearAllMocks(); + mockUseGLTF.mockReturnValue(mockGLTFResult); + // mockCursorSet.mockClear(); // 위에서 setter를 mock했으므로 이걸로 초기화 + document.body.style.cursor = 'auto'; // mockCursorSet을 통해 'auto'로 설정됨 + mockCursorSet.mockClear(); // 호출 기록만 초기화 + }); + + test('renders correctly and applies initial props', async () => { + const renderer = await create(<CubeModel {...defaultProps} />); + const group = renderer.scene.children[0]; // 최상위 group + + expect(group.type).toBe('Group'); + expect(group.props.position).toEqual(defaultProps.position); + expect(group.props.scale).toEqual(0.5); + + const interactionGroup = group.children[0]; // 클릭 이벤트가 있는 내부 group + expect(interactionGroup.type).toBe('Group'); + + const mesh = interactionGroup.children[0]; // mesh + expect(mesh.type).toBe('Mesh'); + expect(mesh.props.geometry).toEqual(mockGLTFResult.nodes.Cube_Material_0.geometry); + expect(mesh.props.material).toEqual(mockGLTFResult.materials.Material); + expect(mesh.props.scale).toEqual(100); + }); + + test('calls useGLTF with the correct path and preloads it', async () => { + await create(<CubeModel {...defaultProps} />); + expect(mockUseGLTF).toHaveBeenCalledWith('/tesseract_cube.glb'); + // useGLTF.preload는 CubeModel 컴포넌트 파일의 최하단에서 호출됩니다. + // vi.mock('@react-three/drei', ...)에서 useGLTF가 mockUseGLTF로 대체되었고, + // mockUseGLTF.preload = vi.fn()으로 설정했으므로, mockUseGLTF.preload를 확인해야 합니다. + expect(mockUseGLTF.preload).toHaveBeenCalledWith('/tesseract_cube.glb'); + }); + + test('handles onClick event', async () => { + const renderer = await create(<CubeModel {...defaultProps} />); + const interactionGroup = renderer.scene.children[0].children[0]; // 내부 group + + // onClick prop이 있는지 확인 + expect(interactionGroup.props.onClick).toBeDefined(); + + // 이벤트 시뮬레이션 (stopPropagation을 mock해야 할 수도 있음) + const mockStopPropagation = vi.fn(); + await act(async () => { + interactionGroup.props.onClick({ stopPropagation: mockStopPropagation }); + }); + + expect(mockOnClick).toHaveBeenCalledTimes(1); + expect(mockStopPropagation).toHaveBeenCalledTimes(1); + }); + + test('handles pointerOver and pointerOut events for cursor change', async () => { + const renderer = await create(<CubeModel {...defaultProps} />); + const interactionGroup = renderer.scene.children[0].children[0]; + + expect(interactionGroup.props.onPointerOver).toBeDefined(); + expect(interactionGroup.props.onPointerOut).toBeDefined(); + + const mockStopPropagation = vi.fn(); + + await act(async () => { + interactionGroup.props.onPointerOver({ stopPropagation: mockStopPropagation }); + }); + expect(mockCursorSet).toHaveBeenLastCalledWith('pointer'); + expect(mockStopPropagation).toHaveBeenCalledTimes(1); + + mockStopPropagation.mockClear(); // 이전 호출 초기화 + + await act(async () => { + interactionGroup.props.onPointerOut({ stopPropagation: mockStopPropagation }); + }); + expect(mockCursorSet).toHaveBeenLastCalledWith('auto'); + expect(mockStopPropagation).toHaveBeenCalledTimes(1); + }); + + test('useFrame updates rotation (conceptual)', async () => { + const renderer = await create(<CubeModel {...defaultProps} />); + const interactionGroup = renderer.scene.children[0].children[0]; // cubeRef가 가리키는 group + + const initialRotationY = interactionGroup.instance.rotation.y; + + // advanceFrames를 사용하여 프레임 진행 및 시간 경과 시뮬레이션 + // 1프레임, 16ms (60fps 기준) 경과 + await act(async () => { + renderer.advanceFrames(1, 1/60); + }); + + // useFrame 내부 로직: cubeRef.current.rotation.y += delta * 0.5; + // delta는 1/60 (약 0.01666) + // 예상 증가량: (1/60) * 0.5 + const expectedIncrease = (1/60) * 0.5; + expect(interactionGroup.instance.rotation.y).toBeCloseTo(initialRotationY + expectedIncrease); + + // 여러 프레임 진행 + await act(async () => { + renderer.advanceFrames(10, 1/60); + }); + expect(interactionGroup.instance.rotation.y).toBeCloseTo(initialRotationY + expectedIncrease * 11); // (10+1) 프레임 + }); + + test('matches initial snapshot of the scene', async () => { + const renderer = await create(<CubeModel {...defaultProps} />); + // renderer.scene은 Three.js 객체이므로 toMatchSnapshot()에 직접 사용하기 부적합할 수 있음 + // renderer.toGraph() 와 같은 API로 JSON 직렬화 가능한 형태로 변환하거나, + // 주요 노드의 props를 스냅샷으로 만드는 것이 더 일반적입니다. + // 여기서는 주요 요소들의 props를 스냅샷으로 만듭니다. + const group = renderer.scene.children[0]; + const interactionGroup = group.children[0]; + const mesh = interactionGroup.children[0]; + + const snapshotData = { + groupProps: { position: group.props.position, scale: group.props.scale }, + interactionGroupType: interactionGroup.type, // ref는 props에 직접 나타나지 않음 + meshProps: { + geometry: mesh.props.geometry, + material: mesh.props.material, + scale: mesh.props.scale, + rotation: mesh.props.rotation, // 초기 rotation 값 + }, + }; + expect(snapshotData).toMatchSnapshot(); + }); +}); diff --git a/src/shared/ui/__tests__/ThemeToggleButton.test.tsx b/src/shared/ui/__tests__/ThemeToggleButton.test.tsx new file mode 100644 index 00000000..b6dbea44 --- /dev/null +++ b/src/shared/ui/__tests__/ThemeToggleButton.test.tsx @@ -0,0 +1,105 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { vi } from 'vitest'; +import ThemeToggleButton from '../ThemeToggleButton'; // 경로가 실제 위치에 맞는지 확인 + +// next-themes 모듈 전체를 mock +vi.mock('next-themes', () => ({ + useTheme: vi.fn(), +})); + +describe('ThemeToggleButton', () => { + const mockSetTheme = vi.fn(); + + afterEach(() => { + vi.clearAllMocks(); // 각 테스트 후 mock 호출 기록 초기화 + }); + + it('renders correctly and shows Sun icon when theme is light', () => { + // useTheme mock 설정 (light 테마) + vi.mocked(require('next-themes').useTheme).mockReturnValue({ + theme: 'light', + setTheme: mockSetTheme, + }); + + render(<ThemeToggleButton />); + + // 버튼이 존재하는지 확인 + const button = screen.getByRole('button', { name: /테마 변경/i }); + expect(button).toBeInTheDocument(); + + // Sun 아이콘이 보이는지 확인 (lucide-react 아이콘은 보통 title이나 특정 svg path로 식별) + // 여기서는 텍스트 기반으로 찾기 어려우므로, theme === 'light' 조건부 렌더링에 의존하여 + // Sun 아이콘이 렌더링되었을 것이라고 가정하고, Moon 아이콘이 없는 것을 확인하는 방식으로 접근 가능 + // 또는 아이콘 컴포넌트에 data-testid를 추가하는 방법도 있음 + // Sun 아이콘은 <Sun /> 컴포넌트로 렌더링되므로, 해당 컴포넌트의 존재를 확인하는 방식도 고려 가능. + // 여기서는 Moon 아이콘이 없는 것으로 간접 확인 + expect(screen.queryByRole('img', { name: /moon/i })).not.toBeInTheDocument(); // Moon 아이콘은 없을 것 + // Sun 아이콘이 있는지 확인 (Sun 컴포넌트가 title="Sun"을 갖는다고 가정) + // 실제 lucide-react 아이콘은 title을 기본으로 갖지 않으므로, 이 방식은 실패할 수 있음. + // 아이콘 존재 여부는 스냅샷 테스트나, 아이콘 래퍼에 data-testid를 추가하여 검증하는 것이 더 견고함. + // 지금은 setTheme 호출 여부에 더 집중. + }); + + it('renders correctly and shows Moon icon when theme is dark', () => { + // useTheme mock 설정 (dark 테마) + vi.mocked(require('next-themes').useTheme).mockReturnValue({ + theme: 'dark', + setTheme: mockSetTheme, + }); + + render(<ThemeToggleButton />); + + const button = screen.getByRole('button', { name: /테마 변경/i }); + expect(button).toBeInTheDocument(); + + // Moon 아이콘이 있는지 확인 (Sun 아이콘이 없는 것으로 간접 확인) + expect(screen.queryByRole('img', { name: /sun/i })).not.toBeInTheDocument(); + }); + + it('calls setTheme with "dark" when current theme is "light" and button is clicked', () => { + vi.mocked(require('next-themes').useTheme).mockReturnValue({ + theme: 'light', + setTheme: mockSetTheme, + }); + + render(<ThemeToggleButton />); + const button = screen.getByRole('button', { name: /테마 변경/i }); + fireEvent.click(button); + + expect(mockSetTheme).toHaveBeenCalledTimes(1); + expect(mockSetTheme).toHaveBeenCalledWith('dark'); + }); + + it('calls setTheme with "light" when current theme is "dark" and button is clicked', () => { + vi.mocked(require('next-themes').useTheme).mockReturnValue({ + theme: 'dark', + setTheme: mockSetTheme, + }); + + render(<ThemeToggleButton />); + const button = screen.getByRole('button', { name: /테마 변경/i }); + fireEvent.click(button); + + expect(mockSetTheme).toHaveBeenCalledTimes(1); + expect(mockSetTheme).toHaveBeenCalledWith('light'); + }); + + // 스냅샷 테스트 (선택 사항이지만 UI 변경 감지에 유용) + it('matches snapshot when theme is light', () => { + vi.mocked(require('next-themes').useTheme).mockReturnValue({ + theme: 'light', + setTheme: mockSetTheme, + }); + const { container } = render(<ThemeToggleButton />); + expect(container.firstChild).toMatchSnapshot(); + }); + + it('matches snapshot when theme is dark', () => { + vi.mocked(require('next-themes').useTheme).mockReturnValue({ + theme: 'dark', + setTheme: mockSetTheme, + }); + const { container } = render(<ThemeToggleButton />); + expect(container.firstChild).toMatchSnapshot(); + }); +}); diff --git a/src/views/login/ui/__tests__/AuthPageLoginIntegration.test.tsx b/src/views/login/ui/__tests__/AuthPageLoginIntegration.test.tsx new file mode 100644 index 00000000..6f6360b2 --- /dev/null +++ b/src/views/login/ui/__tests__/AuthPageLoginIntegration.test.tsx @@ -0,0 +1,110 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { vi } from 'vitest'; +import AuthPage from '../AuthPage'; // 경로 확인 +import * as AuthApi from '@/features/auth/api/auth-api'; // login 함수를 mock 하기 위해 + +// next/navigation (useRouter)는 tests/setup.ts 에서 이미 mock 되어 있음 +const mockRouterPush = vi.fn(); +vi.mock('next/navigation', async () => { + const actual = await vi.importActual('next/navigation'); + return { + ...actual, + useRouter: () => ({ + push: mockRouterPush, + // 다른 router 메소드들도 필요에 따라 mock + }), + }; +}); + +// features/auth/api/auth-api.ts의 login 함수를 mock +const mockLoginApi = vi.spyOn(AuthApi, 'login'); + +describe('AuthPage - Login Integration Test', () => { + beforeEach(() => { + vi.clearAllMocks(); + // 로그인 API mock의 기본 성공 응답 설정 + mockLoginApi.mockResolvedValue(undefined); // 성공 시 반환값이 없다고 가정 + }); + + // AuthPage 내부의 LoginForm이 마운트될 때까지 기다리는 helper + const renderAuthPageAndWaitForForm = async () => { + render(<AuthPage type="login" />); + // LoginForm 내부의 "로그인" 헤더가 나타날 때까지 기다림 + await screen.findByRole('heading', { name: /로그인/i, level: 1 }); + }; + + it('renders LoginForm when type is "login" and handles successful login flow', async () => { + await renderAuthPageAndWaitForForm(); + + // LoginForm이 렌더링 되었는지 확인 (LoginForm의 특정 요소로 확인) + expect(screen.getByLabelText(/이메일/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/비밀번호/i)).toBeInTheDocument(); + const loginButton = screen.getByRole('button', { name: /로그인/i }); + expect(loginButton).toBeInTheDocument(); + + // SpacePortal 위젯도 렌더링 되는지 간단히 확인 (SpacePortal이 특정 testid를 갖는다고 가정) + // 예: <div data-testid="space-portal-widget">...</div> + // expect(screen.getByTestId('space-portal-widget')).toBeInTheDocument(); + // SpacePortal의 실제 내용을 모르므로, 여기서는 LoginForm에 집중합니다. + + // 유효한 데이터 입력 + const testEmail = 'testuser@example.com'; + const testPassword = 'password123'; + fireEvent.change(screen.getByLabelText(/이메일/i), { target: { value: testEmail } }); + fireEvent.change(screen.getByLabelText(/비밀번호/i), { target: { value: testPassword } }); + + // 로그인 버튼 클릭 + fireEvent.click(loginButton); + + // API 호출 검증 + await waitFor(() => { + expect(mockLoginApi).toHaveBeenCalledTimes(1); + expect(mockLoginApi).toHaveBeenCalledWith({ email: testEmail, password: testPassword }); + }); + + // 라우팅 호출 검증 + await waitFor(() => { + expect(mockRouterPush).toHaveBeenCalledTimes(1); + expect(mockRouterPush).toHaveBeenCalledWith('/'); // LoginForm에서 성공 시 '/'로 이동 + }); + + // 실패 메시지가 없는지 확인 + expect(screen.queryByText(/로그인에 실패했습니다!/i)).not.toBeInTheDocument(); + }); + + it('renders LoginForm and handles login failure within AuthPage', async () => { + // API가 실패하도록 mock 설정 + mockLoginApi.mockRejectedValueOnce(new Error('Invalid credentials')); + + await renderAuthPageAndWaitForForm(); + + const emailInput = screen.getByLabelText(/이메일/i); + const passwordInput = screen.getByLabelText(/비밀번호/i); + const loginButton = screen.getByRole('button', { name: /로그인/i }); + + fireEvent.change(emailInput, { target: { value: 'wrong@example.com' } }); + fireEvent.change(passwordInput, { target: { value: 'wrongpassword' } }); + fireEvent.click(loginButton); + + // API 호출 검증 + await waitFor(() => { + expect(mockLoginApi).toHaveBeenCalledTimes(1); + }); + + // 실패 메시지 확인 (LoginForm에서 표시) + expect(await screen.findByText(/로그인에 실패했습니다!/i)).toBeInTheDocument(); + + // 라우팅이 호출되지 않았는지 확인 + expect(mockRouterPush).not.toHaveBeenCalled(); + }); + + // SignupForm 렌더링 테스트 (선택 사항) + it('renders SignupForm when type is "signup"', async () => { + render(<AuthPage type="signup" />); + // SignupForm이 렌더링 되었는지 확인 (SignupForm의 특정 요소로 확인) + // 예: SignupForm에 "회원가입" 헤더가 있다고 가정 + expect(await screen.findByRole('heading', { name: /회원가입/i })).toBeInTheDocument(); + // LoginForm 관련 요소는 없어야 함 + expect(screen.queryByLabelText(/이메일/i)).not.toBeInTheDocument(); // LoginForm의 이메일 필드가 아니어야 함 (SignupForm도 이메일 필드가 있을 수 있으므로, 더 구체적인 식별자 필요) + }); +}); diff --git a/tests/setup.ts b/tests/setup.ts new file mode 100644 index 00000000..5ad5d858 --- /dev/null +++ b/tests/setup.ts @@ -0,0 +1,118 @@ +import 'vitest-canvas-mock'; +import { vi } from 'vitest'; + +// ResizeObserver mock (vitest-canvas-mock에 포함되어 있을 수 있으나, 명시적 선언) +if (typeof window !== 'undefined') { + global.ResizeObserver = require('resize-observer-polyfill'); + + // window.matchMedia mock (일부 UI 라이브러리 또는 훅에서 필요할 수 있음) + window.matchMedia = window.matchMedia || function() { + return { + matches: false, + media: '', + onchange: null, + addListener: vi.fn(), // deprecated + removeListener: vi.fn(), // deprecated + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + }; + }; +} + +// @react-three/test-renderer 사용 시 @react-three/fiber의 많은 부분을 mock할 필요가 줄어듭니다. +// Canvas, useThree, useFrame 등은 test-renderer가 내부적으로 처리하거나 테스트용 버전을 제공합니다. +// 하지만, useGLTF 같이 파일 시스템/네트워크 접근이 있는 훅은 여전히 mock하는 것이 좋습니다. + +vi.mock('@react-three/drei', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + // useGLTF만 mock하고, 다른 Drei 컴포넌트/훅은 test-renderer가 처리하도록 둡니다. + // Cube.test.tsx에서 useGLTF를 직접 mock 하므로, 여기서는 전역 mock을 제거하거나 최소화합니다. + // 만약 다른 Drei 요소에 대한 전역 mock이 필요하다면 여기에 추가합니다. + // useGLTF: vi.fn().mockReturnValue({ nodes: {}, materials: {} }), // Cube.test.tsx에서 구체적으로 mock + }; +}); + + +// next/router 및 next/navigation mock (라우팅 관련 기능 테스트 시) +// 이 부분은 R3F 테스트와 직접적인 관련은 없으므로 그대로 둡니다. +vi.mock('next/router', () => ({ + useRouter: () => ({ + route: '/', + pathname: '', + query: {}, + asPath: '', + push: vi.fn(), + replace: vi.fn(), + reload: vi.fn(), + back: vi.fn(), + prefetch: vi.fn().mockResolvedValue(undefined), + beforePopState: vi.fn(), + events: { + on: vi.fn(), + off: vi.fn(), + emit: vi.fn(), + }, + isFallback: false, + }), +})); + +vi.mock('next/navigation', () => ({ + useRouter: () => ({ + push: vi.fn(), + replace: vi.fn(), + refresh: vi.fn(), + back: vi.fn(), + forward: vi.fn(), + prefetch: vi.fn(), + }), + usePathname: () => '/', + useSearchParams: () => new URLSearchParams(), + redirect: vi.fn(), + notFound: vi.fn(), +})); + +// next/link mock +// https://github.com/vercel/next.js/issues/48987#issuecomment-1508989426 +// next/link를 사용하는 컴포넌트 테스트 시 Link가 실제 anchor 태그처럼 동작하도록 mock. +vi.mock('next/link', () => { + const React = require('react'); + return { + __esModule: true, + default: React.forwardRef(function NextLink(props, ref) { + const { href, children, ...rest } = props; + return React.createElement('a', { ...rest, href, ref }, children); + }), + }; +}); + +// window.URL.createObjectURL mock (파일 업로드 등에서 사용될 수 있음) +if (typeof window !== 'undefined' && typeof window.URL.createObjectURL === 'undefined') { + Object.defineProperty(window.URL, 'createObjectURL', { value: vi.fn(), writable: true }); + Object.defineProperty(window.URL, 'revokeObjectURL', { value: vi.fn(), writable: true }); +} + +// IntersectionObserver mock (무한 스크롤 등에서 사용될 수 있음) +if (typeof window !== 'undefined' && typeof IntersectionObserver === 'undefined') { + global.IntersectionObserver = vi.fn(() => ({ + observe: vi.fn(), + unobserve: vi.fn(), + disconnect: vi.fn(), + takeRecords: vi.fn(() => []), + })); +} + +// 필요한 경우 다른 전역 API mock (localStorage, fetch 등) 추가 +// 예: global.fetch = vi.fn(() => Promise.resolve({ json: () => Promise.resolve({}) })); +// msw를 사용하고 있다면 fetch는 msw가 처리하므로 명시적 mock이 불필요할 수 있음. + +// console.error, console.warn을 mock하여 테스트 중 불필요한 로그를 숨기거나 특정 경고를 확인할 수 있음 +// beforeEach(() => { +// vi.spyOn(console, 'error').mockImplementation(() => {}); +// vi.spyOn(console, 'warn').mockImplementation(() => {}); +// }); +// afterEach(() => { +// vi.restoreAllMocks(); +// }); diff --git a/vitest.config.ts b/vitest.config.ts index dc041f02..9a260fdd 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,9 +1,26 @@ import { defineConfig } from 'vitest/config'; import react from '@vitejs/plugin-react'; +import path from 'path'; export default defineConfig({ plugins: [react()], test: { + globals: true, // 전역 API (describe, it 등) 사용 설정 environment: 'jsdom', + setupFiles: ['./tests/setup.ts'], // 테스트 설정 파일 추가 + // coverage: { // 필요시 커버리지 설정 추가 + // provider: 'v8', // or 'istanbul' + // reporter: ['text', 'json', 'html'], + // }, + alias: { // 경로 별칭이 있다면 Vitest에도 동일하게 설정 + '@': path.resolve(__dirname, './src'), + // 다른 경로 별칭들도 여기에 추가 + }, + }, + resolve: { // 경로 별칭을 Vite 자체에서도 인식하도록 설정 (옵션) + alias: { + '@': path.resolve(__dirname, './src'), + // 다른 경로 별칭들도 여기에 추가 + }, }, });