본문 바로가기

React 테스팅 기초

5. React 테스팅 기초

React 애플리케이션의 품질을 보장하기 위해서는 체계적인 테스팅이 필요합니다. 이 장에서는 React 테스팅의 기본 개념과 도구들을 학습해보겠습니다.

테스팅이 중요한 이유

테스팅의 장점

// 테스트가 없는 코드의 문제점
function calculateTotal(items, taxRate = 0.1) {
  // 버그: items가 배열이 아닐 경우 오류 발생
  const subtotal = items.reduce((sum, item) => sum + item.price, 0);
  return subtotal * (1 + taxRate);
}

// 사용 시 런타임에서만 오류 발견
// calculateTotal(null); // TypeError 발생
// 테스트를 통해 미리 발견할 수 있는 문제들
function calculateTotal(items, taxRate = 0.1) {
  if (!Array.isArray(items)) {
    throw new Error('items는 배열이어야 합니다');
  }
  
  const subtotal = items.reduce((sum, item) => {
    if (typeof item.price !== 'number') {
      throw new Error('각 아이템은 숫자 타입의 price 속성을 가져야 합니다');
    }
    return sum + item.price;
  }, 0);
  
  return subtotal * (1 + taxRate);
}

// 테스트 예제
describe('calculateTotal', () => {
  test('올바른 총액을 계산해야 함', () => {
    const items = [{ price: 100 }, { price: 200 }];
    expect(calculateTotal(items, 0.1)).toBe(330);
  });
  
  test('빈 배열일 때 0을 반환해야 함', () => {
    expect(calculateTotal([], 0.1)).toBe(0);
  });
  
  test('items가 배열이 아닐 때 오류를 발생시켜야 함', () => {
    expect(() => calculateTotal(null)).toThrow('items는 배열이어야 합니다');
  });
});

테스팅 도구 소개

주요 테스팅 라이브러리

Vite로 생성한 React 프로젝트에서는 기본적으로 다음 테스팅 도구들을 사용합니다:

# Vite 프로젝트 생성
npm create vite@latest my-react-app -- --template react
cd my-react-app
npm install

# 테스팅 라이브러리 설치
npm install -D vitest jsdom @testing-library/react @testing-library/jest-dom @testing-library/user-event

테스트 설정 파일

// vite.config.js
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

export default defineConfig({
  plugins: [react()],
  test: {
    globals: true,
    environment: 'jsdom',
    setupFiles: ['./src/test-setup.js']
  }
})
// src/test-setup.js
import '@testing-library/jest-dom'

첫 번째 테스트 작성하기

간단한 컴포넌트 테스트

// src/components/Greeting.js
function Greeting({ name = '방문자' }) {
  return (
    <div>
      <h1>안녕하세요, {name}님!</h1>
      <p>오늘도 좋은 하루 되세요.</p>
    </div>
  );
}

export default Greeting;
// src/components/Greeting.test.js
import { render, screen } from '@testing-library/react';
import { describe, test, expect } from 'vitest';
import Greeting from './Greeting';

describe('Greeting 컴포넌트', () => {
  test('기본 인사말을 렌더링해야 함', () => {
    render(<Greeting />);
    
    // 텍스트로 요소 찾기
    expect(screen.getByText('안녕하세요, 방문자님!')).toBeInTheDocument();
    expect(screen.getByText('오늘도 좋은 하루 되세요.')).toBeInTheDocument();
  });
  
  test('전달된 이름으로 인사말을 렌더링해야 함', () => {
    render(<Greeting name="김개발" />);
    
    expect(screen.getByText('안녕하세요, 김개발님!')).toBeInTheDocument();
  });
  
  test('제목이 올바른 태그로 렌더링되어야 함', () => {
    render(<Greeting name="김개발" />);
    
    // role로 요소 찾기
    const heading = screen.getByRole('heading', { level: 1 });
    expect(heading).toHaveTextContent('안녕하세요, 김개발님!');
  });
});

다양한 쿼리 방법

Testing Library의 쿼리 우선순위

// src/components/LoginForm.js
import { useState } from 'react';

