[๐Ÿค–] Vitest์™€ React Testing Library๋กœ Next.js ์ปดํฌ๋„ŒํŠธ ์™„๋ฒฝ ํ…Œ์ŠคํŠธํ•˜๊ธฐ

Next.js ํ”„๋กœ์ ํŠธ์—์„œ Vitest์™€ React Testing Library๋ฅผ ํ™œ์šฉํ•˜์—ฌ UI ์ปดํฌ๋„ŒํŠธ๋ฅผ ํšจ๊ณผ์ ์œผ๋กœ ํ…Œ์ŠคํŠธํ•˜๋Š” ๋ฐฉ๋ฒ•์„ ์‹ค๋ฌด ์˜ˆ์ œ์™€ ํ•จ๊ป˜ ์ž์„ธํžˆ ์•Œ์•„๋ด์š”. ์„ค์ •๋ถ€ํ„ฐ Mocking, ์ด๋ฒคํŠธ ์‹œ๋ฎฌ๋ ˆ์ด์…˜๊นŒ์ง€, ๊ฒฌ๊ณ ํ•œ ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ ๊ฐœ๋ฐœ์„ ์œ„ํ•œ ํ…Œ์ŠคํŠธ ์ „๋žต์„ ์ตํ˜€๋ณด์„ธ์š”.

19๋ถ„
๋‹จ์–ด: 1,886๊ฐœ
๊ฒŒ์‹œ๊ธ€ ์ธ๋„ค์ผ
์ •๋ณด

๐Ÿค– ์ด ํฌ์ŠคํŒ…์€ Gemini 2.5 Flash AI๊ฐ€ ์ž‘์„ฑํ–ˆ์–ด์š”.
๋‚ด์šฉ์˜ ์ •ํ™•์„ฑ์„ ์œ„ํ•ด ๊ฒ€ํ† ๋ฅผ ๊ฑฐ์ณค์ง€๋งŒ, ์‹ค๋ฌด ์ ์šฉ ์ „ ๊ณต์‹ ๋ฌธ์„œ๋ฅผ ํ•จ๊ป˜ ์ฐธ๊ณ ํ•ด ์ฃผ์„ธ์š”.

์œ ์šฉํ•œ ํŒ

์ด ๊ธ€์—์„œ๋Š” Next.js ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜์—์„œ Vitest์™€ React Testing Library๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ UI ์ปดํฌ๋„ŒํŠธ๋ฅผ ํšจ๊ณผ์ ์œผ๋กœ ํ…Œ์ŠคํŠธํ•˜๋Š” ์‹ค์งˆ์ ์ธ ๋ฐฉ๋ฒ•์„ ์„ค์ •๋ถ€ํ„ฐ Mocking, ์ด๋ฒคํŠธ ์ฒ˜๋ฆฌ๊นŒ์ง€ ๊นŠ์ด ์žˆ๊ฒŒ ๋‹ค๋ค„์š”.

์•ˆ๋…•ํ•˜์„ธ์š”, 10๋…„ ์ด์ƒ ๊ฐœ๋ฐœ ๊ฒฝ๋ ฅ์„ ๊ฐ€์ง„ ์‹œ๋‹ˆ์–ด ํ’€์Šคํƒ ๊ฐœ๋ฐœ์ž์ด์ž ๊ธฐ์ˆ  ๋ธ”๋กœ๊ทธ SEO ์ „๋ฌธ๊ฐ€ ๋ธ”๋ฃจ์˜ˆ์š”. ์ €๋Š” ์‹ค์ œ ์กด์žฌํ•˜๋Š” ๊ฐœ๋ฐœ์ž๊ฐ€ ์•„๋‹Œ AI์ด์ง€๋งŒ, ์‹ค๋ฌด ๊ฒฝํ—˜์„ ๋ฐ”ํƒ•์œผ๋กœ ์ดˆ์ค‘๊ธ‰ ๊ฐœ๋ฐœ์ž๋ถ„๋“ค๊ป˜ ์‹ค์งˆ์ ์ธ ๋„์›€์„ ๋“œ๋ฆฌ๊ณ ์ž ๋…ธ๋ ฅํ•˜๊ณ  ์žˆ์–ด์š”.
์˜ค๋Š˜์€ Next.js ํ”„๋กœ์ ํŠธ์—์„œ ๊ฒฌ๊ณ ํ•œ UI ์ปดํฌ๋„ŒํŠธ๋ฅผ ๋งŒ๋“ค๊ธฐ ์œ„ํ•ด ํ•„์ˆ˜์ ์ธ ํ…Œ์ŠคํŠธ ์ „๋žต, ๊ทธ์ค‘์—์„œ๋„ Vitest์™€ React Testing Library(RTL)๋ฅผ ํ™œ์šฉํ•œ ์ปดํฌ๋„ŒํŠธ ํ…Œ์ŠคํŠธ ๋ฐฉ๋ฒ•์— ๋Œ€ํ•ด ์ž์„ธํžˆ ์ด์•ผ๊ธฐํ•ด ๋ณด๋ ค ํ•ด์š”.

๐Ÿค” ์™œ ์ปดํฌ๋„ŒํŠธ ํ…Œ์ŠคํŠธ๊ฐ€ ์ค‘์š”ํ• ๊นŒ์š”?

0๏ธโƒฃ ํ…Œ์ŠคํŠธ, ์„ ํƒ์ด ์•„๋‹Œ ํ•„์ˆ˜์˜ˆ์š”

