본문으로 건너뛰기

안동민 개발노트

본문 시작

컴포넌트 테스트

Testing Library로 props에 따른 렌더링과 사용자 이벤트를 검증하고 내부 구현보다 화면에서 보이는 동작에 집중합니다.

컴포넌트 테스트(Component Testing)는 UI 컴포넌트가 독립적으로 올바르게 렌더링되는지, 상호작용에 따라 예상대로 동작하는지, props에 맞는 화면을 표시하는지를 검증하는 데 집중합니다.

단위 테스트와 비슷하지만, 내부 로직뿐 아니라 시각적 출력과 사용자 경험까지 함께 본다는 점에서 더 넓은 범위의 테스트입니다.

Next.js에서는 React 컴포넌트가 핵심 빌딩 블록이므로 컴포넌트 테스트가 애플리케이션 안정성과 UI 품질을 좌우합니다.

이 절에서는 @testing-library/react + Jest 조합과 Cypress 컴포넌트 테스트 활용 방법을 비교하고, 효과적인 테스트 작성 전략을 정리합니다.

먼저 컴포넌트 테스트가 단위 테스트와 E2E 사이에서 어느 범위를 맡는지 확인합니다.

컴포넌트 테스트 범위는 관찰 가능한 계약에서 멈춘다

구현 함수가 아니라 사용자가 보고 조작하는 DOM과 외부 경계의 결과를 검증한다.

  1. 포함
    렌더 계약

    props에 따른 텍스트·역할·상태 표시

  2. 포함
    사용자 행동

    클릭·입력·키보드 뒤 보이는 결과

  3. 대체
    외부 경계

    network·router·시간만 제어 가능한 fake로 교체

  4. 제외
    구현 세부

    내부 state·private 함수·class 이름 직접 단정


컴포넌트 테스트의 중요성

컴포넌트 테스트는 props, 사용자 이벤트, 렌더링 결과를 한 단위로 묶어 검증합니다.

컴포넌트 테스트가 중요한 이유

컴포넌트는 props, 상태, 이벤트가 만나는 작은 제품 표면입니다. 이 표면을 빨리 검증하면 전체 앱 실행 없이도 많은 회귀를 잡을 수 있습니다.

  1. 1
    독립 검증

    백엔드 없이 UI 단위로 문제를 좁힌다.

  2. 2
    시각 회귀 방지

    상태 변경 뒤 화면 문구와 버튼이 유지되는지 본다.

  3. 3
    재사용성 확인

    props 계약이 명확할수록 테스트도 선명하다.

  4. 4
    빠른 피드백

    브라우저 수동 확인 전에 깨짐을 잡는다.

  5. 5
    동작 문서

    컴포넌트가 어떤 상황을 지원하는지 예시로 남긴다.

  • 독립적인 UI 검증: 컴포넌트가 다른 컴포넌트나 백엔드 의존성 없이 독립적으로 올바르게 동작하는지 확인합니다. 이는 복잡한 애플리케이션에서 특정 UI 문제가 발생했을 때 문제의 원인을 신속하게 파악하는 데 도움을 줍니다.
  • 렌더링 회귀 방지: 컴포넌트의 props나 상태가 변경될 때 텍스트, 역할, 상호작용 결과가 의도와 달라지는 회귀를 잡습니다. 픽셀·레이아웃 차이를 비교하는 시각적 회귀 검사는 별도의 스크린샷 도구가 필요합니다.
  • 재사용성 향상: 테스트 가능한 컴포넌트는 일반적으로 잘 정의된 인터페이스를 가지며, 이는 컴포넌트의 재사용성을 높이는 데 기여합니다.
  • 개발 속도 향상: 컴포넌트를 변경할 때마다 전체 애플리케이션을 실행하거나 브라우저에서 수동으로 확인하는 대신, 빠르고 자동화된 테스트를 통해 변경 사항의 영향을 즉시 확인할 수 있습니다.
  • 동작 문서화: 컴포넌트 테스트는 해당 컴포넌트가 어떤 props를 받고, 어떤 상태에서 어떤 결과를 렌더링해야 하는지 실행 가능한 예시로 남깁니다.

컴포넌트 테스트 도구 선택

Next.js에서 컴포넌트 테스트를 수행하는 데는 주로 두 가지 접근 방식이 있습니다.