function LoginForm({ onSubmit }) {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [isSubmitting, setIsSubmitting] = useState(false);
  
  const handleSubmit = async (e) => {
    e.preventDefault();
    setIsSubmitting(true);
    
    try {
      await onSubmit({ email, password });
    } finally {
      setIsSubmitting(false);
    }
  };
  
  return (
    <form onSubmit={handleSubmit} aria-label="로그인 폼">
      <div>
        <label htmlFor="email">이메일</label>
        <input
          id="email"
          type="email"
          value={email}
          onChange={(e) => setEmail(e.target.value)}
          required
        />
      </div>
      
      <div>
        <label htmlFor="password">비밀번호</label>
        <input
          id="password"
          type="password"
          value={password}
          onChange={(e) => setPassword(e.target.value)}
          required
        />
      </div>
      
      <button 
        type="submit" 
        disabled={isSubmitting}
        aria-label={isSubmitting ? '로그인 중' : '로그인'}
      >
        {isSubmitting ? '로그인 중...' : '로그인'}
      </button>
    </form>
  );
}

export default LoginForm;
// src/components/LoginForm.test.js
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, test, expect, vi } from 'vitest';
import LoginForm from './LoginForm';

describe('LoginForm 컴포넌트', () => {
  test('폼 요소들이 올바르게 렌더링되어야 함', () => {
    const mockSubmit = vi.fn();
    render(<LoginForm onSubmit={mockSubmit} />);
    
    // 1. getByRole - 가장 선호되는 방법
    expect(screen.getByRole('form')).toBeInTheDocument();
    expect(screen.getByRole('textbox', { name: '이메일' })).toBeInTheDocument();
    expect(screen.getByRole('button', { name: '로그인' })).toBeInTheDocument();
    
    // 2. getByLabelText - 레이블과 연결된 input 찾기
    expect(screen.getByLabelText('이메일')).toBeInTheDocument();
    expect(screen.getByLabelText('비밀번호')).toBeInTheDocument();
    
    // 3. getByDisplayValue - 현재 값으로 찾기
    expect(screen.getByDisplayValue('')).toBeInTheDocument();
  });
  
  test('사용자 입력을 처리해야 함', async () => {
    const user = userEvent.setup();
    const mockSubmit = vi.fn();
    render(<LoginForm onSubmit={mockSubmit} />);
    
    const emailInput = screen.getByLabelText('이메일');
    const passwordInput = screen.getByLabelText('비밀번호');
    
    // 사용자 입력 시뮬레이션
    await user.type(emailInput, 'test@example.com');
    await user.type(passwordInput, 'password123');
    
    expect(emailInput).toHaveValue('test@example.com');
    expect(passwordInput).toHaveValue('password123');
  });
  
  test('폼 제출이 올바르게 동작해야 함', async () => {
    const user = userEvent.setup();
    const mockSubmit = vi.fn().mockResolvedValue();
    render(<LoginForm onSubmit={mockSubmit} />);
    
    // 입력 채우기
    await user.type(screen.getByLabelText('이메일'), 'test@example.com');
    await user.type(screen.getByLabelText('비밀번호'), 'password123');
    
    // 제출 버튼 클릭
    await user.click(screen.getByRole('button', { name: '로그인' }));
    
    // 제출 함수가 올바른 데이터로 호출되었는지 확인
    expect(mockSubmit).toHaveBeenCalledWith({
      email: 'test@example.com',
      password: 'password123'
    });
  });
  
  test('제출 중에는 버튼이 비활성화되어야 함', async () => {
    const user = userEvent.setup();
    const mockSubmit = vi.fn(() => new Promise(resolve => setTimeout(resolve, 100)));
    render(<LoginForm onSubmit={mockSubmit} />);
    
    // 입력 채우기
    await user.type(screen.getByLabelText('이메일'), 'test@example.com');
    await user.type(screen.getByLabelText('비밀번호'), 'password123');
    
    const submitButton = screen.getByRole('button');
    await user.click(submitButton);
    
    // 제출 중 상태 확인
    expect(submitButton).toBeDisabled();
    expect(screen.getByText('로그인 중...')).toBeInTheDocument();
  });
});

비동기 작업 테스트

API 호출이 있는 컴포넌트 테스트

// src/components/UserProfile.js
import { useState, useEffect } from 'react';

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);
  
  useEffect(() => {
    if (!userId) return;
    
    const fetchUser = async () => {
      try {
        setLoading(true);
        setError(null);
        
        // 실제로는 API 호출
        const response = await fetch(`/api/users/${userId}`);
        if (!response.ok) {
          throw new Error('사용자를 찾을 수 없습니다');
        }
        const userData = await response.json();
        setUser(userData);
      } catch (err) {
        setError(err.message);
      } finally {
        setLoading(false);
      }
    };
    
    fetchUser();
  }, [userId]);
  
  if (loading) {
    return <div>사용자 정보를 불러오는 중...</div>;
  }
  
  if (error) {
    return <div role="alert">오류: {error}</div>;
  }
  
  if (!user) {
    return <div>사용자 정보가 없습니다.</div>;
  }
  
  return (
    <div>
      <h2>{user.name}의 프로필</h2>
      <p>이메일: {user.email}</p>
      <p>가입일: {user.joinDate}</p>
    </div>
  );
}