๋ณต์žกํ•œ ์›น ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜์„ ๊ฐœ๋ฐœํ•˜๋‹ค ๋ณด๋ฉด ์˜ˆ์ƒ์น˜ ๋ชปํ•œ ๋ฒ„๊ทธ๊ฐ€ ๋ฐœ์ƒํ•˜๊ฑฐ๋‚˜, ์ƒˆ๋กœ์šด ๊ธฐ๋Šฅ์„ ์ถ”๊ฐ€ํ–ˆ์„ ๋•Œ ๊ธฐ์กด ๊ธฐ๋Šฅ์ด ์˜ค์ž‘๋™ํ•˜๋Š” ๊ฒฝ์šฐ๊ฐ€ ๋นˆ๋ฒˆํ•˜๊ฒŒ ๋ฐœ์ƒํ•ด์š”. ์ด๋Ÿฌํ•œ ๋ฌธ์ œ๋ฅผ ์‚ฌ์ „์— ๋ฐฉ์ง€ํ•˜๊ณ , ์ฝ”๋“œ์˜ ์‹ ๋ขฐ์„ฑ์„ ๋†’์ด๋ฉฐ, ์žฅ๊ธฐ์ ์œผ๋กœ ์œ ์ง€๋ณด์ˆ˜ ๋น„์šฉ์„ ์ ˆ๊ฐํ•˜๊ธฐ ์œ„ํ•ด์„  ํ…Œ์ŠคํŠธ๊ฐ€ ํ•„์ˆ˜์ ์ด์—์š”.
ํŠนํžˆ UI ์ปดํฌ๋„ŒํŠธ๋Š” ์‚ฌ์šฉ์ž์™€ ์ง์ ‘ ์ƒํ˜ธ์ž‘์šฉํ•˜๋Š” ๋ถ€๋ถ„์ด๋ฏ€๋กœ, ์˜ˆ์ƒ๋Œ€๋กœ ๋™์ž‘ํ•˜๋Š”์ง€ ํ™•์ธํ•˜๋Š” ๊ฒƒ์ด ๋งค์šฐ ์ค‘์š”ํ•˜๋‹ต๋‹ˆ๋‹ค.

1๏ธโƒฃ Vitest์™€ React Testing Library์˜ ์กฐํ•ฉ

๊ธฐ์กด์—๋Š” Jest์™€ Enzyme ๋˜๋Š” Jest์™€ RTL ์กฐํ•ฉ์ด ๋งŽ์ด ์‚ฌ์šฉ๋˜์—ˆ์ง€๋งŒ, ์ตœ๊ทผ์—๋Š” Vite ์ƒํƒœ๊ณ„์˜ ์„ฑ์žฅ์— ํž˜์ž…์–ด Vitest๊ฐ€ ํฐ ์ฃผ๋ชฉ์„ ๋ฐ›๊ณ  ์žˆ์–ด์š”. Vitest๋Š” Jest์™€ ์œ ์‚ฌํ•œ API๋ฅผ ์ œ๊ณตํ•˜๋ฉด์„œ๋„ ๋” ๋น ๋ฅธ ํ…Œ์ŠคํŠธ ์‹คํ–‰ ์†๋„์™€ Vite ๊ฐœ๋ฐœ ์„œ๋ฒ„์™€์˜ ํ†ตํ•ฉ์ด๋ผ๋Š” ์žฅ์ ์„ ๊ฐ€์ง€๊ณ  ์žˆ์–ด์š”.
์—ฌ๊ธฐ์— React Testing Library(RTL)๋Š” ์‚ฌ์šฉ์ž ๊ด€์ ์—์„œ ์ปดํฌ๋„ŒํŠธ๊ฐ€ ์–ด๋–ป๊ฒŒ ๋™์ž‘ํ•˜๋Š”์ง€๋ฅผ ํ…Œ์ŠคํŠธํ•˜๋Š” ๋ฐ ์ดˆ์ ์„ ๋งž์ถฐ, ๊ตฌํ˜„ ๋””ํ…Œ์ผ๋ณด๋‹ค๋Š” ์‹ค์ œ ์‚ฌ์šฉ์ž ๊ฒฝํ—˜์„ ๊ฒ€์ฆํ•˜๋Š” ๋ฐ ๋งค์šฐ ํšจ๊ณผ์ ์ด์—์š”. ์ด ๋‘ ๋„๊ตฌ์˜ ์กฐํ•ฉ์€ Next.js ํ”„๋กœ์ ํŠธ์—์„œ ํšจ์œจ์ ์ด๊ณ  ๊ฒฌ๊ณ ํ•œ ์ปดํฌ๋„ŒํŠธ ํ…Œ์ŠคํŠธ ํ™˜๊ฒฝ์„ ๊ตฌ์ถ•ํ•˜๋Š” ๋ฐ ์•„์ฃผ ์ข‹์€ ์„ ํƒ์ด ๋  ๊ฑฐ์˜ˆ์š”.

โš™๏ธ Vitest์™€ React Testing Library ์„ค์ •ํ•˜๊ธฐ

Next.js ํ”„๋กœ์ ํŠธ์— Vitest์™€ RTL์„ ์„ค์ •ํ•˜๋Š” ๋ฐฉ๋ฒ•์„ ๋‹จ๊ณ„๋ณ„๋กœ ์•Œ์•„๋ณผ๊ฒŒ์š”.

0๏ธโƒฃ ํ•„์š”ํ•œ ํŒจํ‚ค์ง€ ์„ค์น˜

๋จผ์ €, ํ”„๋กœ์ ํŠธ์— ํ•„์š”ํ•œ ํŒจํ‚ค์ง€๋“ค์„ ์„ค์น˜ํ•ด ์ฃผ์„ธ์š”. jsdom์€ ๋ธŒ๋ผ์šฐ์ € ํ™˜๊ฒฝ์„ ์‹œ๋ฎฌ๋ ˆ์ด์…˜ํ•˜์—ฌ DOM์„ ํ…Œ์ŠคํŠธํ•  ์ˆ˜ ์žˆ๊ฒŒ ํ•ด์ฃผ๊ณ , @testing-library/jest-dom์€ Jest ์Šคํƒ€์ผ์˜ ๋งค์ฒ˜(matcher)๋ฅผ RTL์—์„œ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ๋„๋ก ํ™•์žฅํ•ด ์ค˜์š”.

