Skip to Content
PatternsTesting Patterns

Testing Patterns

효과적인 테스트 전략으로 코드 품질과 안정성을 보장하는 패턴입니다.

개요

테스팅은 다음과 같은 경우에 필요합니다:

  • 단위 테스트: 개별 함수와 컴포넌트의 동작 검증
  • 통합 테스트: 여러 컴포넌트가 함께 동작하는지 검증
  • E2E 테스트: 사용자 시나리오 전체를 검증
  • 시각적 회귀 테스트: UI 변경사항을 자동으로 감지
  • 성능 테스트: 애플리케이션 성능을 측정하고 검증

기본 패턴

1. Vitest를 활용한 단위 테스트

Vitest로 빠르고 효율적인 단위 테스트를 작성합니다.

// src/utils/__tests__/formatters.test.ts import { describe, it, expect } from "vitest"; import { formatCurrency, formatDate, formatPhoneNumber } from "../formatters"; describe("formatCurrency", () => { it("should format number to Korean currency", () => { expect(formatCurrency(1000)).toBe("₩1,000"); expect(formatCurrency(1234567)).toBe("₩1,234,567"); }); it("should handle zero", () => { expect(formatCurrency(0)).toBe("₩0"); }); it("should handle negative numbers", () => { expect(formatCurrency(-1000)).toBe("-₩1,000"); }); }); describe("formatDate", () => { it("should format date to Korean format", () => { const date = new Date("2024-01-15"); expect(formatDate(date)).toBe("2024년 1월 15일"); }); it("should handle invalid date", () => { expect(formatDate(new Date("invalid"))).toBe("유효하지 않은 날짜"); }); }); describe("formatPhoneNumber", () => { it("should format 10-digit phone number", () => { expect(formatPhoneNumber("0212345678")).toBe("02-1234-5678"); }); it("should format 11-digit phone number", () => { expect(formatPhoneNumber("01012345678")).toBe("010-1234-5678"); }); it("should return original if invalid format", () => { expect(formatPhoneNumber("123")).toBe("123"); }); });

2. React Testing Library로 컴포넌트 테스트

사용자 관점에서 컴포넌트를 테스트합니다.

// src/components/__tests__/LoginForm.test.tsx import { describe, it, expect, vi } from "vitest"; import { render, screen, fireEvent, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { LoginForm } from "../LoginForm"; describe("LoginForm", () => { it("should render login form", () => { render(<LoginForm onSubmit={vi.fn()} />); expect(screen.getByLabelText("이메일")).toBeInTheDocument(); expect(screen.getByLabelText("비밀번호")).toBeInTheDocument(); expect(screen.getByRole("button", { name: "로그인" })).toBeInTheDocument(); }); it("should show validation errors for empty fields", async () => { const user = userEvent.setup(); render(<LoginForm onSubmit={vi.fn()} />); const submitButton = screen.getByRole("button", { name: "로그인" }); await user.click(submitButton); expect( await screen.findByText("이메일을 입력해주세요") ).toBeInTheDocument(); expect( await screen.findByText("비밀번호를 입력해주세요") ).toBeInTheDocument(); }); it("should call onSubmit with form data when valid", async () => { const user = userEvent.setup(); const handleSubmit = vi.fn(); render(<LoginForm onSubmit={handleSubmit} />); const emailInput = screen.getByLabelText("이메일"); const passwordInput = screen.getByLabelText("비밀번호"); const submitButton = screen.getByRole("button", { name: "로그인" }); await user.type(emailInput, "user@example.com"); await user.type(passwordInput, "password123"); await user.click(submitButton); await waitFor(() => { expect(handleSubmit).toHaveBeenCalledWith({ email: "user@example.com", password: "password123", }); }); }); it("should show loading state during submission", async () => { const user = userEvent.setup(); const slowSubmit = vi.fn( () => new Promise((resolve) => setTimeout(resolve, 1000)) ); render(<LoginForm onSubmit={slowSubmit} />); const emailInput = screen.getByLabelText("이메일"); const passwordInput = screen.getByLabelText("비밀번호"); const submitButton = screen.getByRole("button", { name: "로그인" }); await user.type(emailInput, "user@example.com"); await user.type(passwordInput, "password123"); await user.click(submitButton); expect(screen.getByRole("button", { name: "로그인 중..." })).toBeDisabled(); }); it("should display error message on failed submission", async () => { const user = userEvent.setup(); const handleSubmit = vi.fn().mockRejectedValue(new Error("로그인 실패")); render(<LoginForm onSubmit={handleSubmit} />); const emailInput = screen.getByLabelText("이메일"); const passwordInput = screen.getByLabelText("비밀번호"); const submitButton = screen.getByRole("button", { name: "로그인" }); await user.type(emailInput, "user@example.com"); await user.type(passwordInput, "wrong-password"); await user.click(submitButton); expect(await screen.findByText("로그인 실패")).toBeInTheDocument(); }); });