Jest + @testing-library/react (RTL)
  • 특징: Node.js 환경에서 JSDOM을 사용하여 브라우저 환경을 시뮬레이션합니다. 실제 브라우저가 아니므로 빠르지만, 실제 브라우저 환경과의 완벽한 일치는 어렵습니다. 사용자 상호작용과 DOM 변경에 중점을 둡니다.
  • 장점:
    • 매우 빠름.
    • 경량이며 설정이 비교적 간단.
    • 클라이언트 사이드 로직 및 UI 인터랙션 테스트에 적합.
  • 단점: 실제 브라우저가 아니므로 특정 브라우저 환경에서만 발생하는 레이아웃 문제, 스타일 문제 등을 잡아내기 어려울 수 있습니다.
  • 사용 시점: 대부분의 클라이언트 컴포넌트의 기능적 동작 및 사용자 상호작용 테스트.
Cypress Component Testing
  • 특징: 실제 브라우저 환경에서 컴포넌트를 마운트하여 테스트합니다. E2E 테스트와 동일한 Cypress 러너와 API를 사용합니다.
  • 장점:
    • 실제 브라우저 환경에서 실행되므로, 스타일, 레이아웃, 반응형 디자인 등 시각적 측면까지 더 정확하게 테스트할 수 있습니다.
    • E2E 테스트와 동일한 워크플로우와 디버깅 경험을 제공.
    • 네트워크 모킹, 시간 제어 등 Cypress 기능을 컴포넌트 레벨에서 활용 가능.
  • 단점: Jest/RTL에 비해 상대적으로 느릴 수 있습니다. 설정이 Jest보다 복잡할 수 있습니다.
  • 사용 시점: 시각적 정확성, 복잡한 CSS 상호작용, 또는 브라우저 특정 동작이 중요한 컴포넌트 테스트.

대부분의 경우 Jest와 RTL 조합이 컴포넌트 테스트에 가장 널리 사용되며 효율적입니다.

Cypress 컴포넌트 테스트는 특정 시각적 또는 브라우저 관련 문제가 중요할 때 보완적으로 사용될 수 있습니다.

여기서는 Jest와 RTL을 중심으로 설명합니다.


Jest + @testing-library/react

14장 1절 단위 테스트 설정 (Jest)에서 Jest와 @testing-library/react의 설치 및 기본 설정(jest.config.js, jest.setup.js)은 이미 다루었습니다.

이 절에서는 실제 컴포넌트 테스트 예시를 통해 활용 방법을 심화합니다.

테스트할 컴포넌트 예시

버튼을 클릭하면 숫자가 증가/감소하는 간단한 카운터 컴포넌트를 테스트합니다.

components/Counter.tsx
"use client"; // 클라이언트 컴포넌트

import React, { useState } from 'react';

export default function Counter() {
  const [count, setCount] = useState(0);

  const increment = () => setCount(prev => prev + 1);
  const decrement = () => setCount(prev => prev - 1);

  return (
    <div style={{ padding: '20px', border: '1px solid #00BCD4', borderRadius: '8px', textAlign: 'center' }}>
      <h2 style={{ color: '#00BCD4' }}>카운터</h2>
      <p style={{ fontSize: '2.5em', margin: '20px 0' }} data-testid="count-value">
        {count}
      </p>
      <div style={{ display: 'flex', justifyContent: 'center', gap: '15px' }}>
        <button
          onClick={decrement}
          style={{ padding: '10px 20px', fontSize: '1.2em', backgroundColor: '#FF5722', color: 'white', border: 'none', borderRadius: '5px', cursor: 'pointer' }}
        >
          감소
        </button>
        <button
          onClick={increment}
          style={{ padding: '10px 20px', fontSize: '1.2em', backgroundColor: '#4CAF50', color: 'white', border: 'none', borderRadius: '5px', cursor: 'pointer' }}
        >
          증가
        </button>
      </div>
    </div>
  );
}

Counter 컴포넌트 테스트 작성

__tests__/Counter.test.tsx
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react'; // RTL 유틸리티 임포트
import Counter from '../components/Counter'; // 테스트할 컴포넌트 임포트

describe('Counter 컴포넌트', () => {
  test('초기 카운트 값은 0이어야 합니다.', () => {
    render(<Counter />); // Counter 컴포넌트를 렌더링합니다.

    // data-testid로 요소를 찾고 텍스트 내용이 '0'인지 확인합니다.
    expect(screen.getByTestId('count-value')).toHaveTextContent('0');
  });

  test('증가 버튼 클릭 시 카운트 값이 1 증가해야 합니다.', () => {
    render(<Counter />);

    const incrementButton = screen.getByRole('button', { name: '증가' }); // '증가' 텍스트를 가진 버튼을 찾습니다.
    fireEvent.click(incrementButton); // 버튼 클릭 이벤트를 발생시킵니다.

    // 카운트 값이 '1'로 변경되었는지 확인합니다.
    expect(screen.getByTestId('count-value')).toHaveTextContent('1');
  });

  test('감소 버튼 클릭 시 카운트 값이 1 감소해야 합니다.', () => {
    render(<Counter />);

    const decrementButton = screen.getByRole('button', { name: '감소' }); // '감소' 텍스트를 가진 버튼을 찾습니다.
    fireEvent.click(decrementButton); // 버튼 클릭 이벤트를 발생시킵니다.

    // 카운트 값이 '-1'로 변경되었는지 확인합니다.
    expect(screen.getByTestId('count-value')).toHaveTextContent('-1');
  });

  test('증가 후 감소 버튼을 클릭하면 초기값으로 돌아와야 합니다.', () => {
    render(<Counter />);

    const incrementButton = screen.getByRole('button', { name: '증가' });
    const decrementButton = screen.getByRole('button', { name: '감소' });

    fireEvent.click(incrementButton); // 0 -> 1
    expect(screen.getByTestId('count-value')).toHaveTextContent('1');

    fireEvent.click(decrementButton); // 1 -> 0
    expect(screen.getByTestId('count-value')).toHaveTextContent('0');
  });
});

