프론트엔드 테스트 완벽 입문

깽지·2026년 3월 11일
post-thumbnail

테스트 코드... 알아야 한다는 건 알겠는데 어디서부터 시작해야 할지 막막하죠?
이 글은 테스트가 완전히 처음인 분을 위해 "왜 써야 하는지"부터 "어떻게 쓰는지"까지 차근차근 정리했습니다.


🤔 테스트 코드, 왜 써야 할까?

개발하다 보면 이런 경험 한 번쯤 있으셨을 겁니다.

"분명 이 버튼 고쳤는데... 저쪽 기능이 왜 갑자기 안 되지?"

코드를 수정할 때마다 모든 기능을 손으로 직접 눌러보며 확인하는 건 시간도 오래 걸리고, 실수도 잦습니다.

테스트 코드는 이 과정을 자동화합니다.

테스트 코드가 없을 때
수정 → 수동 확인 → 배포 → 버그 발견 → 야근 😭

테스트 코드가 있을 때
수정 → 테스트 실행(자동) → 문제 즉시 발견 → 안심 배포 😊

테스트 코드의 핵심 가치

  • 회귀 방지: 새 코드가 기존 기능을 망가뜨리지 않는지 자동으로 확인
  • 리팩토링 자신감: 테스트가 통과하면 동작은 동일하다는 보장
  • 문서화: 테스트 코드 자체가 "이 함수/컴포넌트가 어떻게 동작해야 하는지"를 설명
  • 설계 개선: 테스트하기 어려운 코드 = 설계가 나쁜 코드

🏗️ 테스트의 종류 — 테스트 피라미드

테스트는 크게 세 가지 층으로 나뉩니다.

          /\
         /  \
        / E2E \        ← 적게, 핵심 시나리오만
       /────────\
      /통합 테스트\     ← 중간 정도
     /────────────\
    /  단위 테스트  \   ← 많이, 빠르게
   /─────────────────\
종류테스트 대상속도비용신뢰도
단위(Unit)함수, 컴포넌트 하나매우 빠름낮음낮음
통합(Integration)여러 컴포넌트 조합보통중간중간
E2E(End-to-End)실제 브라우저 전체 흐름느림높음높음

피라미드 아래로 갈수록 많이, 빠르게 / 위로 갈수록 적게, 느리게
대부분의 테스트는 단위 + 통합에 집중하는 것이 효율적입니다.


1️⃣ 단위 테스트 (Unit Test)

함수 하나, 컴포넌트 하나를 독립적으로 테스트합니다.
다른 것에 의존하지 않고 해당 단위만 검증합니다.

도구: Vitest / Jest

npm install -D vitest
<# 또는
npm install -D jest @types/jest

순수 함수 테스트 예시

// utils/formatPrice.js
export function formatPrice(price) {
  if (price < 0) throw new Error('가격은 0 이상이어야 합니다');
  return `${price.toLocaleString()}원`;
}
// utils/formatPrice.test.js
import { describe, it, expect } from 'vitest';
import { formatPrice } from './formatPrice';

describe('formatPrice', () => {
  it('숫자를 한국 원화 형식으로 변환한다', () => {
    expect(formatPrice(10000)).toBe('10,000원');
  });

  it('0원도 정상 처리한다', () => {
    expect(formatPrice(0)).toBe('0원');
  });

  it('음수면 에러를 던진다', () => {
    expect(() => formatPrice(-1)).toThrow('가격은 0 이상이어야 합니다');
  });
});

테스트 구조: AAA 패턴

좋은 테스트는 세 단계로 구성됩니다.

it('설명', () => {
  // Arrange: 준비
  const input = 10000;

  // Act: 실행
  const result = formatPrice(input);

  // Assert: 검증
  expect(result).toBe('10,000원');
});

2️⃣ 컴포넌트 테스트

React 컴포넌트가 올바르게 렌더링되고 동작하는지 테스트합니다.

도구: React Testing Library

"구현 방식이 아닌 사용자 관점에서 테스트하라"는 철학을 가진 라이브러리입니다.

npm install -D @testing-library/react @testing-library/user-event @testing-library/jest-dom

기본 컴포넌트 테스트

// components/Button.jsx
export function Button({ onClick, disabled, children }) {
  return (
    <button onClick={onClick} disabled={disabled}>
      {children}
    </button>
  );
}
// components/Button.test.jsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Button } from './Button';

describe('Button 컴포넌트', () => {
  it('텍스트가 올바르게 렌더링된다', () => {
    render(<Button>클릭하세요</Button>);
    expect(screen.getByText('클릭하세요')).toBeInTheDocument();
  });

  it('클릭 시 onClick이 호출된다', async () => {
    const handleClick = vi.fn();
    render(<Button onClick={handleClick}>클릭</Button>);

    await userEvent.click(screen.getByText('클릭'));

    expect(handleClick).toHaveBeenCalledTimes(1);
  });

  it('disabled 상태에서는 클릭이 동작하지 않는다', async () => {
    const handleClick = vi.fn();
    render(<Button onClick={handleClick} disabled>클릭</Button>);

    await userEvent.click(screen.getByText('클릭'));

    expect(handleClick).not.toHaveBeenCalled();
  });
});

