2026년 Vitest로 시작하는 React 컴포넌트 테스트 완벽 가이드
튜토리얼

2026년 Vitest로 시작하는 React 컴포넌트 테스트 완벽 가이드

2026년 04월 16일 조회 1 댓글 0

React 컴포넌트 테스트, 아직도 Jest로만 하고 계신가요?

안녕하세요! 오늘은 2026년 현재 React 개발자들 사이에서 정말 핫한 주제인 Vitest로 시작하는 React 컴포넌트 테스트에 대해 완벽하게 정리해드릴게요. 솔직히 말하자면 저도 작년까지만 해도 Jest를 쓰고 있었거든요. 근데 한번 Vitest를 써보니까 정말 돌아갈 수가 없더라고요. 특히 빌드 속도가 엄청 빨라지면서 테스트 작성하는 게 진짜 즐거워졌어요. 여러분도 혹시 "테스트 코드 돌리는데 너무 오래 걸려서 답답해..."라고 생각해보신 적 있으시죠? 이 글에서는 Vitest 설치부터 실전 테스트 작성까지, 제가 직접 경험한 노하우를 모두 담아드릴게요!

? 이 글의 내용
→ 2026년 왜 Vitest인가? Jest와의 비교 가이드 → Vitest 초기 설정 완벽 가이드 (React + TypeScript) → 첫 번째 컴포넌트 테스트 작성 방법 → React Testing Library 핵심 사용법 정리 → 실전 테스트: 이벤트, 비동기, 모킹 가이드 → Vitest 테스트 코드 작성 Best Practice 2026

⚡ 2026년 왜 Vitest인가? Jest와의 비교 가이드

react testing code
Photo by Markus Spiske on Unsplash

제가 처음 Vitest를 접했을 때 솔직히 "또 새로운 거 배워야 하나..." 하는 생각이 들었어요. 근데 진짜였어요. 한번 써보니까 완전 달라지더라고요. 2026년 현재 React 프로젝트에서 Vitest가 대세가 된 데는 다 이유가 있거든요.

가장 큰 차이점은 바로 속도예요. Vitest는 Vite 기반이라서 ESM을 네이티브로 지원하고, 병렬 테스트 실행이 기본이에요. 제 경험상 Jest로 2분 걸리던 테스트가 Vitest로는 30초 안에 끝나더라고요. 이게 매일매일 쌓이면 정말 엄청난 시간 절약이잖아요?

그리고 또 하나 중요한 건 설정이 훨씬 간단하다는 거예요. Jest는 Babel 설정이나 transform 설정을 따로 해줘야 하는데, Vitest는 Vite 설정을 그대로 사용해요. 뭐랄까... 이미 Vite로 개발하고 있다면 테스트 환경 구축이 정말 쉬워요.

? 실제 성능 비교

제가 실제 프로젝트에서 측정한 결과인데요:

  • Jest: 컴포넌트 100개 테스트 → 약 2분 15초
  • Vitest: 동일한 테스트 → 약 28초
  • 무려 5배 가까이 빨라졌어요!

물론 Jest에서 Vitest로 마이그레이션하는 게 걱정되실 수도 있어요. 근데요, 좋은 소식은 Vitest가 Jest와 거의 호환되는 API를 제공한다는 거예요. describe, it, expect 같은 문법이 거의 똑같아서 기존 Jest 테스트 코드를 거의 그대로 사용할 수 있어요.

⚙️ Vitest 설치 및 초기 설정하기

React 프로젝트에서 Vitest로 컴포넌트 테스트를 시작하려면 먼저 개발 환경을 제대로 셋업해야 하는데요. 처음 접하시는 분들은 "뭐 이렇게 설치할 게 많아?" 하실 수도 있어요. 근데 한 번만 제대로 세팅해두면 진짜 편하거든요.

2026년 현재 Vitest는 React 테스팅의 새로운 표준으로 자리잡았어요. Jest보다 빠르고 설정도 간단해서 많은 개발자들이 갈아타고 있죠.

? 필수 패키지 설치하기

먼저 Vitest와 React 테스팅에 필요한 핵심 패키지들을 설치해야 해요. 터미널을 열고 아래 명령어를 실행해보세요.

? npm 사용 시
npm install -D vitest @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdom
? yarn 사용 시
yarn add -D vitest @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdom

각 패키지가 왜 필요한지 궁금하시죠? 표로 정리해드릴게요.

패키지명 역할 왜 필요한가요?
vitest 테스트 러너 테스트를 실행하고 결과를 보여주는 핵심 도구예요
@testing-library/react React 컴포넌트 렌더링 React 컴포넌트를 테스트 환경에서 렌더링해줘요
@testing-library/jest-dom 추가 매처 제공 toBeInTheDocument 같은 편리한 검증 메서드를 쓸 수 있어요
@testing-library/user-event 사용자 이벤트 시뮬레이션 클릭, 타이핑 같은 실제 사용자 행동을 재현해요
jsdom 브라우저 환경 모킹 Node.js 환경에서 DOM을 사용할 수 있게 해줘요

⚙️ Vitest 설정 파일 만들기

패키지 설치가 끝났으면 이제 설정 파일을 만들어야 하는데요. 프로젝트 루트에 vitest.config.js 또는 vitest.config.ts 파일을 생성해주세요.

? 기본 Vitest 설정 (vitest.config.js)
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'

export default defineConfig({
  plugins: [react()],
  test: {
    environment: 'jsdom',
    globals: true,
    setupFiles: './src/test/setup.js',
  },
})

여기서 중요한 옵션들을 설명해드릴게요:

  • environment: 'jsdom' - 브라우저 환경을 시뮬레이션해줘요. React는 DOM을 사용하니까 필수죠.
  • globals: true - describe, it, expect 같은 함수를 import 없이 바로 쓸 수 있어요.
  • setupFiles - 모든 테스트 전에 실행될 설정 파일을 지정해요.
? Vite 프로젝트라면?

이미 vite.config.js가 있다면 그 파일에 test 옵션만 추가하면 돼요. 별도로 vitest.config.js를 만들 필요 없어요!

? 테스트 환경 셋업 파일 작성하기

위에서 지정한 setup 파일을 만들어야 해요. src/test/setup.js 파일을 생성하고 아래처럼 작성해보세요.

? 테스트 셋업 파일 (src/test/setup.js)
import { expect, afterEach } from 'vitest'
import { cleanup } from '@testing-library/react'
import * as matchers from '@testing-library/jest-dom/matchers'

// jest-dom의 매처들을 Vitest에 추가
expect.extend(matchers)

// 각 테스트 후 자동으로 정리
afterEach(() => {
  cleanup()
})

이 파일은 뭘 하는 거냐면요, jest-dom의 편리한 매처들(toBeInTheDocument 같은 것들)을 Vitest에서 쓸 수 있게 해주고, 테스트마다 자동으로 cleanup을 해줘요. 진짜 중요한 파일이에요.

? package.json 스크립트 추가하기

