본문 바로가기

React 유닛 테스트: 실전 엔드 투 엔드 가이드

설정부터 CI/CD까지 React 유닛 테스트를 마스터하세요. Jest, RTL, hooks, 비동기 code, mocking, 그리고 견고한 크로스 플랫폼 앱을 위한 베스트 프랙티스를 다룹니다.

React 유닛 테스트: 실전 엔드 투 엔드 가이드

점심 식사 전, 작은 UI 변경 사항을 푸시했습니다. 별거 아닌 것 같았죠. 버튼 레이블을 바꾸고, 조건부 렌더링을 단순화했으며, 헬퍼 hook에 새로운 분기를 하나 추가했습니다. PR은 깔끔했고, 리뷰는 빠르게 끝났으며, 배포까지 완료되었습니다.

한 시간 뒤, 고객 지원팀으로부터 특정 플랫폼에서 로그인이 안 된다는 보고가 들어옵니다. 웹은 멀쩡합니다. 데스크톱 쉘은 오래된 렌더링 경로를 사용하고 있고, 모바일 빌드는 비동기 상태 변경 후 다르게 동작합니다. code에 테스트가 있었음에도 아무도 잡아내지 못한 이유는, 테스트가 있긴 했지만 '올바른' 테스트가 아니었으며, 무엇보다 테스트를 뒷받침할 신뢰할 만한 시스템이 없었기 때문입니다.

이것이 바로 실무 팀에서 React 유닛 테스트를 할 때 겪는 핵심 문제입니다. 단순히 통과하는 테스트 몇 개를 작성하는 건 어렵지 않습니다. 리팩터링, 릴리스 트레인, 핫픽스, 그리고 크로스 플랫폼 패키징 과정에서도 여러분을 보호할 수 있는 테스트 스위트를 구축하는 것이 진짜 어려운 부분입니다. React 앱은 팀이 호출 방법을 잊어버려서 실패하는 것이 아닙니다. render()테스트가 구현 세부 사항에만 치중되거나, 비동기 동작을 대충 덮어버리거나, CI가 테스트를 릴리스 게이트가 아닌 단순 체크박스 항목으로 취급할 때 실패하게 됩니다.

현대적인 React 유닛 테스트는 안전 시스템처럼 작동해야 합니다. 로컬에서의 빠른 피드백, CI에서의 결정론적인 검증, 그리고 유닛 테스트 범위에 대한 명확한 기준이 필요합니다. 이는 동일한 React codebase를 브라우저, Capacitor 컨테이너, 또는 Electron 셸로 배포할 때 더욱 중요해집니다.

목차

React 유닛 테스트가 최고의 안전망인 이유

유닛 테스트의 진가는 절대 일어날 리 없다고 확신한 실수를 잡아낼 때 드러납니다. React에서는 보통 컴포넌트는 여전히 렌더링되지만, 사용자가 의존하는 동작이 변하는 경우를 말합니다. 비활성화된 버튼이 클릭 가능해지거나, 로딩 상태가 사라지지 않거나, 리팩터링 후 폴백 메시지가 사라지는 식이죠. 이러한 실패는 code에서는 사소해 보일지 몰라도 프로덕션에서는 치명적입니다.

React 테스트 방식은 다음과 같은 중요한 변화를 맞이했습니다 React Testing Library가 내부 구현이 아닌 동작 중심의 테스트 모델로 자리 잡으면서팀들이 컴포넌트의 props나 state 대신 사용자 동작을 반영하는 테스트를 지향하게 되었습니다. 이는 React Native의 테스트 가이드인 React Native 테스트 개요에서도 확인할 수 있습니다. React code는 끊임없이 재구성되기 때문에 이러한 변화가 중요합니다. Hooks가 이동하고, 컴포넌트가 분리되며, Context가 도입됩니다. 내부 구조에 결합된 테스트는 정상적인 리팩터링 중에도 깨지지만, 눈에 보이는 동작에 결합된 테스트는 대개 유지됩니다.

유닛 테스트가 보호해야 할 대상

좋은 React 유닛 테스트는 하나의 작은 계약(contract)을 보호합니다:

  • 렌더링 결과: 사용자에게 올바른 텍스트, 라벨, 상태 또는 폴백이 표시되나요?
  • 상호작용 동작: 클릭, 타이핑 또는 토글 시 UI가 올바르게 변경되나요?
  • 예외 처리: 정상적인 입력, 데이터 누락 또는 에러 발생 시 컴포넌트가 올바르게 동작하나요?

부실한 테스트는 엉뚱한 것을 검증합니다:

  • 컴포넌트 내부 로직: 상태 구조, 프라이빗 메서드, 구현 전용 props
  • 프레임워크 메커니즘: React가 내부적으로 hook을 의도한 방식대로 업데이트했는지 여부
  • 자식 컴포넌트 상세 정보: 여기서 검증할 의도가 없는 중첩된 컴포넌트의 마크업

