본문으로 건너뛰기

Vitest + MSW로 React 통합 테스트 설계하기: 유닛 테스트를 넘어 실사용 시나리오를 검증하는 법

Development32분 읽기본문 16,243

핵심 요약

이 글에서 확인할 내용

유닛 테스트만으로는 사용자의 불만을 막을 수 없다 프론트엔드 엔지니어들은 한 번쯤 이런 경험을 합니다. 개별 함수의 유닛 테스트는 모두 통과했는데, 정작 사용자가 폼을 제출하면 로딩 스피너가 사라지지 않거나, 에러 메시지가 엉뚱한 위치에 뜨는 현상입니다.

  • 1. 유닛 테스트 vs 통합 테스트
  • 2. Vitest를 선택한 이유
  • 3. MSW v2 핸들러 작성법

Vitest MSW React integration testing real user scenario

유닛 테스트만으로는 사용자의 불만을 막을 수 없다

프론트엔드 엔지니어들은 한 번쯤 이런 경험을 합니다. 개별 함수의 유닛 테스트는 모두 통과했는데, 정작 사용자가 폼을 제출하면 로딩 스피너가 사라지지 않거나, 에러 메시지가 엉뚱한 위치에 뜨는 현상입니다.

이 글은 Vitest와 MSW v2, React Testing Library를 결합해 실사용 시나리오를 통합 테스트로 검증하는 실전 전략을 다룹니다. TDD 철학에 관심 있다면 TDD로 프론트엔드 개발하기도 함께 읽어보시길 권합니다.

통합 테스트의 목표는 "많이 테스트했다"는 숫자를 만드는 것이 아니라, 사용자에게 보이는 기능 경계가 실제 의존성과 함께 올바르게 동작하는지 확인하는 것입니다. 예를 들어 상품 목록 화면이라면 렌더링 함수 하나가 아니라 API 호출, 로딩 상태, 빈 결과, 서버 오류, 재시도 버튼, 캐시 무효화 이후 화면 갱신까지 하나의 흐름으로 봐야 합니다. 이 범위를 명확히 정하지 않으면 통합 테스트는 느린 유닛 테스트가 되거나, 반대로 너무 많은 시스템을 끌어들여 원인 분석이 어려운 E2E 테스트가 됩니다.

유닛 테스트가 통과했는데 화면이 깨지는 근본 원인은, 유닛 테스트가 "부품 하나하나가 스펙대로 동작하는가"만 검증하기 때문입니다. 부품이 모두 정상이어도 조립 과정에서 문제가 생깁니다. 로딩 플래그를 내리는 setState가 에러 분기에서 호출되지 않거나, 폼 검증 훅은 통과했지만 서버가 돌려준 필드별 에러를 화면에 매핑하는 코드가 빠져 있거나, 데이터 패칭 라이브러리의 캐시 키가 잘못 걸려 뮤테이션 이후에도 이전 목록이 그대로 남는 식입니다. 이런 버그는 함수 단위로는 절대 드러나지 않고, 여러 부품이 실제 비동기 타이밍 속에서 상호작용할 때만 나타납니다. 통합 테스트는 바로 그 조립된 상태를 검증하는 도구입니다.

실무에서는 테스트 피라미드를 고정된 비율로 외우기보다 장애 비용을 기준으로 나누는 편이 낫습니다. 계산 로직처럼 입력과 출력이 선명한 영역은 유닛 테스트로 빠르게 보호하고, 폼 제출·검색·필터·권한 분기처럼 사용자 행동과 네트워크 응답이 결합되는 영역은 통합 테스트로 보호합니다. 브라우저 호환성, 실제 라우팅, 배포 산출물 검증은 Playwright 같은 E2E 테스트로 남기는 것이 유지보수 비용을 줄입니다. E2E까지 포함한 파이프라인 설계는 Playwright 비주얼 회귀 테스트에서 더 자세히 다룹니다.

MSW를 쓰는 이유도 단순히 fetch를 가짜로 만들기 위해서가 아닙니다. API 계약을 테스트 코드 안에서 명시하고, 서버가 보낼 수 있는 정상·오류·지연 응답을 UI가 어떻게 받아들이는지 반복 검증하기 위해서입니다. 핸들러가 도메인별로 정리되어 있으면 백엔드 계약이 바뀌었을 때 어떤 화면 테스트가 함께 수정되어야 하는지도 더 빨리 드러납니다.

한 가지 오해를 먼저 짚고 넘어가겠습니다. 통합 테스트를 도입한다고 유닛 테스트를 버리는 것이 아닙니다. 두 층은 잡아내는 결함의 종류가 다릅니다. 통화 포맷 함수, 할인율 계산, 날짜 파싱처럼 경우의 수가 많고 순수한 로직은 여전히 유닛 테스트가 압도적으로 효율적입니다. 이런 로직을 통합 테스트로 검증하려 하면, 특정 계산 분기 하나를 확인하기 위해 컴포넌트를 렌더링하고 네트워크를 모킹하고 비동기를 기다리는 무거운 절차를 밟아야 합니다. 층을 잘못 고르면 테스트는 느려지고 실패 메시지는 모호해집니다. 핵심은 "어떤 층이 이 결함을 가장 싸게 잡는가"를 매번 판단하는 것입니다.


1. 유닛 테스트 vs 통합 테스트

구분유닛 테스트통합 테스트
검증 대상함수/훅/순수 컴포넌트컴포넌트 조합 + API 흐름
의존성 처리완전 모킹네트워크 레이어만 인터셉트
실행 속도매우 빠름빠름
버그 검출 범위로직 오류통합 오류, 상태 전이 오류
유지보수 비용구현 변경 시 깨지기 쉬움인터페이스 기반, 상대적으로 안정적