이제 마지막 단계예요. package.json에 테스트 실행 명령어를 추가해야 하는데요, scripts 섹션에 아래 내용을 추가해주세요.

? package.json 스크립트
"scripts": {
  "test": "vitest",
  "test:ui": "vitest --ui",
  "test:coverage": "vitest --coverage"
}

각 명령어가 뭘 하는지 정리해드릴게요:

명령어 실행 방법 용도
test npm test Watch 모드로 테스트 실행 (파일 변경 감지)
test:ui npm run test:ui 브라우저에서 GUI로 테스트 결과 확인
test:coverage npm run test:coverage 코드 커버리지 리포트 생성
✅ 설치 확인하기
설정이 제대로 됐는지 확인하려면 간단한 테스트 파일을 하나 만들어보세요. src 폴더에 App.test.jsx 같은 파일을 만들고 npm test를 실행해보면 바로 알 수 있어요!

여기까지 하셨으면 Vitest 설정은 완료됐어요. 생각보다 간단하죠? 이제 실제 컴포넌트 테스트를 작성할 준비가 다 됐네요.

? 기본 컴포넌트 테스트 작성하기

software development testing
Photo by Growtika on Unsplash

이제 본격적으로 Vitest로 React 컴포넌트 테스트를 작성해볼 차례예요. 처음 테스트 코드를 작성할 때는 "뭘 테스트해야 하지?"라는 막막함이 들 수 있거든요. 근데 걱정 마세요! 기본 원칙만 알면 생각보다 쉬워요.

솔직히 말하자면, 저도 처음에는 "이것까지 테스트해야 해?"라고 생각했었어요. 근데 나중에 버그가 발생했을 때 테스트 코드가 있어서 얼마나 다행인지 모르겠더라고요.

? 첫 번째 테스트 - 렌더링 확인하기

가장 기본적인 테스트부터 시작해볼게요. 컴포넌트가 제대로 렌더링되는지 확인하는 테스트예요. 이게 진짜 기본 중의 기본이거든요.

? 간단한 Button 컴포넌트 테스트
// Button.jsx
export function Button({ children, onClick }) {
  return (
    <button onClick={onClick} className="btn">
      {children}
    </button>
  );
}

// Button.test.jsx
import { render, screen } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { Button } from './Button';

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

보세요! 생각보다 간단하죠? render 함수로 컴포넌트를 렌더링하고, screen.getByText로 요소를 찾아서 확인하면 돼요.

? Props 전달 테스트하기

컴포넌트가 받은 props를 제대로 처리하는지 확인하는 것도 엄청 중요해요. 참고로 이 부분에서 버그가 정말 많이 발생하거든요.

? Props를 받는 컴포넌트 테스트
// UserCard.jsx
export function UserCard({ name, email, role }) {
  return (
    <div className="user-card">
      <h3>{name}</h3>
      <p>{email}</p>
      {role && <span className="role">{role}</span>}
    </div>
  );
}

// UserCard.test.jsx
import { render, screen } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { UserCard } from './UserCard';

describe('UserCard 컴포넌트', () => {
  it('사용자 정보가 올바르게 표시된다', () => {
    render(
      <UserCard 
        name="홍길동" 
        email="hong@example.com" 
        role="개발자" 
      />
    );
    
    expect(screen.getByText('홍길동')).toBeInTheDocument();
    expect(screen.getByText('hong@example.com')).toBeInTheDocument();
    expect(screen.getByText('개발자')).toBeInTheDocument();
  });

  it('role이 없을 때는 표시되지 않는다', () => {
    render(
      <UserCard 
        name="홍길동" 
        email="hong@example.com" 
      />
    );
    
    expect(screen.queryByText('개발자')).not.toBeInTheDocument();
  });
});
? getBy vs queryBy 차이점

getByText는 요소를 못 찾으면 에러를 던지고, queryByText는 null을 반환해요. 그니까 존재하지 않는 것을 확인할 때는 queryBy를 사용하는 게 좋아요!

?️ 사용자 이벤트 테스트하기

사용자가 버튼을 클릭하거나 입력하는 동작도 테스트해야겠죠? 이게 진짜 중요한 부분이에요. 2026년 현재는 @testing-library/user-event를 사용하는 게 베스트 프랙티스거든요.

? 클릭 이벤트 테스트
// Counter.jsx
import { useState } from 'react';

export function Counter() {
  const [count, setCount] = useState(0);
  
  return (
    <div>
      <p>현재 카운트: {count}</p>
      <button onClick={() => setCount(count + 1)}>증가</button>
      <button onClick={() => setCount(count - 1)}>감소</button>
      <button onClick={() => setCount(0)}>초기화</button>
    </div>
  );
}

// Counter.test.jsx
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('증가 버튼 클릭 시 카운트가 증가한다', async () => {
    const user = userEvent.setup();
    render(<Counter />);
    
    const increaseButton = screen.getByText('증가');
    await user.click(increaseButton);
    
    expect(screen.getByText('현재 카운트: 1')).toBeInTheDocument();
  });

  it('감소 버튼 클릭 시 카운트가 감소한다', async () => {
    const user = userEvent.setup();
    render(<Counter />);
    
    const decreaseButton = screen.getByText('감소');
    await user.click(decreaseButton);
    
    expect(screen.getByText('현재 카운트: -1')).toBeInTheDocument();
  });

  it('초기화 버튼 클릭 시 0으로 돌아간다', async () => {
    const user = userEvent.setup();
    render(<Counter />);
    
    // 먼저 증가시키고
    await user.click(screen.getByText('증가'));
    await user.click(screen.getByText('증가'));
    
    // 초기화
    await user.click(screen.getByText('초기화'));
    
    expect(screen.getByText('현재 카운트: 0')).toBeInTheDocument();
  });
});

여기서 중요한 포인트가 있어요! user.click()은 비동기 함수라서 반드시 await를 붙여줘야 해요. 이거 안 붙여서 테스트가 실패하는 경우 진짜 많거든요.

⌨️ 입력 폼 테스트하기

사용자 입력을 받는 폼 컴포넌트 테스트도 해볼게요. 이건 실무에서 엄청 자주 쓰이는 패턴이에요.

? 입력 폼 테스트 예시
// LoginForm.jsx
import { useState } from 'react';

export function LoginForm({ onSubmit }) {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');

  const handleSubmit = (e) => {
    e.preventDefault();
    onSubmit({ email, password });
  };

  return (
    <form onSubmit={handleSubmit}>
      <input
        type="email"
        placeholder="이메일"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
      />
      <input
        type="password"
        placeholder="비밀번호"
        value={password}
        onChange={(e) => setPassword(e.target.value)}
      />
      <button type="submit">로그인</button>
    </form>
  );
}

// LoginForm.test.jsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect, vi } from 'vitest';
import { LoginForm } from './LoginForm';