export default UserProfile;
// src/components/UserProfile.test.js
import { render, screen, waitFor } from '@testing-library/react';
import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
import UserProfile from './UserProfile';

// fetch API 모킹
const mockFetch = vi.fn();
global.fetch = mockFetch;

describe('UserProfile 컴포넌트', () => {
  beforeEach(() => {
    mockFetch.mockClear();
  });
  
  afterEach(() => {
    vi.restoreAllMocks();
  });
  
  test('사용자 정보를 성공적으로 로드해야 함', async () => {
    const mockUser = {
      id: 1,
      name: '김개발',
      email: 'kim@example.com',
      joinDate: '2024-01-15'
    };
    
    mockFetch.mockResolvedValueOnce({
      ok: true,
      json: () => Promise.resolve(mockUser)
    });
    
    render(<UserProfile userId={1} />);
    
    // 로딩 상태 확인
    expect(screen.getByText('사용자 정보를 불러오는 중...')).toBeInTheDocument();
    
    // 사용자 정보가 로드될 때까지 대기
    await waitFor(() => {
      expect(screen.getByText('김개발의 프로필')).toBeInTheDocument();
    });
    
    expect(screen.getByText('이메일: kim@example.com')).toBeInTheDocument();
    expect(screen.getByText('가입일: 2024-01-15')).toBeInTheDocument();
    
    // API가 올바른 URL로 호출되었는지 확인
    expect(mockFetch).toHaveBeenCalledWith('/api/users/1');
  });
  
  test('API 에러를 올바르게 처리해야 함', async () => {
    mockFetch.mockResolvedValueOnce({
      ok: false,
      status: 404
    });
    
    render(<UserProfile userId={999} />);
    
    // 에러 메시지가 표시될 때까지 대기
    await waitFor(() => {
      expect(screen.getByRole('alert')).toHaveTextContent('오류: 사용자를 찾을 수 없습니다');
    });
  });
  
  test('네트워크 에러를 올바르게 처리해야 함', async () => {
    mockFetch.mockRejectedValueOnce(new Error('네트워크 오류'));
    
    render(<UserProfile userId={1} />);
    
    await waitFor(() => {
      expect(screen.getByRole('alert')).toHaveTextContent('오류: 네트워크 오류');
    });
  });
  
  test('userId가 없을 때 API를 호출하지 않아야 함', () => {
    render(<UserProfile />);
    
    expect(mockFetch).not.toHaveBeenCalled();
    expect(screen.getByText('사용자 정보를 불러오는 중...')).toBeInTheDocument();
  });
});

상태 변경 테스트

카운터 컴포넌트 테스트

// src/components/Counter.js
import { useState } from 'react';

function Counter({ initialValue = 0, step = 1 }) {
  const [count, setCount] = useState(initialValue);
  
  const increment = () => setCount(prev => prev + step);
  const decrement = () => setCount(prev => prev - step);
  const reset = () => setCount(initialValue);
  
  return (
    <div>
      <h2>카운터: {count}</h2>
      <button onClick={increment}>+{step}</button>
      <button onClick={decrement}>-{step}</button>
      <button onClick={reset}>리셋</button>
    </div>
  );
}

export default Counter;
// src/components/Counter.test.js
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, test, expect } from 'vitest';
import Counter from './Counter';