React Testing Library 공식 철학은 명확히 밝히고 있습니다. "The more your tests resemble the way your software is used, the more confidence they can give you."

이 문장이 실무에서 갖는 함의는 생각보다 큽니다. 테스트가 사용자의 실제 사용 방식을 닮을수록, 리팩터링에 강해집니다. 내부 상태 변수 이름을 바꾸거나, useReduceruseState로 갈아끼우거나, 컴포넌트를 두 개로 쪼개도 사용자가 보는 결과가 같다면 통합 테스트는 깨지지 않습니다. 반대로 구현 세부사항에 결합된 유닛 테스트는 이런 변경마다 무더기로 빨간불이 켜집니다. 테스트가 리팩터링을 막는 족쇄가 되는 순간, 팀은 테스트를 지우거나 방치하기 시작합니다. 통합 테스트의 안정성은 단순한 편의가 아니라, 테스트 자산이 오래 살아남게 하는 조건입니다.

경계를 어떻게 그을지 감을 잡는 실용적 기준을 하나 제시합니다. "이 코드가 잘못되면 사용자가 무엇을 보는가"를 먼저 상상해 보십시오. 답이 "잘못된 숫자를 본다"처럼 값 하나로 환원되면 유닛 테스트가 적합합니다. 답이 "버튼을 눌렀는데 아무 반응이 없다", "에러가 났는데 로딩만 계속 돈다"처럼 상태 전이와 비동기가 얽히면 통합 테스트가 적합합니다. 그리고 "다른 브라우저에서만 깨진다", "실제 결제창이 안 뜬다"처럼 실제 런타임과 서드파티가 필요하면 E2E의 영역입니다.

1.1 피라미드인가 트로피인가

전통적인 테스트 피라미드는 유닛 테스트를 넓은 바닥에, E2E를 좁은 꼭대기에 두라고 말합니다. 하지만 컴포넌트 기반 프론트엔드에서는 Kent C. Dodds가 제안한 "테스팅 트로피(Testing Trophy)" 모델이 더 현실을 잘 설명합니다. 트로피는 정적 분석(타입 체크·린트)을 바닥에 깔고, 그 위에 유닛, 가장 넓은 중간층에 통합, 꼭대기에 E2E를 둡니다. 프론트엔드에서 결함이 가장 자주 숨는 곳이 바로 이 통합 층이기 때문입니다. 값 계산보다 "상태와 네트워크와 렌더링이 만나는 지점"에서 버그가 훨씬 많이 발생한다는 경험칙이 이 모델의 근거입니다.

다만 트로피든 피라미드든 비율 자체를 목표로 삼으면 안 됩니다. "통합 테스트 60%"라는 숫자를 채우려고 테스트를 억지로 만들면, 커버리지는 오르지만 신뢰도는 오르지 않습니다. 모델은 어디에 투자를 집중할지 방향을 알려주는 나침반이지, 채워야 할 할당량이 아닙니다.


2. Vitest를 선택한 이유

Vitest 공식 문서에서는 "Vitest aims to position itself as the Test Runner of choice for Vite projects"라고 명시합니다.

// vitest.setup.ts
import '@testing-library/jest-dom';
import { cleanup } from '@testing-library/react';
import { afterEach, beforeAll, afterAll, vi } from 'vitest';
import { server } from './src/mocks/server';

afterEach(() => {
  cleanup();
});

beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'path';

export default defineConfig({
  plugins: [react()],
  resolve: {
    alias: {
      '@': path.resolve(__dirname, './src'),
    },
  },
  test: {
    environment: 'jsdom',
    globals: true,
    setupFiles: ['./vitest.setup.ts'],
    coverage: {
      provider: 'v8',
      reporter: ['text', 'lcov'],
    },
    pool: 'threads',
  },
});

onUnhandledRequest: 'error' 옵션은 특히 중요합니다. 핸들러에 등록되지 않은 API 요청이 발생하면 테스트를 즉시 실패시킵니다.

이 옵션을 켜지 않으면 통합 테스트는 조용히 거짓말을 시작합니다. 컴포넌트가 예상과 다른 엔드포인트를 호출해도 요청이 그냥 흘러가 버리고, 화면에는 빈 상태나 로딩이 남으며, 테스트는 그 빈 상태를 정상으로 착각한 채 통과합니다. 모든 요청이 반드시 명시적 핸들러를 거치도록 강제하면, 계약이 어긋나는 순간 곧바로 실패로 드러납니다. 테스트의 가치는 "통과했다"가 아니라 "잘못되면 실패한다"에서 나오는데, 이 옵션이 바로 그 실패 조건을 보장합니다.

2.1 왜 Jest가 아니라 Vitest인가

Vitest를 고르는 가장 실질적인 이유는 설정 이중화의 제거입니다. Vite로 빌드하는 프로젝트는 이미 별칭(alias), 환경 변수, JSX 변환, PostCSS 같은 파이프라인을 Vite 설정에 정의해 두었습니다. Jest를 쓰면 이 파이프라인을 Babel과 moduleNameMapper로 다시 한 번 기술해야 하고, 두 설정이 미묘하게 어긋나면 "앱은 되는데 테스트만 깨지는" 유령 같은 문제가 생깁니다. Vitest는 동일한 Vite 변환 파이프라인 위에서 돌기 때문에, 프로덕션 빌드가 보는 모듈 그래프와 테스트가 보는 모듈 그래프가 일치합니다.

기술적으로 더 중요한 차이는 ESM 처리입니다. 최신 라이브러리들이 점점 ESM 전용으로 배포되면서, CommonJS 기반 트랜스폼에 의존하는 러너는 transformIgnorePatterns를 손보며 씨름하게 됩니다. Vitest는 Vite의 네이티브 ESM 처리와 esbuild 기반 변환을 그대로 활용하므로 이 마찰이 크게 줄어듭니다. vijest API와 거의 호환되어 마이그레이션 비용도 낮습니다.