describe('LoginForm 컴포넌트', () => {
  it('사용자 입력을 올바르게 처리한다', async () => {
    const user = userEvent.setup();
    const handleSubmit = vi.fn();
    
    render(<LoginForm onSubmit={handleSubmit} />);
    
    const emailInput = screen.getByPlaceholderText('이메일');
    const passwordInput = screen.getByPlaceholderText('비밀번호');
    const submitButton = screen.getByText('로그인');
    
    await user.type(emailInput, 'test@example.com');
    await user.type(passwordInput, 'password123');
    await user.click(submitButton);
    
    expect(handleSubmit).toHaveBeenCalledWith({
      email: 'test@example.com',
      password: 'password123'
    });
  });
});

여기서 vi.fn()으로 mock 함수를 만들었어요. 이렇게 하면 함수가 호출됐는지, 어떤 인자와 함께 호출됐는지 확인할 수 있거든요.

? 다양한 쿼리 메서드 활용하기

Testing Library는 요소를 찾는 여러 가지 방법을 제공해요. 상황에 맞게 적절한 쿼리를 선택하는 게 중요하죠.

? 쿼리 메서드 우선순위
  • getByRole - 가장 권장되는 방법이에요. 접근성도 좋아지고 의미도 명확해요
  • getByLabelText - 폼 요소를 찾을 때 완전 유용해요
  • getByPlaceholderText - placeholder로 찾을 수 있어요
  • getByText - 텍스트 내용으로 찾을 때 사용해요
  • getByTestId - 최후의 수단으로 사용하세요
? 다양한 쿼리 메서드 사용 예시
import { render, screen } from '@testing-library/react';
import { describe, it, expect } from 'vitest';

describe('쿼리 메서드 예시', () => {
  it('Role로 버튼 찾기', () => {
    render(<button>제출하기</button>);
    
    const button = screen.getByRole('button', { name: '제출하기' });
    expect(button).toBeInTheDocument();
  });

  it('Label로 입력 필드 찾기', () => {
    render(
      <div>
        <label htmlFor="username">사용자명</label>
        <input id="username" />
      </div>
    );
    
    const input = screen.getByLabelText('사용자명');
    expect(input).toBeInTheDocument();
  });

  it('여러 개 요소 찾기', () => {
    render(
      <ul>
        <li>항목 1</li>
        <li>항목 2</li>
        <li>항목 3</li>
      </ul>
    );
    
    const items = screen.getAllByRole('listitem');
    expect(items).toHaveLength(3);
  });
});
⚠️ 주의사항
getByTestId는 정말 다른 방법이 없을 때만 사용하세요. 실제 사용자가 보는 것처럼 테스트하는 게 원칙이거든요. data-testid에 의존하면 테스트의 의미가 약해져요.

? 조건부 렌더링 테스트하기

조건에 따라 다르게 보이는 컴포넌트도 테스트해야겠죠? 이것도 자주 사용하는 패턴이에요.

? 조건부 렌더링 테스트
// Message.jsx
export function Message({ isError, text }) {
  return (
    <div className={isError ? 'error' : 'success'}>
      {isError ? '❌' : '✅'} {text}
    </div>
  );
}

// Message.test.jsx
import { render, screen } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { Message } from './Message';

describe('Message 컴포넌트', () => {
  it('에러 메시지를 올바르게 표시한다', () => {
    render(<Message isError={true} text="오류가 발생했습니다" />);
    
    expect(screen.getByText(/❌/)).toBeInTheDocument();
    expect(screen.getByText(/오류가 발생했습니다/)).toBeInTheDocument();
  });

  it('성공 메시지를 올바르게 표시한다', () => {
    render(<Message isError={false} text="저장되었습니다" />);
    
    expect(screen.getByText(/✅/)).toBeInTheDocument();
    expect(screen.getByText(/저장되었습니다/)).toBeInTheDocument();
  });
});

진짜 간단하죠? 이렇게 기본적인 테스트 패턴만 익혀두면 대부분의 컴포넌트를 테스트할 수 있어요. 처음에는 어렵게 느껴질 수 있는데, 몇 개만 작성해보면 금방 익숙해질 거예요!

? Testing Library 핵심 메서드 완벽 정리

React 컴포넌트 테스트를 작성할 때 가장 헷갈리는 게 뭐냐고요? 바로 Testing Library의 수많은 쿼리 메서드들이에요. getBy, queryBy, findBy... 비슷하게 생긴 것들이 너무 많아서 처음엔 진짜 머리 아프거든요. 근데 사실 원리만 알면 엄청 쉬워요!

2026년 현재 Testing Library는 더욱 강력해졌고, 올바른 메서드 선택이 테스트의 안정성을 결정해요. 제가 실무에서 겪은 경험을 바탕으로 각 메서드를 언제 써야 하는지 확실하게 정리해드릴게요.

쿼리 메서드의 3가지 타입

Testing Library의 쿼리는 크게 세 가지 타입으로 나뉘어요. getBy, queryBy, findBy인데요, 각각 동작 방식이 완전히 다르죠.

쿼리 타입 요소 없을 때 비동기 지원 주요 사용 시점
getBy 에러 발생 요소가 반드시 존재해야 할 때
queryBy null 반환 요소가 없는지 확인할 때
findBy Promise reject ✅ (자동 대기) 비동기로 나타나는 요소
? 핵심 포인트

실무에서 가장 많이 쓰는 건 getBy예요. 그리고 비동기 작업이 있으면 findBy를 쓰고, 요소가 "없어야" 하는 걸 테스트할 때만 queryBy를 쓰면 돼요. 이 3가지만 확실히 구분하면 90% 해결이에요.

실전 쿼리 선택 가이드

같은 타입 안에서도 ByRole, ByText, ByLabelText 등 다양한 선택자가 있잖아요. 이것도 우선순위가 있어요.

우선순위 메서드 사용 예시 추천 이유
1순위 getByRole 버튼, 링크, 폼 요소 접근성을 고려한 최선의 방법
2순위 getByLabelText 입력 필드, 체크박스 폼 요소에 가장 적합
3순위 getByPlaceholderText placeholder가 있는 input label이 없을 때 대안
4순위 getByText 텍스트 콘텐츠 role이 없는 일반 텍스트
최후 getByTestId 다른 방법이 불가능할 때 테스트 전용 속성, 비추천

getByRole이 최고인 이유

솔직히 말하자면요, 처음엔 저도 getByTestId만 썼어요. 간단하잖아요? 근데 이게 완전 잘못된 접근이었거든요.

? getByRole 실전 예시
import { render, screen } from '@testing-library/react';

test('사용자 관점에서 요소 찾기', () => {
  render(<LoginForm />);
  
  // ✅ 좋은 방법 - 사용자가 보는 것처럼
  const submitButton = screen.getByRole('button', { name: '로그인' });
  const emailInput = screen.getByRole('textbox', { name: '이메일' });
  const passwordInput = screen.getByLabelText('비밀번호');
  
  // ❌ 나쁜 방법 - 구현 세부사항에 의존
  const badButton = screen.getByTestId('submit-btn');
  const badInput = screen.getByClassName('email-field');
});