실무 원칙: 사용자가 보고 경험하는 기능이 변하지 않도록 컴포넌트를 리팩터링할 수 있다면, 테스트 코드 또한 변경될 필요가 없어야 합니다.

단위 테스트는 더 넓은 테스트 시스템의 일부입니다. 앱 전체가 엔드 투 엔드로 작동함을 증명하려는 것이 아닙니다. 브라우저 수준의 테스트나 디바이스 수준의 검증 단계로 넘어가기 전, 회귀 버그를 빠르게 잡아내는 레이어입니다. 그렇기에 단위 테스트는 다음과 같은 프로덕션 앱을 위한 자동화된 테스트 스택에서.

가장 강력한 첫 번째 방어선이 됩니다. 빈번하게 배포하는 React 팀에게 이러한 역할 분담은 확신을 줍니다. 단위 테스트는 로컬 회귀 버그를 빠르게 잡고, 통합 테스트는 컴포넌트 간의 연결을 검증하며, E2E 테스트는 핵심 경로를 확인합니다. 단위 테스트 단계를 건너뛰면, 이후의 느린 테스트 단계들이 너무 많은 부담을 떠안게 됩니다.

현대적인 React 테스트 환경 구축하기

취약한 테스트 환경은 단 하나의 assertion을 작성하기도 전에 테스트를 불안정하게 만듭니다. 많은 개발자가 Jest, jsdom, 또는 React를 탓하지만, 실제 근본적인 문제는 로컬 환경과 CI 간의 일관성 없는 설정인 경우가 많습니다. 해결책은 환경을 '지루하게' 만드는 것입니다. 여기서 지루함은 곧 안정성을 의미합니다.

code 에디터에서 React 단위 테스트 code 를 보여주는 컴퓨터 모니터가 있는 깔끔한 작업 공간.

예측 가능한 러너와 환경부터 시작하세요

Vite로 생성된 현대적인 React 앱의 경우, 기본 설정에 다음 항목이 포함되어야 합니다:

  • 테스트 러너: Jest는 특히 오래된 React 코드베이스와 엔터프라이즈 CI 스택에서 여전히 많이 사용됩니다.
  • 브라우저와 유사한 환경: jsdom 컴포넌트 테스트에서 DOM 출력을 렌더링할 수 있게 해줍니다.
  • Testing Library 유틸리티: @testing-library/react 및 @testing-library/jest-dom
  • 단일 설정 엔트리포인트: 매처(matcher)와 글로벌 모킹을 등록하는 하나의 파일

React 테스트 가이드가 강조하는 핵심 워크플로우는 간단합니다. jsdom 기반 환경에서 컴포넌트를 렌더링하고, 다음과 같은 셀렉터로 UI를 쿼리한 뒤, getByText 또는 getByRole상호작용을 트리거하고 DOM 변화를 검증하는 것입니다. 이는 React 테스트 문서에 설명된 방식과 같습니다. 이 워크플로우가 신뢰성을 유지하려면 모든 머신이 동일한 테스트 환경에서 실행되어야 합니다.

실제 Jest 설정은 보통 다음과 같습니다:

// jest.config.js
module.exports = {
  testEnvironment: 'jsdom',
  setupFilesAfterEnv: ['<rootDir>/src/setupTests.js'],
  moduleNameMapper: {
    '\\.(css|less|scss)$': 'identity-obj-proxy',
    '^@/(.*)$': '<rootDir>/src/$1',
  },
  transform: {
    '^.+\\.(js|jsx|ts|tsx)$': 'babel-jest',
  },
};

팀에서 Babel 대신 SWC를 사용해도 괜찮습니다. 중요한 건 트랜스포머가 아니라 일관성입니다. 한 가지 방식을 정해 레포지토리에 표준화하세요. 더 넓은 JavaScript 테스트 컨벤션에 대한 참고 자료가 필요하다면, Capgo의 JavaScript 유닛 테스트 가이드 는 팀 내 공유 문서로 유용합니다.

테스트 스위트에서 의존할 설정 파일을 추가하세요

올바른 setupTests.js 설정은 반복되는 번거로움을 크게 줄여줍니다:

import '@testing-library/jest-dom';

Object.defineProperty(window, 'matchMedia', {
  writable: true,
  value: jest.fn().mockImplementation(query => ({
    matches: false,
    media: query,
    onchange: null,
    addListener: jest.fn(),
    removeListener: jest.fn(),
    addEventListener: jest.fn(),
    removeEventListener: jest.fn(),
    dispatchEvent: jest.fn(),
  })),
});

이 파일은 20개의 테스트 파일마다 환경 차이를 해결하는 대신, 한 번에 문제를 해결하는 곳입니다. UI가 의존하는 API에 대한 모크(mock)를 여기에 추가하세요. 예를 들어, matchMedia, ResizeObserver, 또는 IntersectionObserver 컴포넌트 라이브러리에서 이를 요구하는 경우입니다.