2.2 jsdom과 pool 옵션의 트레이드오프

environment 옵션은 테스트가 어떤 DOM 구현 위에서 도는지를 결정합니다. jsdom은 사실상 표준이고 호환성이 가장 넓지만 상대적으로 무겁습니다. happy-dom은 더 가볍고 빠른 대신 일부 브라우저 API 구현이 얕아, 드물게 실제 브라우저와 다르게 동작할 수 있습니다. 대다수 프로젝트는 안정성을 위해 jsdom으로 시작하고, 스위트가 커져 속도가 문제될 때 happy-dom을 실험하는 순서가 안전합니다.

pool 옵션은 테스트 파일을 어떤 격리 단위로 실행할지 정합니다. threads는 워커 스레드 기반으로 기동이 빠르고, forks는 자식 프로세스 기반으로 격리가 더 강합니다. 전역 상태를 오염시키거나 네이티브 모듈을 다루는 테스트가 있으면 forks가 안전한 선택입니다. 여기에 더해 isolate 옵션이 각 테스트 파일마다 모듈 레지스트리를 새로 만들지를 제어하는데, 이 값을 끄면 속도는 오르지만 파일 간 상태 누수 위험이 커집니다. 이 트레이드오프는 뒤의 CI 최적화 절에서 다시 다룹니다.


3. MSW v2 핸들러 작성법

MSW v2는 v1 대비 API가 크게 바뀌었습니다. rest.get() 대신 http.get()을 사용합니다. MSW v2 공식 문서

import { http, HttpResponse, delay } from 'msw';

const MOCK_PRODUCTS = [
  { id: 'prod-001', name: '무선 키보드', price: 89000, stock: 42 },
  { id: 'prod-002', name: '기계식 마우스', price: 65000, stock: 0 },
];

export const productHandlers = [
  http.get('/api/products', async ({ request }) => {
    const url = new URL(request.url);
    const category = url.searchParams.get('category');

    await delay(50);

    const filtered = category
      ? MOCK_PRODUCTS.filter(p => p.id.startsWith(category))
      : MOCK_PRODUCTS;

    return HttpResponse.json({ data: filtered, total: filtered.length });
  }),

  http.get('/api/products/:productId', async ({ params }) => {
    const { productId } = params;
    const product = MOCK_PRODUCTS.find(p => p.id === productId);

    if (!product) {
      return HttpResponse.json(
        { code: 'PRODUCT_NOT_FOUND', message: '상품을 찾을 수 없습니다.' },
        { status: 404 }
      );
    }

    return HttpResponse.json({ data: product });
  }),

  http.post('/api/products', async ({ request }) => {
    const body = await request.json() as any;

    if (!body.name || !body.price) {
      return HttpResponse.json(
        { code: 'VALIDATION_ERROR', message: '필수 항목을 입력해주세요.' },
        { status: 400 }
      );
    }

    return HttpResponse.json(
      { data: { id: `prod-${Date.now()}`, ...body } },
      { status: 201 }
    );
  }),
];

// src/mocks/server.ts
import { setupServer } from 'msw/node';
export const server = setupServer(...productHandlers);

3.1 네트워크 계층 모킹의 원리

MSW의 이름은 Mock Service Worker의 약자지만, 정확히 이해해야 할 지점은 "모킹하는 계층"입니다. vi.mock('axios')처럼 모듈을 통째로 바꾸는 방식은 애플리케이션 코드의 특정 import를 가짜 구현으로 교체합니다. 이 방식은 fetch 클라이언트를 바꾸면 테스트도 함께 고쳐야 하고, 클라이언트 내부의 인터셉터·헤더·에러 변환 로직이 실제로 도는지는 검증하지 못합니다. 모킹 지점이 애플리케이션과 너무 가까워서, 정작 실제 코드의 상당 부분을 건너뛰게 되는 것입니다.

MSW는 훨씬 바깥, 즉 네트워크 경계에서 요청을 가로챕니다. 브라우저 환경에서는 실제 서비스 워커가 나가는 요청을 인터셉트하고, Node 환경에서는 @mswjs/interceptorsglobalThis.fetchhttp/https 모듈, XMLHttpRequest에 패치를 걸어 요청을 낚아챕니다. 애플리케이션 입장에서는 진짜 네트워크 요청을 보낸 것과 구분되지 않습니다. 덕분에 fetch 래퍼, 재시도 로직, 응답 파서, 에러 매핑까지 실제 코드 경로가 모두 실행되고, 우리는 오직 "서버가 이런 응답을 준다면"이라는 계약만 선언하면 됩니다. 이것이 모듈 모킹과 네트워크 모킹의 결정적 차이이며, 통합 테스트가 "실사용에 가깝다"고 말할 수 있는 근거입니다.

3.2 핸들러를 도메인으로 나누는 이유

핸들러를 한 파일에 모두 몰아넣으면 초기에는 편하지만, 화면이 늘수록 어떤 테스트가 어떤 계약에 의존하는지 추적하기 어려워집니다. productHandlers, authHandlers, orderHandlers처럼 도메인 단위로 파일을 쪼개고, server.ts에서 스프레드로 합치는 구조를 권합니다. 이렇게 나누면 백엔드가 상품 응답 스키마를 바꿨을 때 productHandlers만 손대면 되고, 그 변경으로 깨지는 테스트가 곧 "이 계약에 의존하는 화면 목록"이 됩니다. 핸들러 파일은 사실상 프론트엔드가 이해하는 API 계약의 실행 가능한 문서 역할을 합니다. 이 관점은 백엔드 스키마 진화를 다루는 Kafka 스키마 레지스트리 운영기의 호환성 사고방식과도 통합니다.