3. Mock Service Worker (MSW)로 API 모킹

MSW를 사용하여 API 호출을 모킹합니다.

// src/mocks/handlers.ts import { http, HttpResponse } from "msw"; export const handlers = [ // GET 요청 모킹 http.get("https://api.example.com/users", () => { return HttpResponse.json([ { id: "1", name: "Alice", email: "alice@example.com" }, { id: "2", name: "Bob", email: "bob@example.com" }, ]); }), // POST 요청 모킹 http.post("https://api.example.com/login", async ({ request }) => { const { email, password } = await request.json(); if (email === "user@example.com" && password === "password123") { return HttpResponse.json({ token: "fake-jwt-token", user: { id: "1", name: "User", email }, }); } return HttpResponse.json( { message: "이메일 또는 비밀번호가 올바르지 않습니다" }, { status: 401 } ); }), // 에러 시뮬레이션 http.get("https://api.example.com/error", () => { return HttpResponse.json( { message: "Internal Server Error" }, { status: 500 } ); }), // 지연 시뮬레이션 http.get("https://api.example.com/slow", async () => { await new Promise((resolve) => setTimeout(resolve, 2000)); return HttpResponse.json({ data: "slow response" }); }), ]; // src/mocks/server.ts import { setupServer } from "msw/node"; import { handlers } from "./handlers"; export const server = setupServer(...handlers); // src/setupTests.ts import { beforeAll, afterEach, afterAll } from "vitest"; import { server } from "./mocks/server"; beforeAll(() => server.listen()); afterEach(() => server.resetHandlers()); afterAll(() => server.close());

고급 패턴

1. Playwright를 활용한 E2E 테스트

실제 브라우저에서 사용자 시나리오를 테스트합니다.

// tests/e2e/login.spec.ts import { test, expect } from "@playwright/test"; test.describe("Login Flow", () => { test.beforeEach(async ({ page }) => { await page.goto("http://localhost:3000/login"); }); test("should display login form", async ({ page }) => { await expect(page.getByLabel("이메일")).toBeVisible(); await expect(page.getByLabel("비밀번호")).toBeVisible(); await expect(page.getByRole("button", { name: "로그인" })).toBeVisible(); }); test("should login successfully with valid credentials", async ({ page }) => { await page.getByLabel("이메일").fill("user@example.com"); await page.getByLabel("비밀번호").fill("password123"); await page.getByRole("button", { name: "로그인" }).click(); // 로그인 후 대시보드로 리다이렉트 await expect(page).toHaveURL("http://localhost:3000/dashboard"); await expect(page.getByText("환영합니다, User님")).toBeVisible(); }); test("should show error message with invalid credentials", async ({ page, }) => { await page.getByLabel("이메일").fill("user@example.com"); await page.getByLabel("비밀번호").fill("wrong-password"); await page.getByRole("button", { name: "로그인" }).click(); await expect( page.getByText("이메일 또는 비밀번호가 올바르지 않습니다") ).toBeVisible(); }); test("should navigate to signup page", async ({ page }) => { await page.getByRole("link", { name: "회원가입" }).click(); await expect(page).toHaveURL("http://localhost:3000/signup"); }); }); // tests/e2e/shopping-cart.spec.ts test.describe("Shopping Cart", () => { test("should add product to cart and checkout", async ({ page }) => { // 상품 목록 페이지 await page.goto("http://localhost:3000/products"); // 첫 번째 상품 클릭 await page.getByRole("link", { name: /상품/ }).first().click(); // 상품 상세 페이지에서 장바구니에 추가 await page.getByRole("button", { name: "장바구니에 추가" }).click(); await expect(page.getByText("장바구니에 추가되었습니다")).toBeVisible(); // 장바구니 페이지로 이동 await page.getByRole("link", { name: "장바구니" }).click(); await expect(page).toHaveURL("http://localhost:3000/cart"); // 장바구니에 상품이 있는지 확인 await expect(page.getByRole("heading", { name: /상품/ })).toBeVisible(); // 결제 페이지로 이동 await page.getByRole("button", { name: "결제하기" }).click(); await expect(page).toHaveURL("http://localhost:3000/checkout"); // 배송 정보 입력 await page.getByLabel("이름").fill("홍길동"); await page.getByLabel("주소").fill("서울시 강남구"); await page.getByLabel("전화번호").fill("010-1234-5678"); // 결제 완료 await page.getByRole("button", { name: "결제 완료" }).click(); await expect(page.getByText("주문이 완료되었습니다")).toBeVisible(); }); });