이 설정이 없으면 개발자들은 전역 객체를 임시방편으로 패치하게 됩니다. 이는 테스트의 일관성을 해치고 추적하기 어려운 실패를 만듭니다. 누군가는 파일에 수동 모크를 추가해 로컬에서 통과하지만, 설정이 공유되지 않아 CI에서는 실패하게 됩니다.

로컬과 CI 환경의 동작을 일치시키세요

로컬 명령은 CI 명령과 최대한 일치해야 합니다. 개발자는 느슨한 기본 설정으로 watch mode를 실행하는데 CI는 엄격한 설정을 사용한다면, 머지 후 예상치 못한 실패를 겪게 됩니다. 스크립트를 명시적으로 관리하세요:

{
  "scripts": {
    "test": "jest",
    "test:watch": "jest --watch",
    "test:ci": "jest --runInBand --coverage"
  }
}

간단한 가이드는 팀원들이 빠르게 동일한 기준을 잡는 데 도움이 됩니다:

가장 중요한 설정 원칙은 기본값에 대한 엄격함입니다. 별칭(alias)은 설정 파일에 정의하고, 환경 모킹(mock)은 하나의 setup 파일에 모으세요. UI 테스트에는 jsdom 를 사용하고, 순수 유틸리티에는 가능한 가벼운 환경을 사용하세요. 개별 테스트가 요구하는 커스텀 동작이 적을수록 시스템은 더 신뢰할 수 있게 됩니다.

의미 있는 컴포넌트 테스트 작성하기

기업들의 문제는 테스트를 작성하지 못하는 것이 아닙니다. 6개월 뒤에도 여전히 유효한 테스트를 작성하지 못하는 것이 문제입니다.

React 컴포넌트 유닛 테스트의 표준 패턴은 여전히 유효합니다: 컴포넌트를 렌더링하고, 사용자 중심의 셀렉터로 UI를 쿼리한 뒤, 상호작용을 트리거하고, 그 결과로 나타나는 DOM 변화를 검증하세요., which keeps tests away from implementation details like state or props, as described in the React 테스트 가이드핵심은 이 패턴을 절제하며 적용하는 것입니다.

사용자가 아코디언을 사용하는 방식 그대로 테스트하세요

기본적인 Accordion 컴포넌트를 예로 들어보겠습니다. 이 컴포넌트는 제목이 있는 버튼을 렌더링합니다. 패널 콘텐츠는 처음에는 숨겨져 있습니다. 버튼을 클릭하면 콘텐츠가 나타나고 접근성 상태가 업데이트됩니다.

이 정도 동작이면 유용한 테스트를 여러 개 작성할 수 있습니다:

  1. 초기 렌더링 시 제목은 보이지만 콘텐츠는 보이지 않아야 합니다.
  2. 트리거를 클릭하면 콘텐츠가 나타나야 합니다.
  3. 다시 클릭하면 접혀야 합니다.
  4. 접근성 속성이 표시 상태를 반영해야 합니다.

마지막 항목은 너무 자주 간과됩니다. 컴포넌트에서 aria-expanded, aria-controls또는 role 기반 구조를 사용한다면 이를 검증하세요. 이는 단순한 구현 세부 사항이 아니라, 사용자에게 제공되는 인터페이스의 약속입니다.

가장 좋은 컴포넌트 테스트는 받고 싶지 않은 버그 리포트처럼 읽힙니다.

의도에 따라 쿼리를 선택하세요

React Testing Library는 여러 가지 쿼리 스타일을 제공하지만, 서로 바꿔 쓸 수는 없습니다. 잘못된 쿼리를 고르면 테스트가 지저분해지거나 오해를 낳을 수 있습니다.

쿼리 유형 요소를 찾았을 때 요소를 찾지 못했을 때 사용 사례 예시
getBy 요소를 즉시 반환 즉시 에러 발생 버튼이나 헤딩이 화면에 이미 있어야 함을 검증
queryBy 요소를 즉시 반환 반환값 null 상호작용 전 숨겨진 콘텐츠가 없어야 함을 검증
findBy 요소가 나타나면 resolve 대기 후 reject fetch 또는 지연 업데이트 후 비동기 로드 콘텐츠가 나타나는지 검증하세요

간단한 멘탈 모델이 도움이 됩니다:

  • 사용하세요: getBy 이미 존재해야 하는 요소의 경우
  • 사용하세요: queryBy 아직 존재하지 않아야 하는 요소의 경우
  • 사용하세요: findBy 나중에 UI가 변경되는 경우

테스트가 다음으로 시작한다면 findBy 모든 것에 사용한다면, 보통 작성자가 컴포넌트 업데이트 시점을 확신하지 못한다는 뜻입니다. 이러한 불확실성은 나중에 flakiness로 이어집니다.

실용적인 아코디언 예시

대표적인 컴포넌트는 다음과 같습니다:

function Accordion({ title, children }) {
  const [open, setOpen] = React.useState(false);

  return (
    <section>
      <button
        aria-expanded={open}
        aria-controls="accordion-panel"
        onClick={() => setOpen(prev => !prev)}
      >
        {title}
      </button>
      {open ? (
        <div id="accordion-panel">
          {children}
        </div>
      ) : null}
    </section>
  );
}

유지할 가치가 있는 테스트의 형태는 다음과 같습니다:

import { render, screen, fireEvent } from '@testing-library/react';

test('renders the accordion title and hides content initially', () => {
  render(<Accordion title="Shipping details">Delivery takes 3 days</Accordion>);

  expect(screen.getByRole('button', { name: /shipping details/i })).toBeInTheDocument();
  expect(screen.queryByText(/delivery takes 3 days/i)).not.toBeInTheDocument();
});

test('reveals content when the trigger is clicked', () => {
  render(<Accordion title="Shipping details">Delivery takes 3 days</Accordion>);

  fireEvent.click(screen.getByRole('button', { name: /shipping details/i }));

  expect(screen.getByText(/delivery takes 3 days/i)).toBeInTheDocument();
});

test('updates aria-expanded when opened', () => {
  render(<Accordion title="Shipping details">Delivery takes 3 days</Accordion>);

  const button = screen.getByRole('button', { name: /shipping details/i });
  expect(button).toHaveAttribute('aria-expanded', 'false');

  fireEvent.click(button);

  expect(button).toHaveAttribute('aria-expanded', 'true');
});

무엇이 빠졌는지를 아는 것도 중요합니다. 내부 상태에 대한 단언(assertion)이 없고, setOpen 호출 여부 확인도 없으며, 전체 렌더링 트리의 스냅샷도 없습니다. 그런 테스트는 신뢰가 아닌 유지보수 비용만 늘릴 뿐입니다.

컴포넌트 테스트를 강화하는 몇 가지 습관:

  • 역할(role) 기반 쿼리를 사용하세요: 버튼, 헤딩, 다이얼로그, 알림, 입력창 등은 보통 role을 통해 찾아야 합니다.
  • 각 테스트의 범위를 좁게 유지하세요: 테스트 하나당 하나의 사용자 가시적 동작만 다뤄야 실패 원인을 파악하기 쉽습니다.
  • 결과 중심으로 테스트 이름을 지으세요: “정상 작동함”보다는 “열렸을 때 aria-expanded 속성이 업데이트됨”이 훨씬 유용합니다.

DOM을 통한 컴포넌트 테스트가 어렵다면, 이는 설계 문제일 가능성이 높습니다. 상태를 잘못된 곳에 숨겼거나 시맨틱 마크업이 부족할 수 있습니다. 좋은 테스트는 팀이 더 나은 컴포넌트를 만들도록 유도합니다.

Custom Hook 및 애플리케이션 로직 테스트

React 앱의 중요한 동작 중 상당수는 컴포넌트 외부에 숨겨져 있습니다. 상태 전환은 hooks에서 이루어지고, 유효성 검사와 포맷팅은 helper functions에서 처리됩니다. 데이터 가공은 렌더링 전 단계에서 빈번하게 발생합니다. 눈에 보이는 컴포넌트만 테스트한다면, 실제 프로덕션 환경에서 문제를 일으킬 수 있는 code의 상당 부분을 놓치게 됩니다.

Hooks에는 React를 인식하는 테스트 환경이 필요합니다

커스텀 hook이 제대로 실행되려면 여전히 React가 필요하므로, 다음을 사용하여 테스트하세요: renderHook 그리고 상태를 변경하는 호출은 다음으로 감싸주세요: act().

간단한 useToggle hook의 예시는 다음과 같습니다:

import { useState, useCallback } from 'react';

export function useToggle(initialValue = false) {
  const [value, setValue] = useState(initialValue);
  const toggle = useCallback(() => setValue(current => !current), []);
  return { value, toggle };
}

테스트는 공개된 인터페이스(public contract)에 집중해야 합니다:

import { renderHook, act } from '@testing-library/react';
import { useToggle } from './useToggle';

test('returns the initial value', () => {
  const { result } = renderHook(() => useToggle(true));
  expect(result.current.value).toBe(true);
});

test('toggles the value', () => {
  const { result } = renderHook(() => useToggle(false));

  act(() => {
    result.current.toggle();
  });

  expect(result.current.value).toBe(true);
});

이 테스트가 유용한 이유는 hook 자체가 하나의 단위(unit)이기 때문입니다. React 내부 동작을 테스트하는 것이 아니라, hook의 외부 동작을 검증하는 것입니다.

재사용 가능한 UI나 기능 프리미티브를 만드는 프로덕트 팀에게 이 패턴은 매우 중요합니다. Hooks는 종종 앱, 디자인 시스템 또는 내부 툴링 전반에서 공유되는 인터페이스가 됩니다. 상용화를 목적으로 재사용 가능한 동작을 설계하고 있다면, 다음 자료가 도움이 될 것입니다. 메이커를 위한 hooks 관련 자료는 hooks를 단순한 구현 세부 사항이 아닌, 제품화된 빌딩 블록으로 바라보는 데 도움을 줍니다.