기본 핸들러는 "가장 흔한 정상 응답"만 담고, 오류·지연·엣지 케이스는 개별 테스트에서 server.use()로 덮어쓰는 원칙을 지키는 것이 좋습니다. 기본 핸들러에 온갖 분기를 넣기 시작하면 핸들러가 또 하나의 미니 백엔드가 되어 유지보수 대상이 두 배로 늘어납니다.


4. React Testing Library와 결합

쿼리 우선순위:

  1. getByRole — 접근성 역할 기반
  2. getByLabelText — 폼 필드
  3. getByPlaceholderText
  4. getByText
  5. getByTestId — 마지막 수단

userEventfireEvent보다 더 현실적인 사용자 인터랙션을 시뮬레이션합니다.

이 우선순위는 단순한 스타일 가이드가 아니라 접근성 검증을 테스트에 녹이는 장치입니다. getByRole('button', { name: '상품 등록' })로 요소를 찾는다는 것은, 그 요소가 실제로 버튼 역할과 접근 가능한 이름을 갖고 있다는 뜻입니다. 만약 개발자가 divonClick만 달아 버튼처럼 보이게 만들었다면 이 쿼리는 실패하고, 우리는 스크린 리더 사용자가 그 버튼을 인식하지 못한다는 사실을 테스트 단계에서 알게 됩니다. 반대로 getByTestId로 모든 요소를 찾으면 마크업이 접근성 측면에서 엉망이어도 테스트는 초록불을 유지합니다. 쿼리 우선순위를 지키는 것만으로도 접근성 회귀를 상당 부분 예방할 수 있습니다.

userEventfireEvent보다 나은 이유는 한 번의 사용자 행동을 여러 개의 실제 DOM 이벤트 시퀀스로 풀어내기 때문입니다. 사용자가 텍스트를 입력하면 실제로는 포커스, keydown, keypress, input, keyup이 문자마다 연쇄적으로 발생합니다. fireEvent.change는 이 과정을 건너뛰고 값만 한 번에 바꿔치기하므로, 키 입력마다 검증을 트리거하는 폼이나 IME 조합, maxLength 처리 같은 현실적 동작을 재현하지 못합니다. userEvent는 이 시퀀스를 충실히 흉내 내므로, 실제 사용자가 겪는 문제를 테스트가 먼저 겪습니다. 다만 userEvent의 API는 v14부터 전부 비동기이므로 await를 반드시 붙여야 하고, 이를 빠뜨리면 뒤에서 설명할 플래키 테스트의 흔한 원인이 됩니다.


5. 비동기 쿼리 대기 전략

findBy*getBy*의 비동기 버전입니다. 기본 타임아웃은 1000ms입니다.

const productItem = await screen.findByText('무선 키보드');
expect(productItem).toBeInTheDocument();

waitFor 는 콜백 안의 assertion이 통과할 때까지 폴링합니다.

await waitFor(() => {
  expect(screen.queryByRole('status', { name: '로딩 중' })).not.toBeInTheDocument();
  expect(screen.getByText('무선 키보드')).toBeInTheDocument();
});

waitFor 안에서 await를 사용하면 첫 번째 assertion이 통과한 순간 콜백이 끝나므로 사용하지 않습니다.

에러 바운더리나 Suspense 폴백을 테스트할 때는 React Error Boundary와 Suspense 폴백 패턴을 함께 참고하세요.

5.1 findBy, waitFor, waitForElementToBeRemoved의 역할 구분

세 가지 대기 도구는 겨냥하는 상황이 다릅니다. findBy*는 "곧 나타날 요소 하나를 기다린다"는 의도가 명확할 때 씁니다. 내부적으로 waitForgetBy*를 조합한 것이라, 요소가 나타나면 그 요소를 반환하므로 코드가 가장 간결합니다. waitFor는 "어떤 조건이 참이 될 때까지 기다린다"는 더 일반적인 상황에 씁니다. 요소의 등장뿐 아니라 사라짐, 텍스트 변경, 함수 호출 여부처럼 임의의 assertion을 폴링할 수 있습니다. "요소가 사라지는 것"을 명시적으로 기다릴 때는 waitForElementToBeRemoved가 의도를 가장 또렷하게 드러냅니다. 로딩 스피너가 걷힌 다음 결과가 뜨는 흐름을 검증할 때 특히 유용합니다.

waitFor 콜백을 작성할 때 지켜야 할 규칙이 몇 가지 있습니다. 첫째, 콜백 안에는 부수 효과 없이 assertion만 넣어야 합니다. 콜백은 조건이 만족될 때까지 여러 번 재실행되므로, 그 안에서 userEvent.click 같은 행동을 하면 클릭이 여러 번 발생할 수 있습니다. 둘째, 콜백 안에서 여러 assertion을 넣더라도 서로 관련된 최종 상태를 함께 검증하는 용도로만 씁니다. 셋째, 원문이 지적하듯 콜백 안에서 await를 쓰면 첫 assertion이 통과하는 순간 폴링이 종료되어 나머지 조건을 놓칠 수 있으므로 피합니다.

5.2 act 경고와 가짜 타이머

콘솔에 뜨는 "An update to Component inside a test was not wrapped in act(...)" 경고는 대개 컴포넌트가 테스트가 끝난 뒤에도 상태를 갱신하려 했다는 신호입니다. 비동기 요청이 완료되며 setState를 호출했는데, 테스트는 그 결과를 기다리지 않고 이미 끝나 버린 경우입니다. 이 경고는 무시하면 안 됩니다. 지금은 통과해도 타이밍이 조금만 달라지면 실패하는 잠재적 플래키 테스트라는 뜻이기 때문입니다. 해법은 대개 명시적으로 findBy*waitFor로 비동기 완료를 기다려 주는 것입니다.