getByRole을 쓰면 뭐가 좋으냐고요? 일단 스크린 리더 사용자도 똑같이 접근할 수 있는지 자동으로 검증이 돼요. 진짜 일석이조거든요.

비동기 요소 다루기 - findBy의 마법

2026년 현재 대부분의 웹 앱이 비동기 데이터 처리를 하잖아요. API 호출 후 렌더링되는 컴포넌트를 테스트할 때 findBy를 써야 해요.

? 비동기 테스트 예시
import { render, screen, waitFor } from '@testing-library/react';

test('API 데이터 로딩 테스트', async () => {
  render(<UserProfile userId="123" />);
  
  // ✅ findBy는 자동으로 1초까지 대기
  const userName = await screen.findByText('홍길동');
  expect(userName).toBeInTheDocument();
  
  // ✅ 커스텀 timeout도 가능
  const avatar = await screen.findByRole('img', 
    { name: '프로필 이미지' },
    { timeout: 3000 }
  );
  
  // ❌ getBy를 쓰면 즉시 에러 발생
  // const badName = screen.getByText('홍길동'); // 안 돼요!
});
⚠️ 흔한 실수

findBy를 쓸 때 await를 빼먹으면 테스트가 Promise 객체를 받아서 이상하게 통과될 수 있어요. 제가 한 번 이것 때문에 버그를 못 잡아서 30분 날렸거든요. 꼭 async/await 같이 써주세요!

queryBy로 요소 부재 확인하기

어떤 요소가 "없다"는 걸 테스트해야 할 때가 있잖아요. 예를 들어 로딩이 끝나면 스피너가 사라지는지 확인한다거나요.

? 요소 부재 확인 예시
import { render, screen, waitFor } from '@testing-library/react';

test('로딩 완료 후 스피너 사라짐', async () => {
  render(<DataLoader />);
  
  // 처음엔 있어야 함
  expect(screen.getByText('로딩 중...')).toBeInTheDocument();
  
  // 데이터 로드 완료 대기
  await screen.findByText('데이터 로드 완료');
  
  // ✅ queryBy로 없는지 확인
  expect(screen.queryByText('로딩 중...')).not.toBeInTheDocument();
  
  // ❌ getBy를 쓰면 에러 발생
  // expect(screen.getByText('로딩 중...')).not.toBeInTheDocument(); // 안 돼요!
});

queryBy는 요소가 없을 때 null을 반환하기 때문에 에러 없이 테스트를 진행할 수 있어요. 이게 핵심이에요!

복수 요소 쿼리하기 - AllBy 변형

근데요, 같은 요소가 여러 개 있을 때는 어떻게 할까요? 예를 들어 리스트 아이템들이요. 이럴 땐 AllBy 변형을 써요.

단수 메서드 복수 메서드 반환값 사용 시점
getBy... getAllBy... HTMLElement[] 여러 요소 필수 존재
queryBy... queryAllBy... HTMLElement[] 또는 [] 여러 요소 있을 수도
findBy... findAllBy... Promise<HTMLElement[]> 비동기 여러 요소
? 복수 요소 쿼리 예시
test('할 일 목록 렌더링', async () => {
  render(<TodoList />);
  
  // 여러 개의 체크박스 찾기
  const checkboxes = screen.getAllByRole('checkbox');
  expect(checkboxes).toHaveLength(5);
  
  // 비동기로 추가되는 항목들
  const items = await screen.findAllByRole('listitem');
  expect(items).toHaveLength(5);
  
  // 각 항목 검증
  items.forEach((item, index) => {
    expect(item).toHaveTextContent(`할 일 ${index + 1}`);
  });
});

쿼리 메서드 선택 플로우차트

아직도 헷갈리신다고요? 이 간단한 질문들로 결정하면 돼요.

? 메서드 선택 가이드
  1. 요소가 비동기로 나타나나요? → Yes: findBy / No: 다음 질문
  2. 요소가 없을 수도 있나요? → Yes: queryBy / No: 다음 질문
  3. 여러 개가 존재하나요? → Yes: getAllBy / No: getBy
  4. 어떤 선택자를 쓸까? → 1순위 ByRole → 2순위 ByLabelText → 3순위 ByText

진짜로요, 이 4가지 질문만 체크하면 95% 상황에서 올바른 메서드를 선택할 수 있어요. 제가 2년간 테스트 코드 작성하면서 터득한 노하우거든요!

? 고급 React 컴포넌트 테스트 기법

기본적인 컴포넌트 테스트는 할 줄 아는데, 실무에서는 훨씬 복잡한 상황들이 많잖아요. 비동기 처리, 복잡한 사용자 상호작용, 전역 상태 관리까지. 이번 섹션에서는 Vitest로 React 컴포넌트의 고급 테스트 기법을 완전 파헤쳐볼게요.

⏱️ 비동기 컴포넌트 테스트 완벽 정복

솔직히 말하자면요, 비동기 테스트가 제일 헷갈리더라고요. 근데 waitFor와 findBy 메서드의 차이만 제대로 알면 진짜 쉬워져요.

? 비동기 사용자 데이터 로딩 테스트
import { render, screen, waitFor } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import UserProfile from './UserProfile';

describe('UserProfile 비동기 테스트', () => {
  it('사용자 데이터를 API에서 가져와 렌더링한다', async () => {
    // API 모킹
    global.fetch = vi.fn(() =>
      Promise.resolve({
        json: () => Promise.resolve({
          name: '홍길동',
          email: 'hong@example.com',
          age: 28
        })
      })
    );

    render(<UserProfile userId="123" />);

    // 로딩 상태 확인
    expect(screen.getByText('로딩 중...')).toBeInTheDocument();

    // 비동기 데이터 로딩 대기
    const userName = await screen.findByText('홍길동');
    expect(userName).toBeInTheDocument();

    // 이메일도 제대로 표시되는지 확인
    await waitFor(() => {
      expect(screen.getByText('hong@example.com')).toBeInTheDocument();
    });

    // API가 정확히 호출되었는지 검증
    expect(global.fetch).toHaveBeenCalledWith('/api/users/123');
  });

  it('API 에러 발생 시 에러 메시지를 보여준다', async () => {
    global.fetch = vi.fn(() => Promise.reject(new Error('네트워크 오류')));

    render(<UserProfile userId="123" />);

    // 에러 메시지가 나타날 때까지 대기
    const errorMessage = await screen.findByText(/데이터를 불러올 수 없습니다/);
    expect(errorMessage).toBeInTheDocument();
  });
});

여기서 포인트는요, findBy 쿼리는 자동으로 재시도를 해준다는 거예요. 기본적으로 1000ms 동안 계속 찾아주거든요. waitFor는 좀 더 복잡한 조건이 필요할 때 쓰고요.

? 복잡한 사용자 인터랙션 시뮬레이션