순수 로직은 테스트에서도 순수함을 유지해야 합니다

모든 것에 jsdom, React 또는 Testing Library가 필요한 것은 아닙니다. 순수 함수라면 Node 환경에서 일반 Jest로 테스트하세요.

예시:

export function formatDisplayName(firstName: string, lastName: string) {
  return `${firstName.trim()} ${lastName.trim()}`.trim();
}

테스트는 아주 단순해야 합니다:

import { formatDisplayName } from './formatDisplayName';

test('joins and trims both names', () => {
  expect(formatDisplayName(' Ada ', ' Lovelace ')).toBe('Ada Lovelace');
});

test('handles a missing last name', () => {
  expect(formatDisplayName('Ada', '')).toBe('Ada');
});

여기서 얻는 이점은 속도와 명확성입니다. 함수에 렌더링된 트리가 필요 없다면 굳이 제공하지 마세요. React 전용 도구는 오버헤드를 발생시킵니다. 비즈니스 로직 테스트는 작고 빠르게, 그리고 검증하려는 함수와 최대한 가깝게 유지하세요.

실무적인 구분 방법은 다음과 같습니다:

  • Hooks: 사용하세요: renderHook, act(), 그리고 필요한 경우 wrapper provider를 사용하세요.
  • Utilities: DOM 없이 일반 Jest를 사용하세요.
  • 상태를 포함한 교차 관심사 로직: 컴포넌트 테스트가 너무 비대해지면 테스트 가능한 helper 함수로 로직을 분리하세요.

팀들은 종종 하위 스택에 있어야 할 로직 검증까지 컴포넌트 테스트에 과하게 넣곤 합니다. 로직을 분리하면 두 가지 이점이 있습니다. 컴포넌트 테스트는 깔끔해지고, 로직 테스트는 빨라집니다.

고급 기술 마스터하기: Mocking과 Async

신뢰할 수 없는 대부분의 React 테스트 스위트는 두 지점에서 깨집니다. 바로 의존성 경계(dependency boundaries)와 시간(time) 관련 이슈입니다.

이것이 바로 async 테스트와 mocking이 단순한 연습용 테스트 스위트와 배포 전 신뢰할 수 있는 테스트 스위트를 가르는 기준인 이유입니다. 한 분석에 따르면 테스트 불안정성(flakiness)의 46.5%가 async 타이밍과 같은 환경 또는 리소스 관련 이슈에서 발생하며, 에서 이 React unit testing 분석에 따르면React 앱에서는 이것이 상태 전환, 렌더링 지연, 네트워크 기반 UI, 그리고 결정론적으로 기다리는 대신 추측하는 테스트로 직결됩니다.

의존성 Mocking과 비동기 테스트를 중심으로 비교한 고급 React Testing 기술 차트입니다.

모든 레이어가 아닌 경계(boundary)를 Mocking하세요

잘못된 테스트를 작성하는 가장 빠른 방법은 컴포넌트 트리의 절반을 Mocking한 뒤, 본인이 만든 Mock이 잘 작동하는지 검증하는 것입니다.

계정 데이터를 가져오는 컴포넌트라면 네트워크 클라이언트나 API 모듈을 mock하세요. 테스트에서 해당 지점의 격리가 반드시 필요한 경우가 아니라면, hook이나 자식 row 컴포넌트, 로딩 스피너, 그리고 3개의 유틸리티 함수까지 mock할 필요는 없습니다.

다음 규칙을 따르세요:

  • 외부 서비스 mock 하기: HTTP 클라이언트, analytics, 브라우저 전용 API, native bridges
  • 불안정한 플랫폼 API mock 하기: matchMedia타이머, Electron preload 인터페이스, jsdom에서 사용할 수 없는 Capacitor plugins
  • 기본적으로 내부 로직 mock 하기 지양: custom hooks, 단순한 자식 컴포넌트, 로컬 유틸리티

어려운 부분들을 모두 가짜(fake)로 대체해서 테스트를 통과했다면, 실제 배포 시의 신뢰성을 확보했다고 보기 어렵습니다.

runner API 관련 예시와 패턴을 찾는 팀이라면, Capgo 테스트 튜토리얼은 실용적인 레퍼런스 라이브러리가 될 것입니다. 특히 React는 알지만 테스트 메커니즘은 아직 익숙하지 않은 개발자의 onboarding 시에 매우 유용합니다.

타이밍이 불분명하면 비동기 테스트가 실패합니다

비동기 테스트 실패는 보통 다음 세 가지 실수에서 발생합니다:

  1. 테스트가 너무 일찍 단언(assert)합니다.
  2. 임의의 타이머로 대기합니다.
  3. 컴포넌트가 여러 번 업데이트되지만, 테스트는 한 번의 전환만 모델링합니다.

안정적인 비동기 테스트는 보통 다음과 같은 형태를 가집니다:

test('shows user details after data loads', async () => {
  render(<UserProfile userId="42" />);
  expect(screen.getByText(/loading/i)).toBeInTheDocument();

  expect(await screen.findByText(/account owner/i)).toBeInTheDocument();
});

또는, 특정 조건을 기다려야 할 때는 다음과 같이 합니다:

await waitFor(() => {
  expect(screen.getByRole('alert')).toBeInTheDocument();
});

사용하세요: findBy 특정 요소가 나타나는 것이 중요한 이벤트일 때 사용하세요. 다음을 사용하세요: waitFor 조건이 더 광범위하거나 상태를 단일 쿼리로 표현할 수 없을 때 사용하세요. 다음은 피하세요: setTimeout 타이머 동작을 명시적으로 테스트하거나 fake timers를 사용하는 경우가 아니라면 테스트에서 사용하지 마세요.

React 테스트 생태계는 또한 다음을 준수할 것을 요구합니다 act() 업데이트와 관련된 시맨틱(semantics)을 고려해야 합니다. Testing Library가 많은 부분을 처리해주지만, 상태를 수동으로 제어하거나 타이머를 앞당기는 경우에는 업데이트가 언제 반영(flush)되는지 반드시 확인해야 합니다.

상황에 맞는 모킹(mocking) 도구 선택하기

모킹 도구마다 해결할 수 있는 문제가 다릅니다:

도구 최적의 용도 흔한 실수
jest.fn() 독립적인 가짜 콜백 또는 주입된 함수 단순한 콜백으로 충분한데 모듈 전체를 교체하는 경우
jest.spyOn() 실제 객체나 모듈의 특정 메서드만 관찰(Observe)하거나 오버라이드 원본 구현을 복구(restore)하는 것을 잊는 경우
jest.mock() import 경계에서 모듈 의존성 교체 대규모 모듈을 기본적으로 모킹하여 의미 있는 동작을 잃는 경우

다음과 같은 경우 예제를 참고하세요:

  • 다음 상황에서 활용하세요: jest.fn() 컴포넌트가 다음을 받을 때: onSubmit prop.
  • 사용하세요: jest.spyOn() 다음 사항을 검증해야 할 때: console.error, 저장 방식, 또는 외부로 내보낸 API 호출.
  • 사용하세요: jest.mock() 모듈 임포트 시 I/O, 네이티브 code, 또는 유닛 경계 외부의 동작이 발생할 때.

많은 가이드가 간과하는 고급 영역 중 하나는 최신 React의 에러 경로 테스트입니다. Error boundary, 지연된 상태 변경, 비동기 fallback UI는 단순한 “happy path” 클릭 예제가 아닌, 제대로 된 테스트가 필요합니다. 자식 컴포넌트에서 에러가 발생하면 fallback UI를 검증하세요. 요청이 실패하면 사용자에게 보이는 복구 상태를 검증하세요. 로딩 중 버튼이 비활성화되는지도 확인해야 합니다. 이런 것들이 사용자가 실제로 겪는 버그입니다.

테스트 품질 및 전략 개선하기

많은 팀이 여전히 테스트 커버리지가 곧 신뢰도라고 착각하며 커버리지 수치에만 매달립니다. 하지만 커버리지가 곧 신뢰도는 아닙니다.

커버리지 목표를 달성하더라도 정작 중요한 회귀 버그를 놓칠 수 있습니다. 얕은 assertion, 광범위한 snapshot, 내부 로직을 모킹한 테스트들로 가득 찬 테스트 스위트는 안전해 보일 뿐, 오히려 유지보수 비용만 높입니다.

품질 높은 테스트의 이점과 테스트 양만 늘렸을 때 발생하는 유지보수 오버헤드를 비교한 인포그래픽입니다.

커버리지는 지도일 뿐, 목표가 아닙니다

커버리지 리포트는 '어떤 핵심 경로에 아직 보호 장치가 없는가?'라는 질문에 답할 때만 유용합니다.

단순히 퍼센트를 높이려고 사소한 wrapper, 정적 마크업, 한 줄짜리 pass-through 파일을 테스트하게 만든다면 무용지물입니다. 커버리지를 탐색 도구로 활용하세요. 인증 상태, 결제 로직, feature flag, 또는 update prompt에 테스트가 없다면 그것은 신호입니다. 반면 단순 UI 아이콘 컴포넌트에 테스트가 없다면, 대개 큰 문제는 아닙니다.

건강한 리뷰를 위한 질문은 간단합니다. '이 테스트가 배포 리스크를 줄여주는가?'

  • 예: 핵심 경로에서 사용자에게 보이는 동작을 검증합니다.
  • 그럴 수도 있습니다: 리팩터링 시 깨지기 쉬운 비즈니스 로직을 보호합니다.
  • 아니요: 구현 세부 사항을 assertion하거나 다른 테스트와 중복되는 가치를 가집니다.

단위 테스트에서 제외해야 할 것들