디바운스 검색이나 토스트 자동 소멸처럼 타이머에 의존하는 UI는 vi.useFakeTimers()로 시간을 우리가 통제해야 합니다. 다만 가짜 타이머와 userEvent를 함께 쓸 때는 주의가 필요합니다. userEvent.setup({ advanceTimers: vi.advanceTimersByTime })처럼 두 시스템을 연결해 주지 않으면, 사용자 이벤트 내부의 지연이 가짜 시계와 어긋나 테스트가 멈춰 버립니다. 실제 시간에 의존하는 delay를 테스트가 진짜로 기다리게 두면 스위트가 느려지므로, 시간 자체가 검증 대상인 경우에는 가짜 타이머로, 그렇지 않은 경우에는 MSW 핸들러의 지연을 최소화하는 방향으로 접근합니다.


6. 에러 시나리오 모킹

import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { server } from '@/mocks/server';
import { ProductList } from '../ProductList';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';

function renderWithProviders(ui: React.ReactElement) {
  const queryClient = new QueryClient({
    defaultOptions: {
      queries: { retry: false, gcTime: 0 },
    },
  });

  return render(
    <QueryClientProvider client={queryClient}>
      {ui}
    </QueryClientProvider>
  );
}

describe('ProductList 통합 테스트', () => {
  it('상품 목록을 성공적으로 불러와 렌더링한다', async () => {
    renderWithProviders(<ProductList />);

    expect(screen.getByRole('status', { name: '로딩 중' })).toBeInTheDocument();

    await screen.findByText('무선 키보드');
    expect(screen.getByText('기계식 마우스')).toBeInTheDocument();
    expect(screen.queryByRole('status', { name: '로딩 중' })).not.toBeInTheDocument();
  });

  it('API 500 에러 시 에러 메시지를 표시한다', async () => {
    server.use(
      http.get('/api/products', () => {
        return HttpResponse.json(
          { code: 'INTERNAL_ERROR', message: '서버 오류' },
          { status: 500 }
        );
      })
    );

    renderWithProviders(<ProductList />);

    await waitFor(() => {
      expect(screen.getByRole('alert')).toHaveTextContent('잠시 후 다시 시도해주세요');
    });
  });

  it('네트워크 연결 실패 시 오프라인 안내를 표시한다', async () => {
    server.use(
      http.get('/api/products', () => {
        return HttpResponse.error();
      })
    );

    renderWithProviders(<ProductList />);
    await screen.findByText('네트워크 연결을 확인해주세요');
  });
});

QueryClient를 테스트마다 새로 생성하는 것은 필수입니다. retry: false 설정도 잊지 말아야 합니다.

이 두 설정이 왜 필수인지 짚어야 합니다. QueryClient를 테스트 파일 최상단에서 한 번만 만들어 공유하면, 앞선 테스트가 채운 캐시가 다음 테스트로 새어 나갑니다. 첫 테스트에서 성공 응답을 받아 캐시된 상품 목록이, 500 에러를 검증해야 할 다음 테스트에서 그대로 렌더링되어 에러 화면을 가려 버립니다. 결과적으로 테스트가 실행 순서에 의존하게 되고, 단독으로 돌리면 통과하지만 전체로 돌리면 실패하는 최악의 플래키 패턴이 만들어집니다. 테스트마다 새 인스턴스를 만드는 것은 완전한 격리를 보장하는 가장 확실한 방법입니다.

retry: false가 필요한 이유는 데이터 패칭 라이브러리의 기본 재시도 정책 때문입니다. 프로덕션에서는 일시적 오류를 흡수하기 위해 실패한 쿼리를 여러 번 재시도하는 것이 바람직하지만, 테스트에서는 이 재시도가 지수 백오프와 함께 수 초의 대기를 만들어 냅니다. 결국 에러 상태를 검증하려는 테스트가 재시도를 다 소진할 때까지 기다리다 타임아웃으로 실패합니다. 테스트에서는 재시도를 꺼서 첫 실패가 곧바로 에러 상태로 이어지게 해야 합니다. 재시도 로직 자체를 검증하고 싶다면 그것은 별도의 전용 테스트로 분리하는 편이 낫습니다. 재시도·타임아웃·서킷 브레이커 같은 회복탄력성 패턴의 설계 원칙은 API 회복탄력성 설계에서 별도로 다룹니다.

HttpResponse.error()status: 500 응답은 서로 다른 실패를 재현한다는 점도 중요합니다. 전자는 TCP 연결 자체가 끊긴 네트워크 오류, 즉 fetch가 TypeError를 던지는 상황이고, 후자는 서버가 정상적으로 응답했지만 오류 상태 코드를 돌려준 상황입니다. 애플리케이션의 에러 처리 코드는 이 둘을 다르게 다뤄야 하는 경우가 많습니다. 오프라인 안내와 "서버 오류, 잠시 후 재시도" 안내는 사용자에게 주는 메시지가 다르기 때문입니다. 두 경로를 각각 별도 테스트로 남겨 두면, 나중에 에러 처리 코드를 리팩터링할 때 어느 한쪽을 빠뜨리는 실수를 잡아낼 수 있습니다.


본문 중간 핵심 흐름을 시각화한 Development 일러스트

7. 폼 제출 전체 흐름 테스트