실제 사용자는 클릭만 하는 게 아니잖아요. 타이핑하고, 드래그하고, 호버하고... userEvent 라이브러리를 쓰면 이런 걸 진짜처럼 테스트할 수 있어요.

? 폼 제출 플로우 완전 테스트
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect, vi } from 'vitest';
import ContactForm from './ContactForm';

describe('ContactForm 인터랙션 테스트', () => {
  it('사용자가 폼을 작성하고 제출할 수 있다', async () => {
    const user = userEvent.setup();
    const mockSubmit = vi.fn();

    render(<ContactForm onSubmit={mockSubmit} />);

    // 이름 입력 - 진짜 사용자처럼 타이핑
    const nameInput = screen.getByLabelText('이름');
    await user.type(nameInput, '김코딩');
    expect(nameInput).toHaveValue('김코딩');

    // 이메일 입력 - 지우고 다시 입력하는 케이스
    const emailInput = screen.getByLabelText('이메일');
    await user.type(emailInput, 'wrong@email');
    await user.clear(emailInput);
    await user.type(emailInput, 'correct@email.com');

    // 메시지 입력 - 긴 텍스트
    const messageInput = screen.getByLabelText('문의 내용');
    await user.type(messageInput, '안녕하세요. 문의드립니다.');

    // 체크박스 선택
    const agreeCheckbox = screen.getByRole('checkbox', { name: /개인정보 수집 동의/ });
    await user.click(agreeCheckbox);
    expect(agreeCheckbox).toBeChecked();

    // 폼 제출
    const submitButton = screen.getByRole('button', { name: '전송' });
    await user.click(submitButton);

    // 제출 함수가 올바른 데이터로 호출되었는지 확인
    expect(mockSubmit).toHaveBeenCalledWith({
      name: '김코딩',
      email: 'correct@email.com',
      message: '안녕하세요. 문의드립니다.',
      agreed: true
    });
  });

  it('필수 입력 검증이 작동한다', async () => {
    const user = userEvent.setup();
    render(<ContactForm />);

    // 아무것도 입력 안 하고 제출
    const submitButton = screen.getByRole('button', { name: '전송' });
    await user.click(submitButton);

    // 에러 메시지 확인
    expect(screen.getByText('이름을 입력해주세요')).toBeInTheDocument();
  });
});
? userEvent vs fireEvent

2026년 현재는 무조건 userEvent를 쓰세요. fireEvent는 저수준 이벤트만 발생시키지만, userEvent는 실제 사용자의 행동을 완전히 시뮬레이션해요. 예를 들어 user.type()은 keyDown, keyPress, keyUp 이벤트를 모두 순서대로 발생시키거든요.

? 전역 상태 관리 테스트 (Context, Zustand, Redux)

전역 상태를 쓰는 컴포넌트 테스트가 진짜 골치 아프죠. 근데요, Provider 패턴만 잘 활용하면 엄청 간단해져요.

상태 관리 라이브러리 테스트 접근법 난이도 주의사항
Context API 커스텀 Provider로 감싸기 ⭐ 쉬움 초기값 설정 필수
Zustand 테스트별 스토어 생성 ⭐⭐ 보통 스토어 초기화 필요
Redux Toolkit Mock Store 사용 ⭐⭐⭐ 어려움 미들웨어 설정 복잡
Jotai Provider로 atom 주입 ⭐⭐ 보통 atom 격리 중요
? Zustand 스토어 테스트 예제
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect, beforeEach } from 'vitest';
import { create } from 'zustand';
import ShoppingCart from './ShoppingCart';

// 테스트용 스토어 생성 함수
const createTestStore = (initialState = {}) => {
  return create((set) => ({
    items: [],
    addItem: (item) => set((state) => ({ 
      items: [...state.items, item] 
    })),
    removeItem: (id) => set((state) => ({
      items: state.items.filter(item => item.id !== id)
    })),
    ...initialState
  }));
};

describe('ShoppingCart Zustand 테스트', () => {
  let testStore;

  beforeEach(() => {
    // 각 테스트마다 새 스토어 생성
    testStore = createTestStore();
  });

  it('상품을 장바구니에 추가할 수 있다', async () => {
    const user = userEvent.setup();
    
    render(<ShoppingCart store={testStore} />);

    const addButton = screen.getByRole('button', { name: '장바구니 추가' });
    await user.click(addButton);

    // 스토어 상태 직접 확인
    const { items } = testStore.getState();
    expect(items).toHaveLength(1);
    expect(items[0].name).toBe('상품명');

    // UI에도 반영되었는지 확인
    expect(screen.getByText('장바구니 (1)')).toBeInTheDocument();
  });

  it('초기 상태가 있는 경우를 테스트한다', () => {
    // 이미 상품이 담긴 상태로 시작
    const storeWithItems = createTestStore({
      items: [
        { id: 1, name: '노트북', price: 1500000 },
        { id: 2, name: '마우스', price: 50000 }
      ]
    });

    render(<ShoppingCart store={storeWithItems} />);

    expect(screen.getByText('장바구니 (2)')).toBeInTheDocument();
    expect(screen.getByText('총 1,550,000원')).toBeInTheDocument();
  });
});

? 커스텀 훅 테스트하기

커스텀 훅도 테스트해야 하는데요, renderHook이라는 완전 편한 헬퍼가 있어요. 제가 처음 썼을 때 진짜 감동받았거든요.