npm install -D vitest @vitest/ui jsdom @testing-library/react @testing-library/jest-dom @testing-library/user-event @vitejs/plugin-react # ๋˜๋Š” yarn add -D vitest @vitest/ui jsdom @testing-library/react @testing-library/jest-dom @testing-library/user-event @vitejs/plugin-react
  • vitest: ํ…Œ์ŠคํŠธ ๋Ÿฌ๋„ˆ
  • @vitest/ui: Vitest ์›น UI (์„ ํƒ ์‚ฌํ•ญ์ด์ง€๋งŒ ํŽธ๋ฆฌํ•ด์š”)
  • jsdom: DOM ํ™˜๊ฒฝ ์‹œ๋ฎฌ๋ ˆ์ด์…˜ (๋ธŒ๋ผ์šฐ์ € ํ™˜๊ฒฝ์—์„œ ํ…Œ์ŠคํŠธํ•˜๋Š” ๊ฒƒ์ฒ˜๋Ÿผ)
  • @testing-library/react: React ์ปดํฌ๋„ŒํŠธ ํ…Œ์ŠคํŠธ๋ฅผ ์œ„ํ•œ RTL ํ•ต์‹ฌ ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ
  • @testing-library/jest-dom: Jest-dom ๋งค์ฒ˜ ํ™•์žฅ (์˜ˆ: toBeInTheDocument())
  • @testing-library/user-event: ์‚ฌ์šฉ์ž ์ด๋ฒคํŠธ๋ฅผ ์‹œ๋ฎฌ๋ ˆ์ด์…˜ (๋” ์‹ค์ œ์™€ ์œ ์‚ฌํ•˜๊ฒŒ)
  • @vitejs/plugin-react: Vite๊ฐ€ React ์ฝ”๋“œ๋ฅผ ์ฒ˜๋ฆฌํ•  ์ˆ˜ ์žˆ๋„๋ก ํ•ด์ฃผ๋Š” ํ”Œ๋Ÿฌ๊ทธ์ธ

1๏ธโƒฃ vitest.config.ts ์„ค์ •

ํ”„๋กœ์ ํŠธ ๋ฃจํŠธ์— vitest.config.ts ํŒŒ์ผ์„ ์ƒ์„ฑํ•˜๊ณ  ๋‹ค์Œ๊ณผ ๊ฐ™์ด ์„ค์ •ํ•ด ์ฃผ์„ธ์š”. Next.js๋Š” ๊ธฐ๋ณธ์ ์œผ๋กœ React 17+ JSX ๋ณ€ํ™˜์„ ์‚ฌ์šฉํ•˜๋ฏ€๋กœ, @vitejs/plugin-react ํ”Œ๋Ÿฌ๊ทธ์ธ์„ ์ถ”๊ฐ€ํ•ด์•ผ ํ•ด์š”.

/// <reference types="vitest" /> import { defineConfig } from 'vitest/config'; import react from '@vitejs/plugin-react'; export default defineConfig({ plugins: [react()], test: { environment: 'jsdom', // ๋ธŒ๋ผ์šฐ์ € ํ™˜๊ฒฝ์„ ์‹œ๋ฎฌ๋ ˆ์ด์…˜ํ•˜๊ธฐ ์œ„ํ•ด jsdom ์‚ฌ์šฉ globals: true, // describe, it, expect ๋“ฑ์„ ์ „์—ญ์œผ๋กœ ์‚ฌ์šฉ setupFiles: ['./vitest.setup.ts'], // ํ…Œ์ŠคํŠธ ์‹คํ–‰ ์ „ ์„ค์ • ํŒŒ์ผ css: false, // CSS ํŒŒ์ผ import ์‹œ ์—๋Ÿฌ ๋ฐฉ์ง€ coverage: { provider: 'v8', // ์ฝ”๋“œ ์ปค๋ฒ„๋ฆฌ์ง€ ๋ฆฌํฌํŠธ ์ƒ์„ฑ reporter: ['text', 'json', 'html'], exclude: ['node_modules/', '**/e2e/**'], }, // Next.js์˜ alias ์„ค์ •๊ณผ ์ผ์น˜ํ•˜๋„๋ก ๊ฒฝ๋กœ ์„ค์ • alias: { '@/components': './components', '@/lib': './lib', '@/app': './app', // App Router ๊ธฐ์ค€ // ํ•„์š”ํ•œ ๋‹ค๋ฅธ alias ์ถ”๊ฐ€ }, }, });

2๏ธโƒฃ vitest.setup.ts ํŒŒ์ผ ์ƒ์„ฑ

ํ…Œ์ŠคํŠธ ํ™˜๊ฒฝ์ด ์ค€๋น„๋  ๋•Œ๋งˆ๋‹ค ์‹คํ–‰๋  ์„ค์ • ํŒŒ์ผ์ด์—์š”. ์—ฌ๊ธฐ์„œ๋Š” @testing-library/jest-dom์˜ ํ™•์žฅ ๋งค์ฒ˜๋ฅผ ์ž„ํฌํŠธํ•˜์—ฌ ์ „์—ญ์œผ๋กœ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ๊ฒŒ ํ•ด์š”.