describe('ProductCreateForm 통합 테스트', () => {
  const user = userEvent.setup();

  async function fillAndSubmitForm(overrides = {}) {
    const name = overrides.name ?? '테스트 상품';
    const price = overrides.price ?? '50000';

    await user.type(screen.getByLabelText('상품명'), name);
    await user.type(screen.getByLabelText('판매가'), price);
    await user.click(screen.getByRole('button', { name: '상품 등록' }));
  }

  it('필수 항목을 모두 입력하고 제출하면 성공 토스트가 표시된다', async () => {
    render(<ProductCreateForm />);
    await fillAndSubmitForm();

    expect(screen.getByRole('button', { name: '등록 중...' })).toBeDisabled();

    await screen.findByText('상품이 등록되었습니다.');

    await waitFor(() => {
      expect(screen.getByLabelText('상품명')).toHaveValue('');
    });
  });

  it('서버 400 유효성 검증 에러를 폼 필드 옆에 인라인으로 표시한다', async () => {
    server.use(
      http.post('/api/products', () => {
        return HttpResponse.json(
          {
            code: 'VALIDATION_ERROR',
            errors: [{ field: 'price', message: '판매가는 100원 이상이어야 합니다.' }],
          },
          { status: 400 }
        );
      })
    );

    render(<ProductCreateForm />);
    await fillAndSubmitForm({ price: '10' });

    const priceFieldGroup = screen.getByRole('group', { name: '판매가' });
    await waitFor(() => {
      expect(within(priceFieldGroup).getByRole('alert')).toHaveTextContent(
        '판매가는 100원 이상이어야 합니다.'
      );
    });
  });
});

폼 테스트에서 놓치기 쉬운 것이 "제출 중" 상태의 검증입니다. 위 테스트가 등록 중... 버튼이 disabled인지 확인하는 부분은 단순한 장식이 아닙니다. 제출 버튼을 비활성화하지 않으면 사용자가 빠르게 두 번 눌러 같은 상품을 중복 등록할 수 있습니다. 이 중복 제출 버그는 유닛 테스트로는 절대 드러나지 않고, 실제 비동기 제출이 진행 중인 시점을 관찰하는 통합 테스트에서만 잡힙니다. 로딩 중 상태, 성공 후 폼 초기화, 성공 토스트 노출은 모두 사용자가 실제로 마주하는 피드백이므로 각각을 명시적으로 검증해야 합니다.

서버 검증 에러를 필드 옆에 인라인으로 매핑하는 흐름은 프론트엔드에서 가장 자주 깨지는 지점 중 하나입니다. 클라이언트 측 검증은 잘 만들어 두었는데, 정작 서버가 돌려준 errors 배열을 각 필드에 연결하는 코드가 없거나, 필드 식별자가 어긋나 엉뚱한 곳에 에러가 뜨는 일이 흔합니다. 위 테스트가 getByRole('group', { name: '판매가' })로 필드 그룹을 먼저 좁힌 뒤 그 안에서 alert를 찾는 이유가 여기 있습니다. 단순히 화면 어딘가에 에러 문구가 있는지가 아니라, "판매가 필드 옆에" 있는지를 검증해야 실제 사용자 경험을 보장할 수 있습니다. 에러 메시지에 role="alert"를 부여하면 스크린 리더가 즉시 읽어 주므로 접근성 측면에서도 올바른 마크업입니다.

within으로 검색 범위를 좁히는 기법은 목록이나 반복되는 카드처럼 같은 텍스트가 여러 번 등장하는 화면에서 특히 중요합니다. 화면 전체에서 "삭제" 버튼을 찾으면 여러 개가 잡혀 쿼리가 실패하지만, 특정 상품 카드 영역으로 범위를 좁힌 뒤 그 안에서 "삭제"를 찾으면 의도한 요소를 정확히 집어낼 수 있습니다.


8. Server-side MSW

MSW v2는 브라우저 환경뿐 아니라 Node.js 환경에서도 동일한 핸들러를 사용할 수 있습니다. MSW setupServer 문서

Vitest 환경(jsdom)은 Node.js 위에서 실행되므로, 브라우저 서비스 워커 대신 msw/node의 인터셉터가 fetchXMLHttpRequest를 가로챕니다.

Next.js App Router의 서버 컴포넌트나 API Route를 테스트할 때는 별도의 Node 전용 테스트 환경을 구성합니다.

// vitest.config.server.ts
import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    environment: 'node',
    include: ['src/**/*.server.test.ts'],
    setupFiles: ['./vitest.setup.server.ts'],
  },
});

동일한 핸들러 정의를 브라우저와 Node 양쪽에서 재사용할 수 있다는 점이 MSW의 큰 이점입니다. 개발 중에는 브라우저에서 서비스 워커로, 통합 테스트에서는 msw/node로, 심지어 스토리북이나 로컬 목업 서버에서도 같은 핸들러를 쓸 수 있습니다. 계약을 한 곳에 정의해 두고 여러 실행 환경에서 재사용하면, 개발과 테스트가 서로 다른 가짜 서버를 바라보다 어긋나는 문제를 원천적으로 막을 수 있습니다.

한 가지 주의할 점은 서버 컴포넌트 테스트의 실행 환경입니다. 서버 컴포넌트나 API Route는 브라우저 DOM이 필요 없으므로 environment: 'node'로 돌리는 것이 더 빠르고 정확합니다. jsdom 환경에서 서버 코드를 돌리면 불필요한 브라우저 전역이 끼어들어 실제 런타임과 미묘하게 달라질 수 있습니다. 위 설정처럼 .server.test.ts 같은 파일명 규칙으로 서버 테스트를 분리하고 별도 설정으로 실행하면, 클라이언트 테스트와 서버 테스트가 각자에게 맞는 환경에서 돌아갑니다. 서버 컴포넌트 아키텍처 자체가 낯설다면 React Server Components 아키텍처를 먼저 참고하면 경계 개념을 잡는 데 도움이 됩니다.

MSW의 라이프사이클 이벤트도 디버깅에 유용합니다. server.events.on('request:start', ...)로 어떤 요청이 실제로 나갔는지 로깅하면, "핸들러가 없어 테스트가 실패한다"는 상황에서 컴포넌트가 정확히 어떤 URL을 호출했는지 즉시 확인할 수 있습니다. 예상 엔드포인트와 실제 요청이 한 글자라도 다르면 이 로그가 곧바로 원인을 알려 줍니다.