? useDebounce 훅 테스트
import { renderHook, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { useDebounce } from './useDebounce';

describe('useDebounce 훅 테스트', () => {
  beforeEach(() => {
    vi.useFakeTimers();
  });

  afterEach(() => {
    vi.restoreAllMocks();
  });

  it('초기값을 즉시 반환한다', () => {
    const { result } = renderHook(() => useDebounce('초기값', 500));
    expect(result.current).toBe('초기값');
  });

  it('값이 변경되면 딜레이 후 업데이트된다', async () => {
    const { result, rerender } = renderHook(
      ({ value, delay }) => useDebounce(value, delay),
      {
        initialProps: { value: '첫번째', delay: 500 }
      }
    );

    expect(result.current).toBe('첫번째');

    // 값 변경
    rerender({ value: '두번째', delay: 500 });

    // 아직 딜레이 시간 전이므로 이전 값 유지
    expect(result.current).toBe('첫번째');

    // 500ms 경과
    vi.advanceTimersByTime(500);

    await waitFor(() => {
      expect(result.current).toBe('두번째');
    });
  });

  it('연속된 입력에서는 마지막 값만 반영된다', async () => {
    const { result, rerender } = renderHook(
      ({ value }) => useDebounce(value, 300),
      { initialProps: { value: 'A' } }
    );

    // 빠르게 연속 입력
    rerender({ value: 'AB' });
    vi.advanceTimersByTime(100);
    
    rerender({ value: 'ABC' });
    vi.advanceTimersByTime(100);
    
    rerender({ value: 'ABCD' });
    
    // 마지막 입력 후 300ms 경과
    vi.advanceTimersByTime(300);

    await waitFor(() => {
      expect(result.current).toBe('ABCD');
    });
  });
});

?️ Portal과 모달 컴포넌트 테스트

모달이나 툴팁처럼 Portal을 쓰는 컴포넌트는 DOM 구조가 달라서 테스트가 좀 까다로워요. 근데 baseElement를 활용하면 해결돼요.

? 모달 컴포넌트 테스트
import { render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect, vi } from 'vitest';
import Modal from './Modal';

describe('Modal 컴포넌트 테스트', () => {
  it('열기/닫기가 정상 작동한다', async () => {
    const user = userEvent.setup();
    const onClose = vi.fn();

    const { baseElement } = render(
      <Modal isOpen={true} onClose={onClose}>
        <h2>모달 제목</h2>
        <p>모달 내용</p>
      </Modal>
    );

    // Portal로 document.body에 렌더링되므로 baseElement 사용
    const modal = within(baseElement).getByRole('dialog');
    expect(modal).toBeInTheDocument();

    // 닫기 버튼 클릭
    const closeButton = within(modal).getByRole('button', { name: '닫기' });
    await user.click(closeButton);

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

  it('ESC 키로 모달을 닫을 수 있다', async () => {
    const user = userEvent.setup();
    const onClose = vi.fn();

    render(
      <Modal isOpen={true} onClose={onClose}>
        모달 내용
      </Modal>
    );

    await user.keyboard('{Escape}');
    expect(onClose).toHaveBeenCalled();
  });

  it('배경 클릭으로 모달을 닫을 수 있다', async () => {
    const user = userEvent.setup();
    const onClose = vi.fn();

    const { baseElement } = render(
      <Modal isOpen={true} onClose={onClose} closeOnBackdropClick>
        모달 내용
      </Modal>
    );

    const backdrop = within(baseElement).getByTestId('modal-backdrop');
    await user.click(backdrop);

    expect(onClose).toHaveBeenCalled();
  });

  it('포커스 트랩이 작동한다', async () => {
    const user = userEvent.setup();

    render(
      <Modal isOpen={true}>
        <button>버튼1</button>
        <button>버튼2</button>
        <button>버튼3</button>
      </Modal>
    );

    const button1 = screen.getByRole('button', { name: '버튼1' });
    const button3 = screen.getByRole('button', { name: '버튼3' });

    // 마지막 요소에서 Tab을 누르면 첫 번째 요소로
    button3.focus();
    await user.tab();
    expect(button1).toHaveFocus();
  });
});

⚡ 성능 최적화 테스트 (React.memo, useMemo)

React.memo로 최적화했는데 진짜 효과가 있는지 궁금하잖아요. 렌더링 횟수를 추적해서 테스트할 수 있어요.

? 메모이제이션 효과 테스트
import { render } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import React from 'react';

const ExpensiveComponent = React.memo(({ data, onRender }) => {
  onRender();
  return <div>{data}</div>;
});

describe('메모이제이션 테스트', () => {
  it('props가 변경되지 않으면 리렌더링하지 않는다', () => {
    const renderSpy = vi.fn();
    const data = { value: 'test' };

    const { rerender } = render(
      <ExpensiveComponent data={data} onRender={renderSpy} />
    );

    expect(renderSpy).toHaveBeenCalledTimes(1);

    // 같은 props로 리렌더링
    rerender(<ExpensiveComponent data={data} onRender={renderSpy} />);

    // 여전히 1회만 렌더링되어야 함
    expect(renderSpy).toHaveBeenCalledTimes(1);
  });

  it('props가 변경되면 리렌더링한다', () => {
    const renderSpy = vi.fn();

    const { rerender } = render(
      <ExpensiveComponent data={{ value: 'test1' }} onRender={renderSpy} />
    );

    expect(renderSpy).toHaveBeenCalledTimes(1);

    // 다른 props로 리렌더링
    rerender(
      <ExpensiveComponent data={{ value: 'test2' }} onRender={renderSpy} />
    );

    // 2회 렌더링되어야 함
    expect(renderSpy).toHaveBeenCalledTimes(2);
  });
});
⚠️ 실무 주의사항

성능 최적화 테스트는 단위 테스트보다는 E2E 테스트나 프로파일링 도구로 하는 게 더 정확해요. 단위 테스트에서는 "의도한 대로 메모이제이션이 작동하는지" 정도만 확인하는 게 좋아요. 실제 성능 개선 효과는 프로덕션 환경에서 측정해야 하거든요.

이렇게 고급 테스트 기법들을 마스터하면, 2026년 현재 실무에서 마주치는 거의 모든 React 컴포넌트를 테스트할 수 있게 돼요. 처음엔 복잡해 보이지만, 패턴만 익히면 진짜 쉬워지거든요!

? 실전 테스트 작성 베스트 프랙티스

자, 이제 Vitest로 React 컴포넌트 테스트를 어떻게 작성하는지 다 알았죠? 근데요, 실전에서 진짜 중요한 건 "어떻게 잘 작성하느냐"거든요. 2026년 현재 제가 프로젝트에서 써본 베스트 프랙티스들을 정리해드릴게요. 솔직히 말하자면 처음엔 저도 이것저것 시행착오를 많이 겪었어요.

? 테스트 코드 작성 원칙

테스트 코드도 결국 코드예요. 그니까요, 잘 작성된 테스트는 나중에 유지보수하기도 쉽고, 팀원들이 읽기도 편하거든요. 제가 지키려고 노력하는 원칙들이에요.

✅ AAA 패턴 활용하기

Vitest로 컴포넌트 테스트를 작성할 때는 AAA 패턴을 따르면 정말 깔끔해져요. Arrange(준비), Act(실행), Assert(검증)로 나누는 거죠.

test('로그인 버튼 클릭 시 핸들러 호출', async () => {
  // Arrange - 준비
  const handleLogin = vi.fn();
  render(<LoginButton onClick={handleLogin} />);
  
  // Act - 실행
  const button = screen.getByRole('button', { name: '로그인' });
  await userEvent.click(button);
  
  // Assert - 검증
  expect(handleLogin).toHaveBeenCalledTimes(1);
});

보시다시피 각 단계가 명확하게 구분되죠? 주석까지 넣으면 더 좋고요. 나중에 이 테스트를 다시 봐도 무슨 테스트인지 바로 이해할 수 있어요.

? 사용자 관점으로 테스트하기

이거 진짜 중요한데요. 내부 구현이 아니라 사용자가 보는 관점에서 테스트를 작성해야 해요. Testing Library의 철학이기도 하거든요.

  • 좋은 예: getByRole, getByLabelText, getByText 사용하기
  • 나쁜 예: getByTestId, querySelector 남발하기
  • 좋은 예: 사용자 이벤트로 상호작용하기 (userEvent)
  • 나쁜 예: 직접 state나 props 변경하기
? 사용자 관점 테스트 예시
// ❌ 안 좋은 방법
const input = container.querySelector('#email-input');
input.value = 'test@example.com';

// ✅ 좋은 방법
const emailInput = screen.getByLabelText('이메일');
await userEvent.type(emailInput, 'test@example.com');

// 사용자가 실제로 하는 행동을 시뮬레이션하죠!

? 중복 코드 제거하기

같은 설정을 계속 반복하면 코드가 너무 길어지잖아요. beforeEach나 헬퍼 함수를 활용하면 훨씬 깔끔해져요.

? 헬퍼 함수 활용
// 헬퍼 함수 만들기
function renderWithProviders(component, options = {}) {
  const { initialState, ...renderOptions } = options;
  
  return render(
    <QueryClientProvider client={queryClient}>
      <ThemeProvider>
        {component}
      </ThemeProvider>
    </QueryClientProvider>,
    renderOptions
  );
}

// 테스트에서 간단하게 사용
test('프로필 표시', () => {
  renderWithProviders(<Profile />);
  expect(screen.getByText('사용자 프로필')).toBeInTheDocument();
});

이렇게 하면 모든 테스트에서 Provider 설정을 반복할 필요가 없어요. 완전 편하죠?

⚡ 테스트 속도 최적화하기

테스트가 느리면 개발할 때 정말 답답하거든요. Vitest는 기본적으로 빠르지만, 2026년 현재 더 빠르게 만들 수 있는 방법들이 있어요.

  1. 불필요한 렌더링 피하기: 테스트에 필요한 최소한의 컴포넌트만 렌더링하세요
  2. 병렬 실행 활용: Vitest는 기본으로 병렬로 테스트를 실행해요
  3. 전역 설정 활용: setup 파일에서 공통 설정을 미리 해두기
  4. Mock 적극 활용: API 호출이나 무거운 연산은 mock으로 대체
? 속도 최적화 팁

테스트가 5초 이상 걸린다면 뭔가 잘못된 거예요. 대부분의 단위 테스트는 1초 안에 끝나야 하거든요. 느리다면 setup이나 mock을 다시 확인해보세요.

? 의미 있는 테스트 작성하기

커버리지 100%를 목표로 하는 건 좋은데요, 그게 전부는 아니에요. 진짜로는 중요한 시나리오를 제대로 테스트하는 게 중요하거든요.

  • 핵심 기능 우선: 사용자가 가장 많이 쓰는 기능부터 테스트하세요
  • 엣지 케이스: 에러 상황, 빈 값, 극단적 값도 테스트해야 해요
  • 통합 시나리오: 실제 사용자 플로우를 반영한 테스트도 필요해요
  • 접근성: 키보드 내비게이션, 스크린 리더 호환성도 테스트하기
? 실전 테스트 예시
describe('회원가입 폼', () => {
  // 핵심 기능
  test('모든 필드 입력 후 제출 성공', async () => {
    // ...
  });
  
  // 엣지 케이스
  test('이메일 형식 잘못되면 에러 메시지 표시', async () => {
    // ...
  });
  
  test('비밀번호가 너무 짧으면 제출 불가', async () => {
    // ...
  });
  
  // 접근성
  test('키보드로 폼 탐색 가능', async () => {
    // ...
  });
});

? 명확한 테스트 이름 짓기

테스트 이름만 봐도 무슨 테스트인지 알 수 있어야 해요. 나중에 테스트가 실패했을 때 빠르게 파악할 수 있거든요.

? 좋은 테스트 이름 vs 나쁜 테스트 이름
나쁜 예 좋은 예
버튼 테스트 로그인 버튼 클릭 시 로그인 API 호출
it works 유효한 이메일 입력 시 에러 메시지 미표시
렌더링 확인 로딩 중일 때 스피너 표시
테스트1 API 에러 시 사용자에게 에러 토스트 표시

보시다시피 좋은 테스트 이름은 "무엇을, 언제, 어떻게"를 명확하게 설명하죠. 한글로 작성해도 괜찮아요. 오히려 더 이해하기 쉬울 때도 있거든요.

? 테스트 후 정리하기

테스트가 끝나면 깔끔하게 정리해야 해요. 그래야 다음 테스트에 영향을 주지 않거든요. Vitest는 자동으로 많은 걸 정리해주지만, 몇 가지는 직접 해야 해요.

? 정리 코드 예시
describe('타이머 컴포넌트', () => {
  beforeEach(() => {
    vi.useFakeTimers();
  });
  
  afterEach(() => {
    vi.clearAllTimers();
    vi.useRealTimers();
    cleanup(); // React Testing Library 정리
  });
  
  test('3초 후 메시지 표시', () => {
    render(<Timer />);
    
    act(() => {
      vi.advanceTimersByTime(3000);
    });
    
    expect(screen.getByText('시간 종료!')).toBeInTheDocument();
  });
});

특히 타이머나 네트워크 요청을 mock했다면 꼭 정리해줘야 해요. 안 그러면 다른 테스트에서 이상한 버그가 생길 수 있거든요.

⚠️ 주의사항

테스트 간 독립성은 정말 중요해요. 한 테스트가 다른 테스트에 영향을 주면 디버깅이 엄청 어려워지거든요. afterEach에서 제대로 정리하는 습관을 들이세요!

? 테스트 구조화하기

테스트 파일이 길어지면 관리가 힘들어져요. describe 블록으로 잘 구조화하면 훨씬 보기 좋아요.

? 구조화된 테스트
describe('UserProfile 컴포넌트', () => {
  describe('렌더링', () => {
    test('사용자 정보가 없을 때 로딩 표시', () => {
      // ...
    });
    
    test('사용자 정보가 있을 때 프로필 표시', () => {
      // ...
    });
  });
  
  describe('편집 기능', () => {
    test('편집 버튼 클릭 시 편집 모드 진입', () => {
      // ...
    });
    
    test('저장 버튼 클릭 시 API 호출', () => {
      // ...
    });
  });
  
  describe('에러 처리', () => {
    test('API 실패 시 에러 메시지 표시', () => {
      // ...
    });
  });
});

이렇게 기능별로 그룹화하면 나중에 특정 기능 테스트만 찾기도 쉽고, 실패한 테스트를 파악하기도 편해요. 진짜 깔끔하죠?

자, 이제 Vitest로 React 컴포넌트 테스트를 작성할 때 지켜야 할 베스트 프랙티스를 다 알아봤어요. 처음엔 귀찮아 보일 수 있는데요, 이런 원칙들을 따르면 장기적으로 엄청난 시간을 절약할 수 있어요. 2026년 현재 많은 개발팀들이 이런 방식으로 테스트를 작성하고 있거든요. 여러분도 한번 적용해보세요!


❓ 자주 묻는 질문

Vitest와 Jest를 같이 쓸 수 있나요? 기존 Jest 프로젝트에 Vitest 추가해도 되나요?

네, 가능해요! 실제로 제가 한 프로젝트에서 Jest 테스트를 단계적으로 Vitest로 마이그레이션했었거든요. vitest.config.ts에서 test.include 옵션으로 특정 폴더만 Vitest로 돌릴 수 있어요. 예를 들어 src/components/**/*.test.tsx는 Vitest로, 나머지는 Jest로 돌리는 식이죠. 다만 두 개의 설정 파일을 관리해야 하고 CI/CD 파이프라인에서도 두 번 테스트를 돌려야 해서 좀 복잡하긴 해요. 그래서 보통은 한 번에 전환하는 걸 추천하지만, 대규모 프로젝트라면 점진적 마이그레이션도 괜찮은 전략이에요.

React 18의 Suspense나 useTransition 같은 기능도 Vitest로 테스트할 수 있나요?

완전 가능해요! Testing Library의 @testing-library/react가 React 18을 완벽하게 지원하거든요. Suspense는 waitFor이나 findBy 쿼리로 로딩 상태와 로드된 콘텐츠를 순차적으로 테스트하면 되고요. useTransition은 act() 함수로 감싸서 pending 상태를 확인할 수 있어요. 제가 2026년 초에 작업한 프로젝트에서 Suspense로 데이터 페칭하는 컴포넌트를 Vitest로 테스트했는데, MSW로 API 모킹하고 로딩 스피너 → 실제 데이터 렌더링 순서를 검증하니까 완벽하게 작동하더라고요. React Testing Library 공식 문서에 React 18 관련 예제도 잘 나와있으니까 참고해보세요!

테스트 커버리지가 80% 이상이면 괜찮은 건가요? 몇 퍼센트가 적당할까요?

솔직히 말하자면 커버리지 숫자 자체는 그렇게 중요하지 않아요. 80%를 채우려고 의미 없는 테스트를 억지로 작성하는 것보다, 핵심 비즈니스 로직과 사용자 플로우를 제대로 테스트하는 게 훨씬 중요하거든요. 제가 실무에서는 보통 이렇게 접근해요. 결제, 로그인, 데이터 제출 같은 크리티컬한 기능은 100% 커버하고, 단순 UI 컴포넌트는 주요 인터랙션 위주로만 테스트해요. Vitest의 --coverage.thresholds 옵션으로 폴더별로 다른 기준을 설정할 수도 있어요. 예를 들어 utils/ 폴더는 90%, components/는 70% 이런 식으로요. 커버리지 리포트에서 빨간색으로 표시된 분기(branch)를 보면서 "이 케이스가 실제로 중요한가?"를 먼저 판단해보세요.

Next.js App Router 환경에서 Server Component는 어떻게 테스트하나요?

Server Component 테스트는 진짜 까다로워요. 근데 좋은 소식은, 대부분의 경우 Server Component를 직접 테스트할 필요가 없다는 거예요! Server Component는 주로 데이터 페칭이랑 렌더링만 하잖아요? 그 부분은 E2E 테스트(Playwright)로 커버하는 게 더 효과적이에요. 대신 Server Component가 사용하는 데이터 fetch 함수나 로직 유틸을 분리해서 단위 테스트하는 거죠. 그리고 Client Component('use client')로 만든 부분들은 Vitest로 테스트하면 돼요. 제가 2026년에 진행한 Next.js 14 프로젝트에서도 이렇게 접근했는데, 서버 로직은 순수 함수로 빼내서 테스트하고, 클라이언트 인터랙션은 Vitest + Testing Library로 커버했어요.

CI/CD 파이프라인에서 테스트가 너무 오래 걸려요. Vitest로도 빨라지나요?

확실히 빨라지긴 하는데, 가장 큰 차이는 병렬 실행에서 나요! Vitest는 기본적으로 모든 테스트를 병렬로 돌리거든요. GitHub Actions에서 테스트하신다면 --threads=false 옵션을 빼고 실행해보세요. CPU 코어를 최대한 활용해서 훨씬 빨라져요. 그리고 --changed 옵션을 쓰면 변경된 파일과 관련된 테스트만 실행할 수도 있어요. 제가 관리하는 프로젝트에서는 PR 빌드 때는 --changed 모드로 빠르게 돌리고, main 브랜치 머지 전에는 전체 테스트를 실행하는 식으로 운영해요. 그리고 Vitest UI 대신 --reporter=json으로 결과만 받으면 더 빨라지더라고요. 추가로 캐시 설정도 중요한데, node_modules/.vitest 폴더를 CI 캐시에 포함시키면 다음 빌드에서 더 빨라져요!

모킹(mocking)을 너무 많이 쓰면 안 좋다는데, 언제까지 모킹해야 하나요?

진짜 좋은 질문이에요! 저도 이 부분 때문에 고민 많이 했거든요. 기본 원칙은 이거예요. "내가 테스트하려는 게 아닌 것들만 모킹하라". 예를 들어 버튼 클릭 테스트를 한다면 실제 클릭 이벤트는 모킹하면 안 되고, 대신 그 버튼이 호출하는 API는 모킹하는 거죠. React 컴포넌트 테스트에서는 외부 의존성(API, localStorage, 타이머)만 모킹하고, 컴포넌트 내부 로직은 실제로 실행되게 해야 해요. Vitest의 vi.mock()보다는 MSW(Mock Service Worker)를 쓰는 걸 추천해요. API 레벨에서 모킹하면 컴포넌트는 실제 코드 그대로 돌아가거든요. 그리고 유틸 함수 같은 건 모킹 없이 실제로 테스트하는 게 제일 좋아요. 모킹이 너무 많으면 "테스트는 통과하는데 실제로는 안 돌아가는" 상황이 생길 수 있어요!


✨ 마무리하며

여기까지 2026년 기준으로 Vitest를 활용한 React 컴포넌트 테스트 방법을 전부 다뤄봤어요. 솔직히 처음에는 "또 새로운 도구를 배워야 하나" 싶을 수 있는데, 막상 써보면 Jest보다 설정도 간단하고 속도도 훨씬 빨라서 금방 익숙해지실 거예요.

가장 중요한 건 완벽한 테스트 커버리지보다, 사용자 관점에서 중요한 기능을 제대로 테스트하는 것이라는 점 기억해주세요. 저도 여전히 배우고 있고, 프로젝트마다 다른 접근 방식을 시도하면서 개선하고 있거든요.

이 가이드가 여러분의 React 프로젝트에 Vitest를 도입하는 데 도움이 됐으면 좋겠네요. 궁금한 점이나 막히는 부분 있으면 댓글로 남겨주세요. 같이 고민해볼게요! 그럼 이제 한번 실제 프로젝트에 적용해보세요. 생각보다 어렵지 않을 거예요. 화이팅! ?

#Vitest #React 테스트 #컴포넌트 테스트 #React Testing Library #프론트엔드 테스트 #Jest 대체 #단위 테스트 #통합 테스트 #Vite #테스트 자동화

이 글 공유하기

Twitter Facebook

댓글 0개

첫 번째 댓글을 남겨보세요!

관련 글