자주 쓰는 쿼리 메서드

메서드사용 상황예시
getByText텍스트로 요소 찾기getByText('제출')
getByRole역할로 찾기 (권장)getByRole('button', { name: '제출' })
getByLabelTextlabel과 연결된 inputgetByLabelText('이메일')
getByPlaceholderTextplaceholder로 찾기getByPlaceholderText('이메일 입력')
getByTestIddata-testid 속성getByTestId('submit-btn')

getByRole을 우선적으로 사용하는 것이 접근성 측면에서도 좋습니다.

get vs query vs find

// getBy: 없으면 에러 → 반드시 있어야 하는 요소
screen.getByText('제목')

// queryBy: 없으면 null → 없어야 하는 요소 검증 시
expect(screen.queryByText('에러 메시지')).not.toBeInTheDocument()

// findBy: 비동기 대기 → 나중에 나타나는 요소
const element = await screen.findByText('로딩 완료')

3️⃣ 비동기 테스트

API 호출처럼 비동기 동작을 테스트할 때는 모킹(Mocking)이 필요합니다.

API 모킹: MSW (Mock Service Worker)

실제 네트워크 요청을 가로채서 가짜 응답을 반환합니다.

npm install -D msw
// mocks/handlers.js
import { http, HttpResponse } from 'msw';

export const handlers = [
  http.get('/api/users', () => {
    return HttpResponse.json([
      { id: 1, name: '홍길동' },
      { id: 2, name: '김철수' },
    ]);
  }),

  http.post('/api/login', async ({ request }) => {
    const { email } = await request.json();
    if (email === 'test@test.com') {
      return HttpResponse.json({ token: 'fake-token' });
    }
    return new HttpResponse(null, { status: 401 });
  }),
];
// components/UserList.test.jsx
import { render, screen } from '@testing-library/react';
import { server } from '../mocks/server';
import { UserList } from './UserList';

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

it('유저 목록을 불러와서 표시한다', async () => {
  render(<UserList />);

  expect(screen.getByText('로딩 중...')).toBeInTheDocument();

  expect(await screen.findByText('홍길동')).toBeInTheDocument();
  expect(screen.getByText('김철수')).toBeInTheDocument();
});

4️⃣ 통합 테스트 (Integration Test)

여러 컴포넌트가 함께 잘 동작하는지 테스트합니다.
실제 사용자 시나리오에 가깝게 작성합니다.

// LoginForm.test.jsx
describe('로그인 폼', () => {
  it('이메일과 비밀번호를 입력하고 제출하면 로그인된다', async () => {
    const user = userEvent.setup();
    render(<LoginForm />);

    await user.type(screen.getByLabelText('이메일'), 'test@test.com');
    await user.type(screen.getByLabelText('비밀번호'), 'password123');
    await user.click(screen.getByRole('button', { name: '로그인' }));

    expect(await screen.findByText('환영합니다!')).toBeInTheDocument();
  });

  it('이메일을 입력하지 않으면 에러 메시지가 표시된다', async () => {
    const user = userEvent.setup();
    render(<LoginForm />);

    await user.click(screen.getByRole('button', { name: '로그인' }));

    expect(screen.getByText('이메일을 입력해주세요')).toBeInTheDocument();
  });
});

5️⃣ E2E 테스트 (End-to-End Test)

실제 브라우저에서 실제 사용자처럼 전체 흐름을 테스트합니다.

도구: Playwright

npm install -D @playwright/test
npx playwright install
// tests/login.spec.ts
import { test, expect } from '@playwright/test';

test('로그인 후 대시보드로 이동한다', async ({ page }) => {
  await page.goto('http://localhost:3000/login');

  await page.fill('[name="email"]', 'test@test.com');
  await page.fill('[name="password"]', 'password123');
  await page.click('button[type="submit"]');

  await expect(page).toHaveURL('/dashboard');
  await expect(page.getByText('환영합니다')).toBeVisible();
});

test('잘못된 비밀번호로 로그인 실패', async ({ page }) => {
  await page.goto('http://localhost:3000/login');
  await page.fill('[name="email"]', 'test@test.com');
  await page.fill('[name="password"]', 'wrong-password');
  await page.click('button[type="submit"]');

  await expect(page.getByText('이메일 또는 비밀번호가 잘못되었습니다')).toBeVisible();
});
npx playwright test          # 전체 실행
npx playwright test --ui     # UI 모드 (시각적 확인)
npx playwright test --project=chromium  # 특정 브라우저만

🎭 Mock — 가짜로 대체하기

테스트에서 외부 의존성(API, 모듈, 함수)을 가짜로 교체하는 기법입니다.

Mock 함수

