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
✅ 권장사항
- 테스트 피라미드 준수: 70% 단위, 20% 통합, 10% E2E
- 사용자 관점 테스트: implementation details 대신 사용자 행동 테스트
- AAA 패턴: Arrange, Act, Assert 구조로 테스트 작성
- 격리된 테스트: 각 테스트는 독립적으로 실행 가능해야 함
- 명확한 테스트 이름: 무엇을 테스트하는지 이름으로 알 수 있어야 함
- Mock 최소화: 필요한 경우에만 mock 사용
- CI/CD 통합: 모든 테스트를 자동화하여 CI/CD 파이프라인에 통합
⚠️ 피해야 할 것
- implementation details 테스트: 내부 구현 대신 동작 테스트
- 과도한 mocking: 실제 동작과 다른 mock 사용
- flaky 테스트: 불안정한 테스트는 즉시 수정
- 테스트 ID 남용: data-testid 대신 role, label 사용
- 비동기 처리 무시: waitFor, findBy 등으로 적절히 대기
- 스냅샷 과다 사용: 의미 있는 검증 대신 스냅샷 남용
테스트 커버리지 목표
// 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();
});
});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();
});테스트 및 실행
CodeSandbox에서 실행
로컬 실행
# 1. 테스트 실행
pnpm test
# 2. 커버리지 리포트 생성
pnpm test:coverage
# 3. watch 모드로 테스트
pnpm test:watch
# 4. E2E 테스트
pnpm test:e2e
# 5. 성능 테스트
pnpm test:perf관련 패턴
- Accessibility Patterns - 접근성 테스트
- Performance Optimization - 성능 테스트
- Security Patterns - 보안 테스트
Last updated on