컴포넌트 테스트
Testing Library로 props에 따른 렌더링과 사용자 이벤트를 검증하고 내부 구현보다 화면에서 보이는 동작에 집중합니다.
컴포넌트 테스트(component testing)는 UI 컴포넌트가 독립적으로 올바르게 렌더링되는지, 상호작용에 따라 예상대로 동작하는지, props에 맞는 화면을 보여 주는지를 검증하는 데 집중합니다. 단위 테스트와 비슷하지만, 내부 로직뿐 아니라 화면에 보이는 출력과 사용자 경험까지 함께 본다는 점에서 범위가 더 넓습니다. Next.js에서는 React 컴포넌트가 화면을 이루는 핵심 단위이므로, 컴포넌트 테스트가 애플리케이션의 안정성과 UI 품질을 크게 좌우합니다. 이 글에서는 @testing-library/react와 Jest의 조합, 그리고 Cypress 컴포넌트 테스트를 비교하고, 효과적인 테스트 작성 전략을 정리합니다.
컴포넌트 테스트의 중요성
컴포넌트 테스트는 props, 사용자 이벤트, 렌더링 결과를 한 단위로 묶어 검증합니다. 컴포넌트가 다른 컴포넌트나 백엔드 의존성 없이 혼자서도 올바르게 동작하는지 확인하므로, 복잡한 애플리케이션에서 특정 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보다 상대적으로 느릴 수 있고 설정도 더 복잡할 수 있습니다. 시각적인 정확성, 복잡한 CSS 상호작용, 특정 브라우저의 동작이 중요한 컴포넌트를 테스트할 때 씁니다.
대부분의 경우 Jest와 RTL 조합이 컴포넌트 테스트에 가장 널리 쓰이고 효율적이며, Cypress 컴포넌트 테스트는 시각적인 문제나 브라우저 관련 문제가 중요할 때 보완하는 용도로 씁니다. 여기서는 Jest와 RTL을 중심으로 설명합니다.
Jest + @testing-library/react
Jest와 @testing-library/react의 설치와 기본 설정(jest.config.js, jest.setup.ts)은 14장 1절에서 이미 다루었으므로, 이 글에서는 실제 컴포넌트 테스트 예시로 활용 방법을 더 깊이 살펴봅니다. 예로 쓸 컴포넌트는 상태 하나와 버튼 두 개만 있는 Counter입니다. 컴포넌트를 렌더링하고, 사용자처럼 버튼을 찾아 누른 뒤, 화면의 숫자가 기대한 값인지 확인하는 세 단계가 모든 테스트의 기본 형식입니다. 테스트 코드 뒤에서는 이때 쓴 render, screen, 쿼리 함수, fireEvent가 각각 무엇을 하는지 정리합니다.
테스트할 컴포넌트 예시
버튼을 누르면 숫자가 늘거나 줄어드는 간단한 카운터 컴포넌트를 테스트합니다. 상태는 count 하나이고 처음 값은 0이며, 감소와 증가 버튼이 이 값을 1씩 바꿉니다. 사용자 입장에서 확인할 것은 버튼을 누른 뒤 화면의 숫자가 기대한 값으로 바뀌는지입니다.
"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>
);
}버튼에는 aria-label 같은 속성이 없으므로, 각 버튼의 접근 가능한 이름은 안에 든 글자인 감소와 증가가 됩니다. 아래 테스트는 이 이름으로 버튼을 찾습니다. 반면 숫자를 보여 주는 <p>는 역할이나 이름으로 구분하기 어려워서 data-testid="count-value"를 붙여 두고 그 값으로 찾습니다.
Counter 컴포넌트 테스트 작성
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를 0에서 시작합니다. 숫자는 문자열 전체를 비교해야, 1을 기대하는 검사가 10이나 -1을 잘못 통과시키지 않습니다. 이 규칙은 toHaveTextContent 부분 일치 규칙에서 확인할 수 있습니다. 네 테스트가 기대하는 결과를 정리하면 다음과 같습니다.
| 사용자 동작 | 기대하는 count 텍스트 |
|---|---|
| 첫 렌더 | 0 |
| 증가 한 번 | 1 |
| 감소 한 번 | -1 |
| 증가 후 감소 | 1을 확인한 뒤 0 |
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 속성으로 요소를 찾습니다. getByTestId()는 DOM에 테스트 전용 식별자를 더하는 방식이므로, 역할이나 이름으로 구분하기 어려운 요소에 씁니다.
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 요소의 속성을 검증합니다.
컴포넌트 테스트 시 고려사항 및 팁
각 컴포넌트 테스트는 독립적이어야 합니다. 한 테스트의 결과가 다른 테스트에 영향을 주지 않도록 beforeEach나 afterEach 훅으로 상태를 초기화하거나 정리합니다. 컴포넌트가 외부 API 호출, Redux나 Zustand 같은 전역 상태 관리, Context API 같은 외부 의존성을 가진다면 Jest의 모킹 기능으로 격리합니다. Context는 테스트에서 렌더링할 때 컴포넌트를 MyContext.Provider로 감싸 모의 값을 주고, useRouter 같은 훅은 jest.mock()으로 모킹해 정해진 값을 돌려주게 합니다.
Jest는 async 서버 컴포넌트를 직접 지원하지 않지만, 그렇다고 동기 서버 컴포넌트까지 모두 지원하지 않는다는 뜻은 아닙니다(Next.js의 Jest 지원 범위). 서버 컴포넌트가 데이터를 가져와 클라이언트 컴포넌트에 props로 넘기는 패턴이라면, 클라이언트 컴포넌트를 테스트하면서 넘어오는 데이터를 props로 모킹하는 방식으로 접근합니다.
CSS 모듈과 Tailwind CSS는 앞에서 쓴 next/jest가 CSS, Sass, CSS 모듈을 자동으로 모킹하므로 별도의 identity-obj-proxy 설정이 필요하지 않습니다. module.exports를 새 객체로 직접 덮어쓰면 Next.js의 SWC 변환과 이미지, 폰트 모킹 설정을 잃을 수 있으므로, 추가 옵션이 필요할 때만 기존 customJestConfig에 합칩니다.
스냅숏 테스트(snapshot testing)에는 React 19에서 사용 중단된 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.js의 collectCoverage 옵션으로 테스트되지 않은 코드를 파악하고 테스트를 보강할 수 있습니다.
글자, 상태, 이벤트는 Jest와 RTL로 확인하고, 실제 레이아웃이나 브라우저 API는 Cypress 컴포넌트 테스트 같은 브라우저 실행에서 확인합니다. 컴포넌트 테스트는 Next.js 서버 전체를 실행하는 것과 같지 않으므로, 서버 데이터 로딩까지 이어진 페이지는 별도의 E2E 범위로 둡니다. 실패한 테스트에는 입력 prop, 사용자 동작, 기대한 DOM, 대역으로 바꾼 의존성을 남깁니다.
정리
- 컴포넌트 테스트는 props, 사용자 이벤트, 렌더링 결과를 한 단위로 묶어, 컴포넌트가 혼자서도 올바르게 동작하는지 검증합니다.
- Jest와 RTL은 JSDOM에서 빠르게 기능과 상호작용을 검사하고, Cypress 컴포넌트 테스트는 실제 브라우저에서 레이아웃과 스타일까지 검사합니다.
- 요소는 가능하면
getByRole로 찾고, 역할로 구분하기 어려운 요소에만data-testid를 씁니다. - 숫자처럼 부분 일치로 잘못 통과할 수 있는 글자는
/^1$/처럼 전체를 비교합니다. next/jest가 CSS와 CSS 모듈을 자동으로 모킹하며, 스냅숏은asFragment()로 작고 안정적인 출력에만 보조로 씁니다.
연습 문제
1. 새 순서를 직접 확인하기
새 Counter에서 증가를 두 번, 감소를 한 번 실행하면 어떤 문자열을 기대해야 할까요? 이어서 별도의 테스트가 Counter를 새로 렌더링하면 시작값은 무엇일까요? 기대값을 /1/로 쓰면 10과 -1도 일치하는지 확인해 보세요.
해설 보기
함수형 갱신은 직전 값에 1을 더하거나 빼므로 0, 1, 2, 1 순서로 바뀌어 1을 기대합니다. 격리된 별도의 테스트에서 새로 렌더링한 Counter는 다시 0에서 시작합니다. /1/은 부분 일치이므로 10과 -1에도 맞지만, /^1$/은 전체가 1인 문자열만 통과시킵니다.
테스트에 최종값뿐 아니라 두 번째 증가 뒤의 2도 기대값으로 넣어 두면, 어느 단계에서 동작이 달라졌는지 찾기 쉽습니다. 숫자 변화를 확인한 검사가 버튼의 픽셀 배치나 실제 화면 크기까지 비교한 것은 아닙니다.
2. fireEvent와 user-event
fireEvent.click(incrementButton)을 @testing-library/user-event의 await user.click(incrementButton)으로 바꾸려고 합니다. 테스트 코드에서 함께 바꿔야 할 점은 무엇이며, 두 방식은 무엇이 다를까요?
해설 보기
먼저 const user = userEvent.setup();으로 사용자 인스턴스를 만들고, user.click()은 Promise를 돌려주므로 테스트 함수를 async로 바꾸고 await를 붙여야 합니다. fireEvent.click은 클릭 이벤트 하나만 곧바로 일으키지만, user.click은 포인터 이동, 누르기, 떼기, 포커스 변화처럼 실제 사용자가 클릭할 때 일어나는 일련의 이벤트를 차례로 일으킵니다. 그래서 비활성화된 버튼처럼 실제로는 누를 수 없는 요소를 다루는 경우에도 실제 동작에 더 가깝게 검사할 수 있습니다.
3. 역할 이름이 바뀌었을 때
카운터의 "증가" 버튼 글자를 "+1"로 바꿨더니 screen.getByRole('button', { name: '증가' })를 쓰는 테스트가 실패했습니다. 테스트를 그대로 두고 화면 글자만 "+1"로 바꾸면서 접근할 수 있는 이름은 "증가"로 유지하려면 컴포넌트를 어떻게 고치면 좋을까요?
해설 보기
버튼에 aria-label="증가"를 붙이면, 화면에는 "+1"이 보이면서 보조 기술과 getByRole이 읽는 접근할 수 있는 이름은 "증가"가 됩니다. 이렇게 하면 테스트가 사용자에게 의미 있는 이름을 기준으로 버튼을 찾게 되어, 화면 글자의 모양이 바뀌어도 테스트를 고칠 필요가 줄어듭니다. 다만 화면 글자와 이름이 너무 달라 사용자가 혼란스럽지 않도록 의미를 맞춥니다.
React Testing Library의 쿼리 우선순위와 사용 방법은 Testing Library의 쿼리 안내에서 확인할 수 있습니다.
이것으로 14장을 마칩니다. 14장에서는 Jest로 단위 테스트를 설정하고, 클라이언트 컴포넌트의 통합 테스트와 컴포넌트 테스트를 작성하고, Cypress로 E2E 테스트를 구현하는 방법을 정리했습니다. 다음 장의 Vercel 배포 과정부터는 완성한 Next.js 애플리케이션을 배포하는 방법을 알아봅니다.