2. 시각적 회귀 테스트

스크린샷 비교로 UI 변경사항을 자동 감지합니다.

// tests/visual/components.spec.ts import { test, expect } from "@playwright/test"; test.describe("Visual Regression Tests", () => { test("Button component should match screenshot", async ({ page }) => { await page.goto( "http://localhost:6006/?path=/story/components-button--primary" ); // 컴포넌트가 로딩될 때까지 대기 await page.waitForSelector('[data-testid="button"]'); // 스크린샷 비교 await expect(page).toHaveScreenshot("button-primary.png"); }); test("Modal component should match screenshot", async ({ page }) => { await page.goto( "http://localhost:6006/?path=/story/components-modal--default" ); // 모달 열기 await page.getByRole("button", { name: "모달 열기" }).click(); await page.waitForSelector('[role="dialog"]'); // 모달 스크린샷 const modal = page.locator('[role="dialog"]'); await expect(modal).toHaveScreenshot("modal-default.png"); }); test("Form validation should match screenshot", async ({ page }) => { await page.goto("http://localhost:3000/signup"); // 빈 폼 제출하여 에러 메시지 표시 await page.getByRole("button", { name: "가입하기" }).click(); // 에러 메시지가 표시된 상태 스크린샷 await expect(page).toHaveScreenshot("form-validation-errors.png"); }); });

3. Custom Testing Utilities

재사용 가능한 테스트 유틸리티를 만듭니다.

// src/test-utils/test-wrapper.tsx import { ReactElement } from "react"; import { render, RenderOptions } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { BrowserRouter } from "react-router-dom"; import { AuthProvider } from "../contexts/AuthContext"; import { ThemeProvider } from "../contexts/ThemeContext"; const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false }, }, }); interface AllTheProvidersProps { children: React.ReactNode; } function AllTheProviders({ children }: AllTheProvidersProps) { return ( <BrowserRouter> <QueryClientProvider client={queryClient}> <ThemeProvider> <AuthProvider>{children}</AuthProvider> </ThemeProvider> </QueryClientProvider> </BrowserRouter> ); } function customRender( ui: ReactElement, options?: Omit<RenderOptions, "wrapper"> ) { return render(ui, { wrapper: AllTheProviders, ...options }); } export * from "@testing-library/react"; export { customRender as render }; // src/test-utils/test-data.ts export const mockUser = { id: "1", name: "Test User", email: "test@example.com", role: "user", }; export const mockProducts = [ { id: "1", name: "Product 1", price: 10000, image: "/product1.jpg" }, { id: "2", name: "Product 2", price: 20000, image: "/product2.jpg" }, { id: "3", name: "Product 3", price: 30000, image: "/product3.jpg" }, ]; export const mockApiResponse = <T,>(data: T, delay = 0) => { return new Promise<T>((resolve) => { setTimeout(() => resolve(data), delay); }); }; export const mockApiError = (message: string, status = 500, delay = 0) => { return new Promise((_, reject) => { setTimeout(() => reject({ message, status }), delay); }); }; // 사용 예시 import { render, screen, waitFor } from "@/test-utils/test-wrapper"; import { mockUser, mockApiResponse } from "@/test-utils/test-data"; test("should display user profile", async () => { const mockFetch = vi.fn(() => mockApiResponse(mockUser)); global.fetch = mockFetch; render(<UserProfile userId="1" />); await waitFor(() => { expect(screen.getByText("Test User")).toBeInTheDocument(); }); });

4. 성능 테스트

Lighthouse CI로 성능을 자동으로 측정합니다.