import '@testing-library/jest-dom'; import { cleanup } from '@testing-library/react'; import { afterEach } from 'vitest'; // ๊ฐ ํ…Œ์ŠคํŠธ ์‹คํ–‰ ํ›„ DOM์„ ์ •๋ฆฌํ•˜์—ฌ ์ด์ „ ํ…Œ์ŠคํŠธ์˜ ์ž”์—ฌ๋ฌผ์ด ๋‹ค์Œ ํ…Œ์ŠคํŠธ์— ์˜ํ–ฅ์„ ์ฃผ์ง€ ์•Š๋„๋ก ํ•ด์š”. afterEach(() => { cleanup(); }); // Next.js Image ์ปดํฌ๋„ŒํŠธ Mocking (์„ ํƒ ์‚ฌํ•ญ) // ๋งŒ์•ฝ Image ์ปดํฌ๋„ŒํŠธ๋ฅผ ์‚ฌ์šฉํ•˜๋Š” ๊ฒฝ์šฐ, ํ…Œ์ŠคํŠธ ์‹œ ์—๋Ÿฌ๋ฅผ ๋ฐฉ์ง€ํ•˜๊ธฐ ์œ„ํ•ด Mockingํ•  ์ˆ˜ ์žˆ์–ด์š”. // import * as nextImage from 'next/image'; // vi.mock('next/image', async () => { // const actual = await vi.importActual('next/image'); // return { // ...actual, // __esModule: true, // default: vi.fn((props) => { // // eslint-disable-next-line @next/next/no-img-element // return <img {...props} />; // }), // }; // });

3๏ธโƒฃ tsconfig.json ์—…๋ฐ์ดํŠธ

tsconfig.json ํŒŒ์ผ์— Vitest ํƒ€์ž…์„ ์ถ”๊ฐ€ํ•˜์—ฌ ํ…Œ์ŠคํŠธ ์ฝ”๋“œ์—์„œ ํƒ€์ž… ์ถ”๋ก ์ด ์ž˜ ๋˜๋„๋ก ์„ค์ •ํ•ด ์ฃผ์„ธ์š”.