Counter 예제의 테스트 코드는 render, screen, 사용자 이벤트, Jest matcher가 한 흐름으로 이어진다고 보면 읽기 쉽습니다.

Counter 테스트 코드는 렌더, 선택, 이벤트, 검증 순서로 읽는다

코드가 길어 보여도 네 동작으로 나누면 간단합니다. 컴포넌트를 올리고, 사용자가 찾을 요소를 고르고, 행동을 발생시키고, 화면 결과를 확인합니다.

  1. 1. 렌더

    렌더 render(<Counter />) JSDOM에 컴포넌트를 올린다.

  2. 2. 선택

    선택 screen.getByRole 사용자가 찾을 수 있는 이름과 role로 버튼을 찾는다.

  3. 3. 이벤트

    벤트 fireEvent.click 증가 또는 감소 버튼을 클릭한다.

  4. 4. 검증

    검증 toHaveTextContent 카운트 값이 기대한 숫자로 바뀌었는지 본다.

설명
  • render(<Component />): 컴포넌트를 JSDOM 환경에 렌더링하고, 렌더링된 컴포넌트와 상호작용할 수 있는 쿼리 함수들을 반환합니다.
  • screen: @testing-library/react에서 별도로 import하는 문서 전체 쿼리 객체입니다. render가 DOM을 준비한 뒤 screen.getByRole()처럼 사용합니다. 특정 렌더 컨테이너에만 질의하려면 render의 반환값이나 within을 사용합니다.
  • 쿼리 함수: @testing-library/react는 컴포넌트의 내부 구현(state, props)이 아닌, 사용자가 화면에서 실제로 보고 상호작용하는 방식으로 요소를 찾는 다양한 쿼리 함수를 제공합니다.
    • getByRole(): 접근성 트리를 기반으로 요소를 찾습니다. 가장 권장되는 방법입니다 (예: button, heading, textbox).
    • getByText(): 텍스트 내용으로 요소를 찾습니다.
    • getByTestId(): data-testid 속성을 사용하여 요소를 찾습니다. 테스트 전용 속성이므로, 최종 제품 코드에는 영향을 주지 않으면서 테스트에서 특정 요소를 안정적으로 선택하는 데 유용합니다.
  • fireEvent.click(): 특정 요소에 클릭 이벤트를 발생시킵니다. fireEvent 외에도 user-event 라이브러리가 사용자 행동을 더 실제적으로 모방합니다 (예: user.type, user.click). npm install --save-dev @testing-library/user-event로 설치하여 사용할 수 있습니다.
  • expect().toHaveTextContent(), expect().toBeInTheDocument(): Jest의 매처와 @testing-library/jest-dom의 확장 매처를 사용하여 렌더링된 DOM 요소의 속성을 검증합니다.

컴포넌트 테스트 시 고려사항 및 팁

컴포넌트 테스트는 위험과 관찰 범위로 깊이를 정한다