9. CI에서 병렬 실행 최적화

name: Integration Tests

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        shard: [1, 2, 3, 4]

    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'pnpm'
      - run: pnpm install --frozen-lockfile
      - name: Run tests (shard ${{ matrix.shard }}/4)
        run: pnpm vitest run --shard=${{ matrix.shard }}/4

추가 최적화:

  • isolate: false 실험적 사용: 모듈 레지스트리 공유.
  • MSW 핸들러의 delay 값 최소화: 테스트에서는 delay(0).
  • --changed 플래그: PR에서 변경된 파일과 연관된 테스트만 실행.
  • coverage.all: false: 커버리지 수집 시 전체 파일을 탐색하지 않음.

샤딩은 테스트 스위트를 여러 CI 러너에 분산하는 기법입니다. --shard=1/4는 전체 테스트 파일을 네 조각으로 나눈 뒤 그중 첫 번째 조각만 실행하라는 뜻이고, GitHub Actions의 매트릭스가 네 개의 러너를 동시에 띄워 각 조각을 병렬로 처리합니다. 총 실행 시간은 대략 4분의 1로 줄어듭니다. 다만 러너 수를 무작정 늘린다고 계속 빨라지지는 않습니다. 러너마다 의존성 설치와 워밍업 비용이 고정적으로 들기 때문에, 어느 지점을 넘으면 오히려 오버헤드가 커집니다. 스위트 규모에 맞는 적정 샤드 수를 실측으로 찾는 것이 좋습니다. 여러 러너의 커버리지 리포트를 하나로 합치려면 각 샤드가 생성한 리포트를 아티팩트로 모아 병합하는 단계를 추가해야 합니다.

isolate: false는 강력하지만 위험한 최적화입니다. 파일마다 모듈 레지스트리를 새로 만드는 비용을 없애 속도를 크게 높이지만, 전역 상태가 파일 사이로 새어 나갈 수 있습니다. 앞서 강조한 테스트 격리 원칙과 정면으로 부딪히는 옵션이므로, 스위트가 이미 상태 누수 없이 깨끗하게 격리되어 있다는 확신이 있을 때만 켜야 합니다. 켠 직후에는 테스트를 무작위 순서로 여러 번 돌려 순서 의존성이 없는지 반드시 검증합니다.

9.1 플래키 테스트를 제거하는 규율

플래키 테스트는 같은 코드에서 어떤 날은 통과하고 어떤 날은 실패하는 테스트를 말합니다. 이런 테스트가 하나둘 쌓이면 팀은 빨간불을 "또 그 테스트겠지" 하며 무시하기 시작하고, 결국 진짜 회귀까지 놓치게 됩니다. 플래키 테스트를 방치하는 것은 테스트 스위트 전체의 신뢰를 갉아먹는 일이므로, 발견 즉시 격리하고 원인을 추적해야 합니다.

플래키의 원인은 대부분 몇 가지 유형으로 수렴합니다. 첫째는 격리 실패입니다. 공유된 QueryClient나 전역 목 데이터, 리셋하지 않은 핸들러 때문에 앞선 테스트가 다음 테스트에 영향을 줍니다. afterEach에서 server.resetHandlers()cleanup()을 반드시 호출하고, QueryClient는 테스트마다 새로 만들어 이 문제를 차단합니다. 둘째는 비동기 대기 누락입니다. await를 빠뜨린 userEvent, 완료를 기다리지 않은 요청이 다음 테스트로 넘어가 상태를 오염시킵니다. 앞서 나온 act 경고가 이 유형의 조기 신호입니다. 셋째는 실제 시간 의존입니다. setTimeout이나 실제 지연에 기대는 테스트는 CI 러너의 부하에 따라 타이밍이 흔들립니다. 가짜 타이머로 시간을 통제하거나 명시적 대기로 바꿔야 합니다.

플래키를 재현하는 실용적 방법은 테스트를 반복 실행하고 실행 순서를 무작위화하는 것입니다. 로컬에서는 통과하는데 CI에서만 깨진다면 대개 순서 의존성이나 타이밍 문제이므로, 순서를 섞어 여러 번 돌려 보면 숨은 결합이 드러납니다. 큰 스위트를 다룰 때는 이 규율이 특히 중요한데, 관련 워크플로는 거대한 리팩터링 PR 쪼개기에서 다룬 브랜치·검증 전략과 함께 적용하면 회귀 위험을 더 줄일 수 있습니다.


10. 테스트 커버리지 전략과 안티패턴

커버리지 100%는 버그가 없다는 증명이 아닙니다.

자주 보이는 안티패턴:

  • 구현 세부사항 테스트: 컴포넌트의 내부 상태 변수 이름을 assertion하는 것.
  • 모킹 과잉: vi.mock으로 비즈니스 로직 함수까지 통째로 모킹하는 것.
  • 단일 테스트에 너무 많은 assertion: 어느 assertion이 문제인지 추적하기 어렵습니다.
  • data-testid 남용: 시맨틱 쿼리로 대체할 수 있는 곳에 data-testid를 쓰면 접근성 결함이 드러나지 않습니다.
  • waitFor 안에서 await 사용: 폴링 로직이 의도대로 동작하지 않습니다.