// tests/performance/lighthouse.spec.ts import { test, expect } from '@playwright/test' import { playAudit } from 'playwright-lighthouse' test.describe('Performance Tests', () => { test('should meet performance budget', async ({ page }, testInfo) => { await page.goto('http://localhost:3000') await playAudit({ page, port: 9222, thresholds: { performance: 90, accessibility: 90, 'best-practices': 90, seo: 90, pwa: 50 }, reports: { formats: { html: true, json: true }, directory: `lighthouse-reports/${testInfo.project.name}` } }) }) test('should have fast LCP', async ({ page }) => { await page.goto('http://localhost:3000') const metrics = await page.evaluate(() => { return new Promise((resolve) => { new PerformanceObserver((list) => { const entries = list.getEntries() const lastEntry = entries[entries.length - 1] resolve(lastEntry) }).observe({ type: 'largest-contentful-paint', buffered: true }) setTimeout(() => resolve(null), 5000) }) }) expect(metrics).toBeTruthy() expect((metrics as any).startTime).toBeLessThan(2500) // LCP < 2.5s }) }) // package.json scripts { "scripts": { "test:perf": "playwright test tests/performance", "lighthouse": "lighthouse http://localhost:3000 --view --preset=desktop" } }

Best Practices

✅ 권장사항

  1. 테스트 피라미드 준수: 70% 단위, 20% 통합, 10% E2E
  2. 사용자 관점 테스트: implementation details 대신 사용자 행동 테스트
  3. AAA 패턴: Arrange, Act, Assert 구조로 테스트 작성
  4. 격리된 테스트: 각 테스트는 독립적으로 실행 가능해야 함
  5. 명확한 테스트 이름: 무엇을 테스트하는지 이름으로 알 수 있어야 함
  6. Mock 최소화: 필요한 경우에만 mock 사용
  7. CI/CD 통합: 모든 테스트를 자동화하여 CI/CD 파이프라인에 통합

⚠️ 피해야 할 것

  1. implementation details 테스트: 내부 구현 대신 동작 테스트
  2. 과도한 mocking: 실제 동작과 다른 mock 사용
  3. flaky 테스트: 불안정한 테스트는 즉시 수정
  4. 테스트 ID 남용: data-testid 대신 role, label 사용
  5. 비동기 처리 무시: waitFor, findBy 등으로 적절히 대기
  6. 스냅샷 과다 사용: 의미 있는 검증 대신 스냅샷 남용

테스트 커버리지 목표

// vitest.config.ts export default defineConfig({ test: { coverage: { provider: 'v8', reporter: ['text', 'json', 'html'], lines: 80, functions: 80, branches: 80, statements: 80, exclude: [ 'node_modules/', 'src/test-utils/', '**/*.test.ts', '**/*.spec.ts' ] } } })

제품별 테스팅 전략

Foundation (기본 프레임워크)

// Foundation용 공통 테스트 유틸리티 export function createMockRouter(options: Partial<Router> = {}) { return { push: vi.fn(), replace: vi.fn(), back: vi.fn(), ...options, }; } export function createMockApiClient() { return { get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn(), }; }

전체 코드 보기: Foundation Test Utils

iCignal (분석 플랫폼)

// iCignal용 차트 컴포넌트 테스트 test("should render chart with data", async () => { const mockData = [ { date: "2024-01", value: 100 }, { date: "2024-02", value: 200 }, ]; render(<AnalyticsChart data={mockData} />); // 차트가 렌더링되었는지 확인 await waitFor(() => { expect(screen.getByRole("img", { name: /차트/ })).toBeInTheDocument(); }); });

전체 코드 보기: iCignal Chart Tests

Cals (예약 시스템)

// Cals용 예약 플로우 E2E 테스트 test("should complete booking flow", async ({ page }) => { await page.goto("http://localhost:3000"); // 날짜 선택 await page.getByRole("button", { name: "예약하기" }).click(); await page.getByLabel("날짜").fill("2024-12-25"); // 시간 선택 await page.getByLabel("시간").selectOption("14:00"); // 예약 정보 입력 await page.getByLabel("이름").fill("홍길동"); await page.getByLabel("전화번호").fill("010-1234-5678"); // 예약 완료 await page.getByRole("button", { name: "예약 완료" }).click(); await expect(page.getByText("예약이 완료되었습니다")).toBeVisible(); });

전체 코드 보기: Cals Booking Tests


테스트 및 실행

CodeSandbox에서 실행

Testing Patterns 예제 실행하기 

로컬 실행

# 1. 테스트 실행 pnpm test # 2. 커버리지 리포트 생성 pnpm test:coverage # 3. watch 모드로 테스트 pnpm test:watch # 4. E2E 테스트 pnpm test:e2e # 5. 성능 테스트 pnpm test:perf

관련 패턴

Last updated on