많은 React 가이드들이 무엇을 테스트하지 말아야 하는지에 대해서는 충분히 다루지 않습니다. BrowserStack의 가이드에서 언급했듯이, 과도한 모킹(over-mocking)과 구현 세부 사항에 대한 테스트는 사용자 경험이 망가지고 있음에도 테스트만 통과하는 취약한 테스트 스위트를 만들기 때문에 이 차이는 매우 중요합니다. React에서 단위 테스트를 피해야 할 대상.

다음 패턴들은 건너뛰거나 엄격히 제한하세요:

  • 내부 상태(Internal state) 검증: 테스트하지 마세요 isOpen 패널이 열렸는지 확인할 수 있다면, 직접적인 상태를 테스트하지 마세요.
  • 프레임워크 동작: React가 effect를 호출했는지 테스트하지 마세요. effect가 변경한 결과물을 테스트하세요.
  • 서드파티 라이브러리 내부 로직: 라이브러리 자체의 렌더링 로직이 아니라, date picker나 router와의 통합 동작을 테스트하세요.
  • 지나치게 잘게 쪼개진 단위: 모든 자식 컴포넌트와 헬퍼 함수를 모킹했다면, 더 이상 의미 있는 동작을 테스트하고 있지 않을 수도 있습니다.

잘못된 테스트는 테스트가 없는 것보다 나쁩니다. 리팩터링을 방해하면서도 정작 프로덕션 버그는 잡아내지 못하기 때문입니다.

유용한 휴리스틱은 경계 소유권(boundary ownership)입니다. code이 소유한 영역을 테스트하세요. 통합 레이어에서 계약(contract)을 변경하지 않는 한, React나 브라우저, 혹은 이미 검증된 라이브러리가 소유한 영역은 테스트할 필요가 없습니다.

스냅샷 테스트의 장점과 단점

스냅샷이 무용지물인 것은 아닙니다. 잘못 사용하기 쉬울 뿐입니다.

구조적 차이(diff)가 의미를 갖는, 출력이 안정적이고 단순한 컴포넌트에만 제한적으로 사용하세요. 상호작용이 많거나 동적인 컴포넌트에는 사용을 피해야 합니다. 노이즈가 되어 개발자들이 내용을 읽지 않고 습관적으로 업데이트만 하게 되기 때문입니다.

보통 더 나은 대안이 존재합니다:

  • 조건부 렌더링의 경우, 주요 텍스트의 존재 여부를 확인하세요.
  • 시각적 상태 변화의 경우, 중요한 역할(role), 레이블(label) 또는 속성(attribute)을 확인하세요.
  • 에러 및 폴백(fallback)의 경우, 실제 메시지나 알림 영역을 확인하세요.

팀에 유닛 테스트 이상의 광범위한 품질 프로세스가 필요하다면, 다음과 같은 솔루션이 좋은 동반자가 될 수 있습니다. 앱 품질 보증(QA) 워크플로우 테스트, 릴리스 체크, 롤백 계획을 하나의 시스템으로 다루는 것이 중요합니다. 이것이 테스트 품질을 가장 빠르게 높이는 사고방식의 전환입니다. 단순히 테스트 개수가 몇 개인지를 묻지 마세요. 어떤 실패가 사용자에게 도달할 수 있는지를 고민해야 합니다.

크로스 플랫폼 CI/CD 파이프라인에 테스트 통합하기

개발자 노트북에서만 돌아가는 테스트 스위트는 제어 장치가 아니라 단순한 제안일 뿐입니다.

모든 pull request가 깨끗한 환경에서 동일한 체크를 수행하고, 체크 실패 시 머지를 차단할 때 비로소 테스트 스위트는 실질적인 역할을 합니다. 당연해 보이지만 많은 팀이 여전히 중요한 허점을 남겨둡니다. 테스트를 수동으로 실행하거나, 커버리지 리포트를 선택 사항으로 두거나, 테스트가 끝나기도 전에 패키징 및 릴리스 작업을 시작하곤 합니다. 이런 허점 때문에 작은 UI 회귀 오류가 대규모 릴리스 실패로 이어집니다.

React 자동화 테스트를 CI/CD 개발 파이프라인에 통합하는 과정을 보여주는 5단계 플로우차트입니다.

pull request는 매번 동일한 게이트를 통과해야 합니다

React 유닛 테스트가 안전망 역할을 하려면 CI에 다음과 같은 필수 요소가 필요합니다:

  • 모든 pull request에서 실행
  • lockfile을 통한 의존성 설치
  • 매번 동일한 테스트 명령 사용
  • 테스트 실패 시 즉시 중단(Fail fast)
  • 테스트 통과 후 아티팩트 게시

이것은 ~의 핵심입니다: 앱 팀을 위한 지속적 배포(CD) 관행의. 출시 후가 아닌, 출시 전에 확신을 가지세요.

많은 팀에게는 간단한 GitHub Actions 워크플로우만으로도 충분합니다:

name: test

on:
  pull_request:
  push:
    branches:
      - main