커버리지 숫자가 위험한 이유는 "실행된 줄"과 "검증된 동작"을 혼동하게 만들기 때문입니다. 코드 한 줄이 테스트 중에 실행되기만 하면 커버리지는 그 줄을 초록색으로 칠하지만, 그 줄의 결과를 아무도 expect로 확인하지 않았다면 실제로는 아무것도 검증되지 않은 것입니다. 단언 없이 렌더링만 하는 테스트로도 커버리지를 90% 넘게 끌어올릴 수 있으며, 이런 스위트는 숫자만 그럴듯할 뿐 회귀를 거의 잡지 못합니다. 그래서 커버리지는 "얼마나 검증했는가"가 아니라 "어디를 아예 건드리지 않았는가"를 찾는 지도로 쓰는 것이 건강합니다. 커버리지가 0인 파일은 명백한 사각지대이지만, 커버리지가 100인 파일이 곧 안전을 뜻하지는 않습니다.

특히 분기 커버리지(branch coverage)에 주목하는 편이 좋습니다. 줄 커버리지는 높아도 에러 분기, 빈 상태 분기, 권한 없음 분기처럼 정상 경로가 아닌 곳이 통째로 비어 있는 경우가 많습니다. 사용자를 가장 화나게 하는 버그는 대개 이 예외 분기에서 나옵니다. 커버리지 리포트를 볼 때 전체 퍼센트보다 "검증되지 않은 분기 목록"을 먼저 훑는 습관을 들이면 투자 대비 효과가 훨씬 큽니다.

모킹 과잉은 통합 테스트의 가치를 스스로 무너뜨리는 안티패턴입니다. 네트워크만 MSW로 가로채고 나머지 애플리케이션 로직은 실제로 돌리는 것이 통합 테스트의 핵심인데, 여기서 비즈니스 로직 함수까지 vi.mock으로 대체하면 정작 검증하고 싶은 실제 코드 경로를 건너뛰게 됩니다. 남는 것은 "모킹한 함수가 모킹한 대로 동작한다"는 공허한 확인뿐입니다. 모킹은 통제하기 어려운 진짜 외부 경계, 즉 네트워크와 시간, 브라우저 API에만 최소한으로 적용하는 것이 원칙입니다.


언제 통합 테스트를 쓰지 말아야 하나

통합 테스트가 만능은 아닙니다. 순수한 계산 로직에 경우의 수가 많다면 유닛 테스트가 압도적으로 효율적입니다. 100가지 입력 조합을 검증해야 하는 검증 함수를 매번 컴포넌트를 렌더링하며 확인하는 것은 낭비입니다. 반대로 실제 결제 게이트웨이 연동, 서드파티 위젯의 실제 렌더링, 브라우저별 CSS 렌더링 차이, 실제 라우팅과 배포 산출물처럼 진짜 런타임이 필요한 검증은 통합 테스트의 범위를 벗어납니다. MSW로 네트워크를 흉내 낸 jsdom 환경은 실제 브라우저가 아니기 때문입니다. 이런 영역은 Playwright 같은 E2E 도구에 맡기는 것이 맞습니다.

또한 아직 스펙이 요동치는 프로토타입 화면에 통합 테스트를 촘촘히 깔아 두면, 매일 바뀌는 UI 때문에 테스트 유지보수가 개발보다 무거워집니다. 화면의 형태가 어느 정도 안정된 뒤, 핵심 사용자 흐름부터 통합 테스트로 고정하는 순서가 현실적입니다. "모든 것을 테스트한다"가 아니라 "깨지면 가장 아픈 흐름을 먼저 보호한다"가 통합 테스트의 올바른 출발점입니다.


자주 묻는 질문

Q. 통합 테스트가 유닛 테스트를 완전히 대체하나요?
아닙니다. 두 층은 잡는 결함이 다릅니다. 순수 로직은 유닛으로 싸게 보호하고, 상태·네트워크·렌더링이 얽히는 사용자 흐름은 통합으로 보호합니다. 어느 하나만으로는 사각지대가 생깁니다.

Q. MSW 대신 vi.mock으로 fetch를 모킹하면 안 되나요?
가능은 하지만 검증 범위가 좁아집니다. vi.mock은 애플리케이션 코드에 가까운 지점에서 모킹하므로 fetch 래퍼, 에러 변환, 재시도 같은 실제 코드 경로를 건너뜁니다. MSW는 네트워크 경계에서 가로채므로 이 경로들이 모두 실행됩니다. 계약 기반 검증이 목적이라면 MSW가 더 적합합니다.

Q. 테스트가 로컬에서는 통과하는데 CI에서만 실패합니다.
대부분 격리 실패나 타이밍 문제입니다. QueryClient를 테스트마다 새로 만들었는지, afterEach에서 핸들러와 DOM을 리셋하는지, userEventawait를 붙였는지 먼저 확인하십시오. 그다음 실행 순서를 무작위화해 반복 실행하면 순서 의존성이 드러납니다.

Q. 커버리지 목표를 몇 %로 잡아야 하나요?
숫자 자체를 목표로 삼지 마십시오. 커버리지는 "검증되지 않은 분기"를 찾는 지도로 쓰고, 특히 에러·빈 상태·권한 분기가 비어 있지 않은지를 우선 확인하는 편이 실질적입니다.


운영 관점 정리

  • MSW 핸들러를 도메인별 파일로 분리하고, onUnhandledRequest: 'error'로 누락된 모킹을 즉시 검출한다.
  • 각 통합 테스트마다 QueryClient 인스턴스를 새로 생성하고 retry: false를 설정한다.
  • findBy*waitFor를 시나리오에 맞게 구분해 사용한다.
  • 에러 시나리오를 반드시 별도 테스트 케이스로 작성한다.
  • CI에서 Vitest 샤딩을 적용해 테스트 실행 시간을 제어한다.
  • 플래키 테스트는 발견 즉시 격리하고, 실행 순서 무작위화로 순서 의존성을 검증한다.
  • 커버리지는 총 퍼센트가 아니라 검증되지 않은 분기를 찾는 지도로 활용한다.