모든 조합을 재현하기보다 실패 영향이 큰 계약을 실제에 가까운 환경에서 확인한다.

  1. 위험 높음 · DOM 충분
    RTL 집중

    폼 검증·접근성·상태 분기

  2. 위험 높음 · 브라우저 필요
    CT 또는 E2E

    layout·focus·portal·실제 API 통합

  3. 위험 낮음 · 순수
    작은 단위

    formatter·validator 같은 결정 함수

  4. 위험 낮음 · 장식
    생략

    프레임워크가 보장하는 단순 markup

  • 테스트의 격리: 각 컴포넌트 테스트는 독립적이어야 합니다. 한 테스트의 결과가 다른 테스트에 영향을 주지 않도록 beforeEachafterEach 훅을 사용하여 상태를 초기화하거나 정리합니다.
  • 모킹 활용: 컴포넌트가 외부 API 호출, 전역 상태 관리(Redux, Zustand), Context API 등 외부 의존성을 가진다면, Jest의 모킹 기능을 사용하여 이러한 의존성을 격리합니다.
    • Context 모킹: MyContext.Provider를 테스트 렌더링 시 감싸서 모킹된 값을 제공합니다.
    • 훅 모킹: jest.mock()을 사용하여 특정 훅(예: useRouter)을 모킹하여 제어된 값을 반환하도록 합니다.
  • Server Components의 제한: Next.js의 Server Components는 클라이언트에서 실행되지 않으므로 @testing-library/react로 직접 테스트하기 어렵습니다. Server Components가 데이터를 가져와 클라이언트 컴포넌트에 props로 전달하는 패턴이라면, 클라이언트 컴포넌트를 테스트하면서 props로 전달되는 데이터를 모킹하는 방식으로 접근합니다.
  • CSS Modules/Tailwind CSS: 앞에서 사용한 next/jest가 CSS·Sass와 CSS Module을 자동으로 모킹하므로 별도의 identity-obj-proxy 설정이 필요하지 않습니다. 직접 module.exports를 새 객체로 덮어쓰면 Next.js의 SWC 변환과 이미지·폰트 모킹 설정을 잃을 수 있으므로, 추가 옵션이 필요할 때만 기존 customJestConfig에 병합합니다.
  • 스냅샷 테스트 (Snapshot Testing): React 19에서 deprecated된 react-test-renderer 대신 React Testing Library의 asFragment()를 사용할 수 있습니다. 하지만 스냅샷은 의도를 명확히 드러내기 어려우므로 작은 안정적 출력에만 보조적으로 사용합니다.
    import { render } from '@testing-library/react';
    import MyComponent from '../components/MyComponent';
    
    test('MyComponent matches snapshot', () => {
      const { asFragment } = render(<MyComponent prop1="value" />);
      expect(asFragment()).toMatchSnapshot();
    });
  • 테스트 커버리지: Jest는 테스트 커버리지 보고서를 생성하는 기능을 제공합니다. 이를 통해 테스트되지 않은 코드 부분을 파악하고 테스트를 보강할 수 있습니다. (jest.config.jscollectCoverage 옵션)

다음 다이어그램은 컴포넌트 테스트에서 Jest/RTL과 Cypress CT를 나누어 쓰는 기준입니다.

RTL과 Cypress CT는 필요한 브라우저 현실성으로 고른다

도구 선호가 아니라 검증할 계약이 DOM simulation으로 충분한지 판단한다.

  1. Jest + RTL
    빠른 DOM 계약

    props·event·async state·접근성 질의를 빠르게 반복

  2. Cypress CT
    실제 브라우저

    CSS layout·focus·portal·브라우저 API를 시각적으로 확인

  3. 공통
    사용자 관점

    role·label·visible text로 상호작용

  4. 선택 기준
    실패 비용

    더 느린 도구는 브라우저 차이가 중요한 계약에만 사용

컴포넌트 테스트는 UI의 안정성과 사용자 경험을 보장하는 데 필수적인 부분입니다.

컴포넌트 테스트는 주요 UI 상태와 사용자 상호작용을 컴포넌트 단위로 검증하는 데 사용합니다.

다음 다이어그램은 어떤 UI를 컴포넌트 테스트로 두면 좋은지 정리합니다.

컴포넌트 테스트 사용 위치

모든 화면을 컴포넌트 테스트로 덮는 것이 목표가 아닙니다. props와 이벤트로 충분히 재현되는 UI 계약을 고르는 것이 핵심입니다.

  1. 컴포넌트 테스트 사용 위치 컴포넌트 테스트

    UI 상태와 사용자 상호작용의 경계에 둔다

  2. 모든 화면

    컴포넌트 테스트로 덮는 것이 목표가 아닙니다.

  3. props

    이벤트로 충분히 재현되는 UI 계약을 고르는 것이 핵심입니다.

마지막으로 컴포넌트 테스트에서 테스트 경계, 선택자, 상태, 의존성 mock을 점검합니다.

컴포넌트 테스트는 네 가지 품질 gate를 통과해야 한다

통과 개수보다 변경에 강하고 실패 이유를 빠르게 설명하는지 최종 점검한다.

  1. G1
    계약

    사용자가 관찰하는 결과를 검증

  2. G2
    격리

    network·router·시간 경계만 제어

  3. G3
    안정성

    임의 sleep·구현 selector·순서 의존 없음

  4. G4
    가독성

    준비·행동·결과와 실패 메시지가 명확

  5. G5
    비용

    중복 시나리오 없이 위험에 맞는 층에 배치