jobs:
  react-tests:
    runs-on: ubuntu-latest

    steps:
      - name: Check out code
        uses: actions/checkout@v4

      - name: Set up Node
        uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm

      - name: Install dependencies
        run: npm ci

      - name: Run unit tests
        run: npm run test:ci

화려하지는 않지만, 그게 핵심입니다. 가장 강력한 파이프라인은 대개 예상치 못한 변수가 없는 파이프라인입니다.

Capacitor 및 Electron에서 이것이 더 중요한 이유

크로스 플랫폼 React 앱은 브라우저 전용 앱보다 출시 리스크가 더 큽니다. 동일한 UI code가 서로 다른 런타임 환경을 가진 다양한 컨테이너로 배포되기 때문입니다.

파이프라인이 도움이 되는 몇 가지 사례는 다음과 같습니다:

  • Capacitor 앱: Web code는 로컬에서는 통과하더라도, 패키징 후 플러그인 브릿지, 오프라인 상태, 또는 앱 라이프사이클의 엣지 케이스로 인해 동작이 달라지면 실패할 수 있습니다.
  • Electron 앱: Renderer 컴포넌트가 preload API, window 메시징 또는 데스크톱 전용 상태에 의존할 수 있는데, 이는 의도적으로 모킹(mock)하지 않으면 일반 브라우저 테스트 환경에는 존재하지 않습니다.
  • 공통 릴리스 트레인: 배포 프로세스에서 게시 단계를 엄격하게 제어하지 않으면, 하나의 잘못된 bundle이 여러 타겟에 영향을 줄 수 있습니다.

따라서 유닛 테스트는 패키징 작업 전에 실행되어야 하며, 패키징 작업은 배포 작업 전에 실행되어야 합니다. 각 단계는 리스크를 줄여줍니다. 유닛 테스트는 로컬 회귀를 빠르게 잡아내고, 플랫폼 패키징은 환경 가정을 검증합니다. 마지막으로 수동 승인이나 단계적 배포(staged rollout)를 통해 최종 릴리스의 신뢰성을 확보합니다.

실용적인 GitHub Actions 워크플로우

더 성숙한 파이프라인은 보통 다음과 같이 역할을 나눕니다:

  1. 테스트 작업: 빠른 유닛 및 hook 테스트
  2. 빌드 작업: 테스트 통과 후 프로덕션 build 진행
  3. 패키지 작업: Capacitor sync, Electron 패키징 또는 artifact 번들링
  4. 릴리스 작업: 승인된 브랜치 또는 태그에서만 배포하세요

Capacitor 또는 Electron 앱에 Live Update를 배포하는 팀에게는 릴리스 툴링이 매우 중요합니다. 해당 워크플로우의 한 가지 옵션은 Capgo이며, 이는 CapacitorJS 및 Electron 앱을 위한 서명된 웹 번들을 배포하며 롤백 지원 및 채널 기반 롤아웃 제어 기능을 제공합니다. 즉, React 테스트 작업이 웹 번들이 스테이징이나 프로덕션으로 전달되기 전의 첫 번째 강력한 게이트 역할을 할 수 있음을 의미합니다.

운영 원칙은 간단합니다. 릴리스 인프라가 부실한 테스트를 보완하게 두지 마세요. 신뢰할 수 있는 테스트를 통해 잘못된 변경 사항을 먼저 걸러낸 뒤에 릴리스 인프라를 사용해야 합니다.

신뢰할 수 있는 테스트 시스템은 팀의 행동을 변화시킵니다. 엔지니어는 망설임 없이 머지하고, 리뷰어는 기본적인 사항을 수동으로 재검토하는 대신 엣지 케이스에 집중합니다. 릴리스 매니저도 모든 배포를 도박처럼 다루지 않게 됩니다. 이것이 바로 React 유닛 테스트를 제대로 수행했을 때 얻는 결과입니다.


팀에서 Capacitor 또는 Electron을 통해 React를 배포한다면, 릴리스 안정성은 로컬 테스트 통과 그 이상의 것에 달려 있습니다. Capgo 팀이 서명된 웹 업데이트를 제어된 방식으로 배포하고, 타겟 롤아웃 채널을 지정하며, 스토어 심사를 기다리지 않고도 잘못된 번들을 롤백할 수 있는 방법을 제공합니다. 이는 배포 전 유닛 테스트 통과를 요구하는 CI 파이프라인 뒤에 자연스럽게 연결됩니다.

Capacitor 앱을 위한 Live Updates

웹 레이어 버그가 라이브 상태일 때, 앱 스토어 승인을 며칠씩 기다리지 말고 Capgo를 통해 수정 사항을 배포하세요. 네이티브 변경 사항은 정상적인 리뷰 절차를 따르는 동안, 사용자는 백그라운드에서 업데이트를 받습니다.

블로그 최신 글

Capgo는 진정한 프로급 모바일 앱을 만드는 데 필요한 최고의 인사이트를 제공합니다.