describe('Counter 컴포넌트', () => {
  test('초기값으로 렌더링되어야 함', () => {
    render(<Counter initialValue={5} />);
    
    expect(screen.getByText('카운터: 5')).toBeInTheDocument();
  });
  
  test('기본값 0으로 렌더링되어야 함', () => {
    render(<Counter />);
    
    expect(screen.getByText('카운터: 0')).toBeInTheDocument();
  });
  
  test('증가 버튼이 동작해야 함', async () => {
    const user = userEvent.setup();
    render(<Counter />);
    
    const incrementButton = screen.getByText('+1');
    await user.click(incrementButton);
    
    expect(screen.getByText('카운터: 1')).toBeInTheDocument();
    
    await user.click(incrementButton);
    expect(screen.getByText('카운터: 2')).toBeInTheDocument();
  });
  
  test('감소 버튼이 동작해야 함', async () => {
    const user = userEvent.setup();
    render(<Counter initialValue={10} />);
    
    const decrementButton = screen.getByText('-1');
    await user.click(decrementButton);
    
    expect(screen.getByText('카운터: 9')).toBeInTheDocument();
  });
  
  test('리셋 버튼이 초기값으로 되돌려야 함', async () => {
    const user = userEvent.setup();
    render(<Counter initialValue={5} />);
    
    // 카운터 변경
    await user.click(screen.getByText('+1'));
    expect(screen.getByText('카운터: 6')).toBeInTheDocument();
    
    // 리셋
    await user.click(screen.getByText('리셋'));
    expect(screen.getByText('카운터: 5')).toBeInTheDocument();
  });
  
  test('커스텀 step으로 동작해야 함', async () => {
    const user = userEvent.setup();
    render(<Counter step={5} />);
    
    await user.click(screen.getByText('+5'));
    expect(screen.getByText('카운터: 5')).toBeInTheDocument();
    
    await user.click(screen.getByText('-5'));
    expect(screen.getByText('카운터: 0')).toBeInTheDocument();
  });
});

테스트 실행과 커버리지

package.json 스크립트 설정

{
  "scripts": {
    "test": "vitest",
    "test:run": "vitest run",
    "test:coverage": "vitest run --coverage"
  }
}

테스트 커버리지 확인

# 단일 실행으로 모든 테스트 실행
npm run test:run

# 커버리지 리포트와 함께 실행
npm run test:coverage

# 특정 파일 테스트
npm test -- Greeting.test.js

# watch 모드로 실행 (개발 중)
npm test

테스트 작성 모범 사례

AAA 패턴 (Arrange, Act, Assert)

// src/utils/formatCurrency.test.js
import { describe, test, expect } from 'vitest';

function formatCurrency(amount, currency = 'KRW') {
  return new Intl.NumberFormat('ko-KR', {
    style: 'currency',
    currency: currency
  }).format(amount);
}

describe('formatCurrency 함수', () => {
  test('한국 원화로 금액을 포맷해야 함', () => {
    // Arrange (준비)
    const amount = 1500000;
    
    // Act (실행)
    const result = formatCurrency(amount);
    
    // Assert (검증)
    expect(result).toBe('₩1,500,000');
  });
  
  test('다른 통화로 금액을 포맷해야 함', () => {
    // Arrange
    const amount = 100;
    const currency = 'USD';
    
    // Act
    const result = formatCurrency(amount, currency);
    
    // Assert
    expect(result).toBe('US$100.00');
  });
  
  test('0원도 올바르게 포맷해야 함', () => {
    // Arrange
    const amount = 0;
    
    // Act
    const result = formatCurrency(amount);
    
    // Assert
    expect(result).toBe('₩0');
  });
});

테스트 이름 작성 규칙

describe('TodoList 컴포넌트', () => {
  // ✅ 좋은 테스트 이름
  test('완료된 할 일은 체크표시와 취소선이 표시되어야 함', () => {});
  test('빈 목록일 때 안내 메시지가 표시되어야 함', () => {});
  test('새 할 일 추가 시 목록 끝에 추가되어야 함', () => {});
  
  // ❌ 나쁜 테스트 이름
  test('렌더링', () => {});
  test('클릭', () => {});
  test('작동함', () => {});
});

정리

React 테스팅의 기초 개념을 학습했습니다:

  1. 테스팅의 중요성: 버그 예방, 리팩토링 안정성, 문서화 효과
  2. 테스팅 도구: Vitest, Testing Library, jsdom 설정
  3. 기본 테스트 작성: 컴포넌트 렌더링과 상호작용 테스트
  4. 쿼리 방법: getByRole, getByLabelText 등의 올바른 사용법
  5. 비동기 테스트: API 호출과 상태 변경 테스트
  6. 모킹: fetch API와 외부 의존성 모킹
  7. 모범 사례: AAA 패턴, 명확한 테스트 이름 작성

테스트는 코드의 품질을 보장하고 유지보수성을 높이는 중요한 도구입니다. 처음에는 테스트 작성에 시간이 걸리지만, 장기적으로는 개발 생산성을 크게 향상시킵니다.

다음 장에서는 컴포넌트 테스팅에 대해 더 자세히 알아보겠습니다.

React 테스팅 기초 - 리엑트 베이스캠프 | 위니버시티