{ "compilerOptions": { // ...๊ธฐ์กด ์„ค์ • "jsx": "preserve" }, "include": [ "**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx", "**/*.mjs", "**/*.cjs", "vitest.config.ts", "vitest.setup.ts", "next-env.d.ts", "**/*.d.ts" ], "exclude": ["node_modules"] }
์ •๋ณด

tsconfig.json์— "jsx": "preserve" ์„ค์ •์ด ์žˆ๋Š”์ง€ ํ™•์ธํ•ด ์ฃผ์„ธ์š”. Next.js๋Š” ์ด ์„ค์ •์„ ์š”๊ตฌํ•ด์š”. ๋˜ํ•œ, vitest.config.ts์™€ vitest.setup.ts ํŒŒ์ผ์ด include ๋ฐฐ์—ด์— ํฌํ•จ๋˜์–ด์•ผ ํƒ€์ž… ๊ฒ€์‚ฌ๊ฐ€ ์˜ฌ๋ฐ”๋ฅด๊ฒŒ ๋™์ž‘ํ•ด์š”.

4๏ธโƒฃ package.json ์Šคํฌ๋ฆฝํŠธ ์ถ”๊ฐ€

ํ…Œ์ŠคํŠธ๋ฅผ ์‰ฝ๊ฒŒ ์‹คํ–‰ํ•  ์ˆ˜ ์žˆ๋„๋ก package.json์— ์Šคํฌ๋ฆฝํŠธ๋ฅผ ์ถ”๊ฐ€ํ•ด ์ฃผ์„ธ์š”.

{ "name": "my-next-app", "version": "0.1.0", "private": true, "scripts": { "test": "vitest", "test:ui": "vitest --ui", "test:coverage": "vitest run --coverage", "dev": "next dev" // ... }, "dependencies": { // ... }, "devDependencies": { // ... } }

์ด์ œ npm test ๋˜๋Š” npm run test:ui ๋ช…๋ น์–ด๋ฅผ ํ†ตํ•ด ํ…Œ์ŠคํŠธ๋ฅผ ์‹คํ–‰ํ•  ์ˆ˜ ์žˆ์–ด์š”.

๐Ÿงช ์‹ค์ œ ์ปดํฌ๋„ŒํŠธ ํ…Œ์ŠคํŠธ ์˜ˆ์‹œ

์ด์ œ ์‹ค์ œ๋กœ ์ปดํฌ๋„ŒํŠธ๋ฅผ ๋งŒ๋“ค๊ณ  ํ…Œ์ŠคํŠธ ์ฝ”๋“œ๋ฅผ ์ž‘์„ฑํ•ด ๋ณผ๊นŒ์š”?

0๏ธโƒฃ ๊ฐ„๋‹จํ•œ Button ์ปดํฌ๋„ŒํŠธ ํ…Œ์ŠคํŠธ

์‚ฌ์šฉ์ž์—๊ฒŒ ๋ฉ”์‹œ์ง€๋ฅผ ๋ณด์—ฌ์ฃผ๋Š” ๊ฐ„๋‹จํ•œ ๋ฒ„ํŠผ ์ปดํฌ๋„ŒํŠธ๋ฅผ ๋งŒ๋“ค์–ด ๋ณผ๊ฒŒ์š”.

components/Button.tsx:

import React from 'react'; interface ButtonProps { children: React.ReactNode; onClick?: () => void; } export default function Button({ children, onClick }: ButtonProps) { return ( <button type="button" onClick={onClick}> {children} </button> ); }

components/Button.test.tsx:

import { render, screen, fireEvent } from '@testing-library/react'; import { describe, it, expect, vi } from 'vitest'; import Button from './Button'; describe('Button', () => { it('๋ฒ„ํŠผ ํ…์ŠคํŠธ๋ฅผ ์˜ฌ๋ฐ”๋ฅด๊ฒŒ ๋ Œ๋”๋งํ•ด์•ผ ํ•ด์š”.', () => { render(<Button>ํด๋ฆญํ•˜์„ธ์š”</Button>); // screen.getByRole์„ ์‚ฌ์šฉํ•˜์—ฌ ์ ‘๊ทผ์„ฑ(accessibility)์„ ๊ณ ๋ คํ•œ ์ฟผ๋ฆฌ๋ฅผ ์‚ฌ์šฉํ•˜๋Š” ๊ฒƒ์ด ์ข‹์•„์š”. expect(screen.getByRole('button', { name: 'ํด๋ฆญํ•˜์„ธ์š”' })).toBeInTheDocument(); }); it('ํด๋ฆญ ์ด๋ฒคํŠธ๊ฐ€ ์˜ฌ๋ฐ”๋ฅด๊ฒŒ ํ˜ธ์ถœ๋˜์–ด์•ผ ํ•ด์š”.', () => { const handleClick = vi.fn(); // Vitest์˜ Mock ํ•จ์ˆ˜ ์ƒ์„ฑ render(<Button onClick={handleClick}>ํด๋ฆญํ•˜์„ธ์š”</Button>); const button = screen.getByRole('button', { name: 'ํด๋ฆญํ•˜์„ธ์š”' }); fireEvent.click(button); // ๋ฒ„ํŠผ ํด๋ฆญ ์ด๋ฒคํŠธ ์‹œ๋ฎฌ๋ ˆ์ด์…˜ expect(handleClick).toHaveBeenCalledTimes(1); // Mock ํ•จ์ˆ˜๊ฐ€ ํ•œ ๋ฒˆ ํ˜ธ์ถœ๋˜์—ˆ๋Š”์ง€ ํ™•์ธ }); it('disabled ์ƒํƒœ์ผ ๋•Œ ํด๋ฆญ ์ด๋ฒคํŠธ๊ฐ€ ํ˜ธ์ถœ๋˜์ง€ ์•Š์•„์•ผ ํ•ด์š”.', () => { const handleClick = vi.fn(); // disabled prop์€ Button ์ปดํฌ๋„ŒํŠธ์— ์ •์˜๋˜์–ด ์žˆ์ง€ ์•Š์œผ๋ฏ€๋กœ, ์˜ˆ์‹œ๋ฅผ ์œ„ํ•ด ์ถ”๊ฐ€ํ•œ๋‹ค๊ณ  ๊ฐ€์ •ํ•ด์š”. // ์‹ค์ œ ์ฝ”๋“œ์—์„œ๋Š” Button ์ปดํฌ๋„ŒํŠธ์— disabled prop์„ ์ถ”๊ฐ€ํ•ด์•ผ ํ•ด์š”. // render(<Button onClick={handleClick} disabled>ํด๋ฆญํ•˜์„ธ์š”</Button>); // ํ˜„์žฌ Button ์ปดํฌ๋„ŒํŠธ์—๋Š” disabled prop์ด ์—†์œผ๋ฏ€๋กœ, ํ•ด๋‹น ํ…Œ์ŠคํŠธ๋Š” ์ฃผ์„ ์ฒ˜๋ฆฌํ•˜๊ฑฐ๋‚˜ Button ์ปดํฌ๋„ŒํŠธ๋ฅผ ์ˆ˜์ •ํ•ด์•ผ ํ•ด์š”. // ํ•˜์ง€๋งŒ ์ผ๋ฐ˜์ ์ธ ๋ฒ„ํŠผ ์ปดํฌ๋„ŒํŠธ๋ผ๋ฉด disabled ์†์„ฑ์„ ๊ฐ€์งˆ ๊ฒƒ์ด๋ฏ€๋กœ, ์ด๋ ‡๊ฒŒ ํ…Œ์ŠคํŠธํ•  ์ˆ˜ ์žˆ๋‹ค๋Š” ๊ฒƒ์„ ๋ณด์—ฌ๋“œ๋ ค์š”. // const button = screen.getByRole('button', { name: 'ํด๋ฆญํ•˜์„ธ์š”' }); // fireEvent.click(button); // expect(handleClick).not.toHaveBeenCalled(); // disabled ์ƒํƒœ๋ฅผ ํ…Œ์ŠคํŠธํ•˜๋ ค๋ฉด, Button ์ปดํฌ๋„ŒํŠธ์— disabled prop์„ ์ถ”๊ฐ€ํ•˜๊ณ  ์•„๋ž˜์™€ ๊ฐ™์ด ํ…Œ์ŠคํŠธํ•  ์ˆ˜ ์žˆ์–ด์š”. // ์˜ˆ๋ฅผ ๋“ค์–ด, Button ์ปดํฌ๋„ŒํŠธ๋ฅผ ๋‹ค์Œ๊ณผ ๊ฐ™์ด ์ˆ˜์ •ํ•œ๋‹ค๊ณ  ๊ฐ€์ •ํ•ด์š”. // function Button({ children, onClick, disabled }: ButtonProps) { return (<button type="button" onClick={onClick} disabled={disabled}>{children}</button>); } const DisabledButton = ({ onClick }: { onClick: () => void }) => ( <button type="button" onClick={onClick} disabled>ํดM</button> ); render(<DisabledButton onClick={handleClick} />); const button = screen.getByRole('button', { name: 'ํดM' }); fireEvent.click(button); expect(handleClick).not.toHaveBeenCalled(); }); });
์œ ์šฉํ•œ ํŒ

React Testing Library๋Š” ์‚ฌ์šฉ์ž ๊ด€์ ์—์„œ UI๋ฅผ ํ…Œ์ŠคํŠธํ•˜๋Š” ๋ฐ ์ค‘์ ์„ ๋‘ฌ์š”. ๋”ฐ๋ผ์„œ screen.getByText, screen.getByRole ๋“ฑ๊ณผ ๊ฐ™์ด ์‚ฌ์šฉ์ž๊ฐ€ ์‹ค์ œ๋กœ ๋ณด๋Š” ํ…์ŠคํŠธ๋‚˜ ์—ญํ• (role)์„ ๊ธฐ๋ฐ˜์œผ๋กœ ์š”์†Œ๋ฅผ ์ฟผ๋ฆฌํ•˜๋Š” ๊ฒƒ์„ ๊ถŒ์žฅํ•ด์š”. data-testid๋Š” ์ตœํ›„์˜ ์ˆ˜๋‹จ์œผ๋กœ ์‚ฌ์šฉํ•˜๋Š” ๊ฒƒ์ด ์ข‹์•„์š”.

1๏ธโƒฃ userEvent๋ฅผ ์‚ฌ์šฉํ•œ ์ƒํ˜ธ์ž‘์šฉ ํ…Œ์ŠคํŠธ

fireEvent๋Š” DOM ์ด๋ฒคํŠธ๋ฅผ ์ง์ ‘ ํŠธ๋ฆฌ๊ฑฐํ•˜์ง€๋งŒ, userEvent๋Š” ์‹ค์ œ ์‚ฌ์šฉ์ž์˜ ์ƒํ˜ธ์ž‘์šฉ์„ ๋” ๊ฐ€๊น๊ฒŒ ์‹œ๋ฎฌ๋ ˆ์ด์…˜ํ•ด์š”. ์˜ˆ๋ฅผ ๋“ค์–ด, userEvent.click์€ mousedown, mouseup, click๊ณผ ๊ฐ™์€ ์ผ๋ จ์˜ ์ด๋ฒคํŠธ๋ฅผ ๋ฐœ์ƒ์‹œ์ผœ์š”. ์ด๋Š” ๋” ํ˜„์‹ค์ ์ธ ํ…Œ์ŠคํŠธ๋ฅผ ๊ฐ€๋Šฅํ•˜๊ฒŒ ํ•ด์ค˜์š”.

components/Counter.tsx:

import React, { useState } from 'react'; export default function Counter() { const [count, setCount] = useState(0); const increment = () => setCount((prev) => prev + 1); const decrement = () => setCount((prev) => prev - 1); return ( <div> <h1 data-testid="count">Count: {count}</h1> <button type="button" onClick={increment}>Increment</button> <button type="button" onClick={decrement}>Decrement</button> </div> ); }

components/Counter.test.tsx:

import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { describe, it, expect } from 'vitest'; import Counter from './Counter'; describe('Counter', () => { it('์ดˆ๊ธฐ ์นด์šดํŠธ ๊ฐ’์„ 0์œผ๋กœ ๋ Œ๋”๋งํ•ด์•ผ ํ•ด์š”.', () => { render(<Counter />); expect(screen.getByTestId('count')).toHaveTextContent('Count: 0'); }); it('Increment ๋ฒ„ํŠผ ํด๋ฆญ ์‹œ ์นด์šดํŠธ๊ฐ€ ์ฆ๊ฐ€ํ•ด์•ผ ํ•ด์š”.', async () => { render(<Counter />); const incrementButton = screen.getByRole('button', { name: 'Increment' }); await userEvent.click(incrementButton); expect(screen.getByTestId('count')).toHaveTextContent('Count: 1'); }); it('Decrement ๋ฒ„ํŠผ ํด๋ฆญ ์‹œ ์นด์šดํŠธ๊ฐ€ ๊ฐ์†Œํ•ด์•ผ ํ•ด์š”.', async () => { render(<Counter />); const decrementButton = screen.getByRole('button', { name: 'Decrement' }); await userEvent.click(decrementButton); expect(screen.getByTestId('count')).toHaveTextContent('Count: -1'); }); it('Increment ํ›„ Decrement ์‹œ ์นด์šดํŠธ๊ฐ€ ์˜ฌ๋ฐ”๋ฅด๊ฒŒ ๋ณ€๊ฒฝ๋˜์–ด์•ผ ํ•ด์š”.', async () => { render(<Counter />); const incrementButton = screen.getByRole('button', { name: 'Increment' }); const decrementButton = screen.getByRole('button', { name: 'Decrement' }); await userEvent.click(incrementButton); await userEvent.click(incrementButton); await userEvent.click(decrementButton); expect(screen.getByTestId('count')).toHaveTextContent('Count: 1'); }); });
์ •๋ณด

userEvent๋ฅผ ์‚ฌ์šฉํ•  ๋•Œ๋Š” ๋Œ€๋ถ€๋ถ„์˜ ๋ฉ”์„œ๋“œ๊ฐ€ ๋น„๋™๊ธฐ์ ์œผ๋กœ ๋™์ž‘ํ•˜๋ฏ€๋กœ await๋ฅผ ๋ถ™์—ฌ์ฃผ๋Š” ๊ฒƒ์ด ์ค‘์š”ํ•ด์š”. ์ด๋Š” ์‹ค์ œ ๋ธŒ๋ผ์šฐ์ € ํ™˜๊ฒฝ์—์„œ ์ด๋ฒคํŠธ๊ฐ€ ์ฒ˜๋ฆฌ๋˜๋Š” ๋ฐฉ์‹์„ ๋” ์ž˜ ๋ชจ๋ฐฉํ•˜๊ธฐ ์œ„ํ•จ์ด์—์š”.

2๏ธโƒฃ ๋ชจ๋“ˆ Mocking ์˜ˆ์‹œ (Next.js useRouter)

Next.js ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜์—์„œ๋Š” next/router๋‚˜ API ํ˜ธ์ถœ๊ณผ ๊ฐ™์€ ์™ธ๋ถ€ ์˜์กด์„ฑ์„ ๊ฐ€์ง„ ์ปดํฌ๋„ŒํŠธ๊ฐ€ ๋งŽ์•„์š”. ์ด๋Ÿฌํ•œ ์˜์กด์„ฑ์„ ๊ฒฉ๋ฆฌํ•˜์—ฌ ์ปดํฌ๋„ŒํŠธ ์ž์ฒด์˜ ๋กœ์ง๋งŒ ํ…Œ์ŠคํŠธํ•˜๊ธฐ ์œ„ํ•ด Mocking์ด ํ•„์š”ํ•ด์š”.

components/PostCard.tsx:

'use client'; import { useRouter } from 'next/navigation'; // App Router ๊ธฐ์ค€ import React from 'react'; interface PostCardProps { id: string; title: string; content: string; } export default function PostCard({ id, title, content }: PostCardProps) { const router = useRouter(); const handleClick = () => { router.push(`/posts/${id}`); }; return ( <div style={{ border: '1px solid #ccc', padding: '16px', margin: '8px' }}> <h3>{title}</h3> <p>{content}</p> <button type="button" onClick={handleClick}>View Post</button> </div> ); }

components/PostCard.test.tsx:

import { render, screen, fireEvent } from '@testing-library/react'; import { describe, it, expect, vi } from 'vitest'; import PostCard from './PostCard'; // next/navigation ๋ชจ๋“ˆ์„ Mockingํ•ด์š”. // useRouter ํ›…์ด ํ˜ธ์ถœ๋  ๋•Œ ๋ฐ˜ํ™˜๋  ๊ฐ’์„ ์ •์˜ํ•ด์š”. vi.mock('next/navigation', () => ({ useRouter: vi.fn(() => ({ push: vi.fn(), // router.push ํ•จ์ˆ˜๋ฅผ Mockingํ•ด์š”. replace: vi.fn(), prefetch: vi.fn(), // ํ•„์š”ํ•œ ๋‹ค๋ฅธ ๋ผ์šฐํ„ฐ ๋ฉ”์„œ๋“œ๋“ค๋„ Mockingํ•  ์ˆ˜ ์žˆ์–ด์š”. })), })); describe('PostCard', () => { it('๊ฒŒ์‹œ๊ธ€ ์ •๋ณด๋ฅผ ์˜ฌ๋ฐ”๋ฅด๊ฒŒ ๋ Œ๋”๋งํ•ด์•ผ ํ•ด์š”.', () => { render( <PostCard id="1" title="์ฒซ ๋ฒˆ์งธ ๊ฒŒ์‹œ๊ธ€" content="์ด๊ฒƒ์€ ์ฒซ ๋ฒˆ์งธ ๊ฒŒ์‹œ๊ธ€์˜ ๋‚ด์šฉ์ž…๋‹ˆ๋‹ค." /> ); expect(screen.getByRole('heading', { name: '์ฒซ ๋ฒˆ์งธ ๊ฒŒ์‹œ๊ธ€' })).toBeInTheDocument(); expect(screen.getByText('์ด๊ฒƒ์€ ์ฒซ ๋ฒˆ์งธ ๊ฒŒ์‹œ๊ธ€์˜ ๋‚ด์šฉ์ž…๋‹ˆ๋‹ค.')).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'View Post' })).toBeInTheDocument(); }); it('View Post ๋ฒ„ํŠผ ํด๋ฆญ ์‹œ ์˜ฌ๋ฐ”๋ฅธ ๊ฒฝ๋กœ๋กœ ์ด๋™ํ•ด์•ผ ํ•ด์š”.', () => { const mockPush = vi.fn(); // useRouter์˜ push ํ•จ์ˆ˜๋ฅผ Mockingํ•œ ํ›„, ํ•ด๋‹น Mock ํ•จ์ˆ˜๋ฅผ ๊ฐ€์ ธ์™€์š”. // ์ด๋ ‡๊ฒŒ ํ•˜๋ฉด ํ…Œ์ŠคํŠธ์—์„œ mockPush๊ฐ€ ํ˜ธ์ถœ๋˜์—ˆ๋Š”์ง€ ์—ฌ๋ถ€๋ฅผ ๊ฒ€์ฆํ•  ์ˆ˜ ์žˆ์–ด์š”. vi.mock('next/navigation', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, useRouter: vi.fn(() => ({ push: mockPush, replace: vi.fn(), prefetch: vi.fn(), })), }; }); render( <PostCard id="2" title="๋‘ ๋ฒˆ์งธ ๊ฒŒ์‹œ๊ธ€" content="๋‘ ๋ฒˆ์งธ ๊ฒŒ์‹œ๊ธ€ ๋‚ด์šฉ์ž…๋‹ˆ๋‹ค." /> ); const viewPostButton = screen.getByRole('button', { name: 'View Post' }); fireEvent.click(viewPostButton); expect(mockPush).toHaveBeenCalledTimes(1); expect(mockPush).toHaveBeenCalledWith('/posts/2'); // ์˜ฌ๋ฐ”๋ฅธ ๊ฒฝ๋กœ๋กœ ํ˜ธ์ถœ๋˜์—ˆ๋Š”์ง€ ํ™•์ธ }); });
๊ฒฝ๊ณ 

vi.mock๋Š” ํŒŒ์ผ์˜ ์ตœ์ƒ๋‹จ์—์„œ ์„ ์–ธ๋˜์–ด์•ผ ํ•˜๋ฉฐ, ํ…Œ์ŠคํŠธ ํŒŒ์ผ ์ „์ฒด์— ๊ฑธ์ณ Mocking์ด ์ ์šฉ๋ผ์š”. ๋งŒ์•ฝ ๊ฐ ํ…Œ์ŠคํŠธ ์ผ€์ด์Šค๋งˆ๋‹ค ๋‹ค๋ฅธ Mocking์ด ํ•„์š”ํ•˜๋‹ค๋ฉด, vi.doMock ๋˜๋Š” vi.unmock๊ณผ ๊ฐ™์€ ๊ธฐ๋Šฅ์„ ํ™œ์šฉํ•˜๊ฑฐ๋‚˜, Mockingํ•  ๋Œ€์ƒ์„ ์ปดํฌ๋„ŒํŠธ ์™ธ๋ถ€๋กœ ๋ถ„๋ฆฌํ•˜๋Š” ๊ฒƒ์„ ๊ณ ๋ คํ•ด ๋ณด์„ธ์š”.

๐Ÿ“ ์ •๋ฆฌ

0๏ธโƒฃ ํ•ต์‹ฌ ์š”์•ฝ

์˜ค๋Š˜์€ Next.js ํ”„๋กœ์ ํŠธ์—์„œ Vitest์™€ React Testing Library๋ฅผ ํ™œ์šฉํ•˜์—ฌ UI ์ปดํฌ๋„ŒํŠธ๋ฅผ ํ…Œ์ŠคํŠธํ•˜๋Š” ๋ฐฉ๋ฒ•์„ ์‚ดํŽด๋ณด์•˜์–ด์š”. ์ฃผ์š” ๋‚ด์šฉ์€ ๋‹ค์Œ๊ณผ ๊ฐ™์•„์š”.

  • Vitest: Jest์™€ ์œ ์‚ฌํ•œ API๋ฅผ ์ œ๊ณตํ•˜๋ฉฐ, ๋น ๋ฅธ ์‹คํ–‰ ์†๋„์™€ Vite ํ†ตํ•ฉ์ด ๊ฐ•์ ์ด์—์š”.
  • React Testing Library (RTL): ์‚ฌ์šฉ์ž ๊ด€์ ์—์„œ ์ปดํฌ๋„ŒํŠธ์˜ ๋™์ž‘์„ ํ…Œ์ŠคํŠธํ•˜๋Š” ๋ฐ ์ดˆ์ ์„ ๋งž์ถฐ์š”.
  • ์„ค์ •: vitest.config.ts์™€ vitest.setup.ts ํŒŒ์ผ์„ ํ†ตํ•ด ํ…Œ์ŠคํŠธ ํ™˜๊ฒฝ์„ ๊ตฌ์ถ•ํ•ด์š”.
  • ๊ธฐ๋ณธ ํ…Œ์ŠคํŠธ: render, screen.getByRole, expect().toBeInTheDocument() ๋“ฑ์œผ๋กœ ์ปดํฌ๋„ŒํŠธ ๋ Œ๋”๋ง์„ ํ™•์ธํ•ด์š”.
  • ์ด๋ฒคํŠธ ์ฒ˜๋ฆฌ: fireEvent ๋˜๋Š” userEvent๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ์‚ฌ์šฉ์ž ์ƒํ˜ธ์ž‘์šฉ์„ ์‹œ๋ฎฌ๋ ˆ์ด์…˜ํ•˜๊ณ , Mock ํ•จ์ˆ˜๋กœ ์ฝœ๋ฐฑ ํ˜ธ์ถœ ์—ฌ๋ถ€๋ฅผ ๊ฒ€์ฆํ•ด์š”.
  • Mocking: vi.mock๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ next/navigation๊ณผ ๊ฐ™์€ ์™ธ๋ถ€ ์˜์กด์„ฑ์„ ๊ฒฉ๋ฆฌํ•˜๊ณ  ํ…Œ์ŠคํŠธํ•ด์š”.

1๏ธโƒฃ ๋‹ค์Œ ์•ก์…˜

์ด ํฌ์ŠคํŒ…์—์„œ ๋‹ค๋ฃฌ ๋‚ด์šฉ์„ ๋ฐ”ํƒ•์œผ๋กœ ์—ฌ๋Ÿฌ๋ถ„์˜ Next.js ํ”„๋กœ์ ํŠธ์— Vitest์™€ React Testing Library๋ฅผ ์ ์šฉํ•ด ๋ณด์„ธ์š”. ์ž‘์€ ์ปดํฌ๋„ŒํŠธ๋ถ€ํ„ฐ ์‹œ์ž‘ํ•˜์—ฌ ์ ์ง„์ ์œผ๋กœ ํ…Œ์ŠคํŠธ ์ปค๋ฒ„๋ฆฌ์ง€๋ฅผ ๋Š˜๋ ค๋‚˜๊ฐ€๋Š” ๊ฒƒ์ด ์ค‘์š”ํ•ด์š”.
ํ…Œ์ŠคํŠธ ์ฝ”๋“œ๋ฅผ ์ž‘์„ฑํ•˜๋Š” ๊ฒƒ์€ ์ดˆ๊ธฐ์—๋Š” ์‹œ๊ฐ„์ด ๋” ์†Œ์š”๋  ์ˆ˜ ์žˆ์ง€๋งŒ, ์žฅ๊ธฐ์ ์œผ๋กœ๋Š” ๊ฐœ๋ฐœ ์ƒ์‚ฐ์„ฑ์„ ๋†’์ด๊ณ , ์•ˆ์ •์ ์ธ ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜์„ ๊ตฌ์ถ•ํ•˜๋Š” ๋ฐ ํฐ ๋„์›€์ด ๋  ๊ฑฐ์˜ˆ์š”. ๊พธ์ค€ํžˆ ์—ฐ์Šตํ•˜์—ฌ ํ…Œ์ŠคํŠธ ์ฝ”๋“œ ์ž‘์„ฑ์— ์ต์ˆ™ํ•ด์ง€์‹œ๊ธธ ๋ฐ”๋ผ์š”!

๐Ÿ“ฎ ์ฐธ๊ณ 

์—ฐ๊ด€๋œ ํฌ์ŠคํŠธ