// vi.fn() — 호출 여부, 인자, 반환값을 추적하는 가짜 함수
const mockFn = vi.fn();
mockFn('hello');

expect(mockFn).toHaveBeenCalled();
expect(mockFn).toHaveBeenCalledWith('hello');
expect(mockFn).toHaveBeenCalledTimes(1);

모듈 모킹

vi.mock('./api', () => ({
  fetchUser: vi.fn().mockResolvedValue({ id: 1, name: '홍길동' }),
}));

타이머 모킹

vi.useFakeTimers();
setTimeout(() => console.log('3초 후'), 3000);
vi.advanceTimersByTime(3000); // 시간을 앞으로 돌림
vi.useRealTimers();

📐 좋은 테스트를 작성하는 원칙

1. 구현이 아닌 동작을 테스트하라

// ❌ 내부 구현(state)에 의존
expect(component.state.isLoading).toBe(false);

// ✅ 사용자가 보는 결과에 집중
expect(screen.queryByText('로딩 중...')).not.toBeInTheDocument();

2. 테스트는 독립적이어야 한다

// ❌ 테스트 순서에 의존
let user;
it('유저를 생성한다', () => { user = createUser(); });
it('유저를 수정한다', () => { updateUser(user); });

// ✅ 각 테스트가 자체적으로 준비
it('유저를 수정한다', () => {
  const user = createUser();
  updateUser(user);
});

3. 테스트 설명은 명확하게

// ❌
it('버튼 테스트', () => { ... });

// ✅ 조건 + 기대 결과
it('장바구니가 비어있을 때 결제 버튼이 비활성화된다', () => { ... });

4. 하나의 테스트에서 하나만 검증

// ❌ 여러 관심사가 섞임
it('폼 테스트', () => { /* 렌더링, 입력, 제출, 에러 모두 검증 */ });

// ✅ 관심사 분리
it('초기 렌더링 시 제출 버튼이 비활성화된다', () => { ... });
it('필수 필드 입력 후 제출 버튼이 활성화된다', () => { ... });
it('제출 성공 시 성공 메시지가 표시된다', () => { ... });

🛠️ 프로젝트 설정 (Vite + React + Vitest)

npm create vite@latest my-app -- --template react-ts
cd my-app
npm install -D vitest @testing-library/react @testing-library/user-event @testing-library/jest-dom jsdom msw
// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  test: {
    globals: true,
    environment: 'jsdom',
    setupFiles: './src/test/setup.ts',
  },
});
// src/test/setup.ts
import '@testing-library/jest-dom';
// package.json scripts
{
  "test": "vitest",
  "test:ui": "vitest --ui",
  "test:coverage": "vitest --coverage"
}

📊 커버리지 (Coverage)

테스트가 코드를 얼마나 실행했는지 측정합니다.

npm run test:coverage
----------|---------|----------|---------|---------|
File      | % Stmts | % Branch | % Funcs | % Lines |
----------|---------|----------|---------|---------|
Button.tsx|   100   |    100   |   100   |   100   |
Form.tsx  |    80   |     75   |    90   |    80   |
----------|---------|----------|---------|---------|

⚠️ 커버리지 100%가 목표가 아닙니다!
핵심 비즈니스 로직 70~80% 커버가 현실적인 목표입니다.
커버리지가 높아도 잘못 작성된 테스트는 의미가 없습니다.


🗺️ 처음 시작할 때 권장 순서

1단계: 순수 함수부터 시작
   └── 유틸 함수, 계산 로직 → 가장 쉽고 빠름

2단계: 단순 컴포넌트 테스트
   └── props에 따라 렌더링이 달라지는 컴포넌트

3단계: 사용자 인터랙션 테스트
   └── 클릭, 입력, 폼 제출

4단계: 비동기 + API 모킹
   └── MSW로 API 가짜 응답 처리

5단계: 통합 테스트
   └── 실제 사용자 시나리오

6단계: E2E 테스트 (선택)
   └── 핵심 Critical Path만

🔧 도구 정리

용도도구특징
테스트 러너VitestVite 기반, 빠름, Jest 호환
테스트 러너Jest가장 널리 쓰임
컴포넌트 테스트React Testing Library사용자 관점 테스트
API 모킹MSW네트워크 레벨 모킹
E2EPlaywright크로스 브라우저, 강력함
E2ECypress시각적 UI, 쉬운 디버깅
컴포넌트 격리 개발StorybookUI 컴포넌트 격리 환경

마치며

처음에는 테스트 코드 작성이 "기능 개발 시간을 빼앗는 것" 처럼 느껴질 수 있습니다.
하지만 프로젝트가 커질수록, 팀원이 늘어날수록 테스트는 안전망이 되어줍니다.

처음부터 완벽한 테스트를 작성하려 하지 마세요.
오늘 작성한 함수 하나, 컴포넌트 하나부터 테스트해보는 것으로 충분합니다. 🚀

"나중에 테스트 작성할게요" — 그 나중은 오지 않습니다.


📚 참고

0개의 댓글