점심 전 작은 UI 변경 사항을 push합니다. 무해해 보입니다. 버튼 라벨이 바뀌고, 조건부 렌더링이 단순해지며, 헬퍼 훅에 새 분기가 하나 추가됩니다. Pull request는 깔끔하고 리뷰도 빠르며 배포도 순조롭게 진행됩니다.
한 시간 후, 지원 팀이 특정 플랫폼에서 로그인이 작동하지 않는다고 보고합니다. Web은 정상입니다. 데스크톱 셸에는 오래된 렌더링 경로가 남아 있고, 모바일 빌드는 비동기 상태 변경 후 다른 방식으로 동작합니다. code에 테스트가 있었지만, 올바른 테스트가 아니었고, 그 테스트를 둘러싼 신뢰할 수 있는 시스템은 더더욱 없었기 때문에 아무도 이를 잡아내지 못했습니다.
프로덕션 팀에서 React를 단위 테스트할 때의 주요 문제입니다. 몇 개의 통과하는 테스트를 작성하는 것은 어렵지 않습니다. 리팩토링, 릴리스 트레인, 핫픽스, 크로스 플랫폼 패키징 중에 여전히 팀을 보호할 수 있는 테스트 스위트를 구축하는 것이 어려운 부분입니다. React 앱은 팀이 어떻게 호출하는지 잊어서 실패하지 않습니다 render(). 테스트가 구현 세부 사항으로 기울어지고, 비동기 동작이 무시되며, CI가 테스트를 릴리스 게이트가 아닌 체크박스처럼 대하기 때문에 실패합니다.
현대적인 React 단위 테스트는 안전 시스템처럼 작동할 때 효과적입니다. 로컬에서 빠른 피드백, CI에서 결정론적인 체크, 단위 테스트에 포함될 것과 포함되지 않을 것 사이의 명확한 경계. 동일한 React 코드베이스가 브라우저, Capacitor 컨테이너, 또는 Electron 셸을 통해 배포될 때 이는 더욱 중요합니다.
목차
- React 유닛 테스트가 최고의 안전망인 이유
- 모던 React 테스트 환경 구축하기
- 의미 있는 컴포넌트 테스트 작성하기
- Custom Hook 및 애플리케이션 로직 테스트
- 고급 기법 마스터하기: 목킹과 비동기
- 테스트 품질 및 전략 개선
- 크로스 플랫폼 CI/CD 파이프라인에 테스트 통합하기
React 유닛 테스트가 최고의 안전망인 이유
단위 테스트는 자신 있게 '일어날 수 없다'고 생각했던 실수를 잡아낼 때 비로소 그 가치를 증명합니다. React에서는 컴포넌트가 여전히 렌더링되지만, 사용자가 의존하는 동작이 바뀌었을 때를 의미합니다. 비활성화된 버튼이 클릭 가능해지거나, 로딩 상태가 사라지지 않거나, 리팩토링 후 폴백 메시지가 사라지는 경우입니다. 이러한 실패는 code에서는 사소한 문제지만, 프로덕션에서는 큰 비용이 됩니다.
React 테스트는 중요한 방식으로 변화했습니다. React Testing Library가 내부 구조가 아닌 동작을 테스트하는 주류 모델이 되면서, 팀들은 컴포넌트의 props나 state가 아닌 사용자 동작을 반영하는 테스트로 방향을 전환하고 있습니다. 이는 React Native의 테스트 가이드라인에서도 확인할 수 있습니다. React Native 테스트 개요. 이러한 변화는 React code가 끊임없이 재구성되기 때문에 중요합니다. Hooks가 이동하고, 컴포넌트가 분리되며, Context가 도입됩니다. 내부 구조에 묶인 테스트는 건강한 리팩토링 중에도 깨집니다. 반면, 가시적인 동작에 묶인 테스트는 대개 유지됩니다.
유닛 테스트가 보호해야 할 대상
좋은 React 단위 테스트는 하나의 작은 계약을 보호합니다:
- 렌더링 출력: 사용자가 올바른 텍스트, 라벨, 상태, 또는 폴백을 보고 있나요?
- 인터랙션 동작: 클릭, 입력, 또는 토글 시 UI가 올바르게 변경되나요?
- 경계 처리: 기대하는 입력, 누락된 데이터, 또는 에러 경로가 주어졌을 때 컴포넌트가 올바르게 동작하는가?
약한 테스트는 잘못된 것을 보호합니다:
- 컴포넌트 내부: 상태 구조, private 메서드, 구현 전용 props
- 프레임워크 메커니즘: React가 내부적으로 hook을 정확히 기대한 방식으로 업데이트했는지 여부
- 자식 컴포넌트 상세: 여기서 검증할 생각이 없는 중첩 컴포넌트의 마크업
실무 원칙: 사용자가 보는 것과 동작이 바뀌지 않게 컴포넌트를 리팩터링할 수 있다면, 테스트도 바뀔 필요가 없어야 합니다.
단위 테스트는 더 넓은 테스트 시스템의 일부입니다. 앱 전체가 end-to-end로 동작함을 증명하려는 게 아닙니다. 브라우저 수준 테스트나 디바이스 수준 검증으로 넘어가기 전에 회귀를 빠르게 잡아내는 레이어입니다. 그래서 단위 테스트는 합리적인 자동화 테스트 스택의 첫 번째 방어선입니다. 프로덕션 앱을 위한 자동화 테스트.
자주 배포하는 React 팀에게 이런 역할 분담은 확신을 줍니다. 단위 테스트는 로컬 회귀를 빠르게 잡고, 통합 테스트는 이음새를 검증하며, end-to-end 테스트는 핵심 경로를 확인합니다. 단위 테스트 레이어를 건너뛰면 뒤따르는 느린 단계들이 너무 많은 부담을 떠안게 됩니다.
모던 React 테스트 환경 구축하기
취약한 테스트 환경은 assertion을 하나도 작성하기 전에 flaky 테스트를 만들어냅니다. 많은 개발자가 Jest, jsdom, React를 탓하지만, 실제 근본 원인은 로컬 머신과 CI 간의 일관성 없는 설정입니다. 해결책은 환경을 지루하게 만드는 것입니다. 여기서는 지루한 게 좋습니다.

예측 가능한 runner와 환경부터 시작하세요
특히 Vite로 만든 모던 React 앱이라면, 기본 설정에 다음이 포함되어야 합니다:
- 테스트 runner: Jest는 특히 오래된 React 코드베이스와 엔터프라이즈 CI 스택에서 여전히 많이 사용됩니다.
- 브라우저와 유사한 환경:
jsdom컴포넌트 테스트에서 DOM 출력을 렌더링할 수 있게 해줍니다. - Testing Library 유틸리티:
@testing-library/react및@testing-library/jest-dom - 단일 설정 엔트리포인트: matcher와 global mock을 등록하는 하나의 파일
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컴포넌트 라이브러리에서 이를 요구한다면요.
이 설정이 없으면 개발자들은 전역 객체를 즉석에서 패치하게 됩니다. 그러면 테스트가 일관되지 않고 실패를 추적하기 어려워집니다. 한 사람은 파일에 수동 mock을 추가해 로컬 실행은 통과하지만, 설정이 공유되지 않아 CI에서는 실패합니다.
로컬과 CI 동작을 일치시키세요
로컬 명령은 CI 명령과 최대한 일치해야 합니다. 개발자는 느슨한 기본값으로 watch 모드를 실행하는데 CI가 더 엄격한 설정으로 실행되면, 머지 후 예상치 못한 실패가 발생합니다. 스크립트는 명시적으로 작성하세요:
{
"scripts": {
"test": "jest",
"test:watch": "jest --watch",
"test:ci": "jest --runInBand --coverage"
}
}
간단한 가이드는 신규 팀원이 빠르게 동일한 기준을 잡는 데 도움이 됩니다:
가장 영향력 있는 설정 선택은 기본값을 일관되게 관리하는 것입니다. 별칭은 config에, 환경 mock은 setup 파일 하나에 넣으세요. UI 테스트에는 환경 mock을 jsdom 사용하고, 가능한 경우 순수 유틸리티에는 더 가벼운 환경을 사용하세요. 각 테스트에 필요한 커스텀 동작이 적을수록 시스템은 더 신뢰할 수 있게 됩니다.
의미 있는 컴포넌트 테스트 작성하기
조직은 테스트 작성 자체에 문제가 있는 게 아닙니다. 문제는 6개월 뒤에도 여전히 의미 있는 테스트를 작성하는 것입니다.
React 컴포넌트 단위 테스트의 표준 패턴은 여전히 유효합니다: 컴포넌트를 렌더링하고, 사용자 중심 셀렉터로 UI를 쿼리한 뒤 상호작용을 트리거하고, 결과 DOM 변경을 검증합니다, 이는 state나 props 같은 구현 세부 사항에서 테스트를 분리하며, React 테스트 가이드에 설명된 방식입니다. 핵심은 이 패턴을 절제해서 적용하는 것입니다.
사용자 관점에서 아코디언 테스트하기
기본적인 Accordion 컴포넌트를 생각해 보세요. 제목이 있는 버튼을 렌더링합니다. 패널 콘텐츠는 처음에는 숨겨져 있습니다. 버튼을 클릭하면 콘텐츠가 표시되고 접근성 상태가 업데이트됩니다.
이 정도 동작만으로도 몇 가지 유용한 테스트를 작성할 수 있습니다:
- 초기 렌더링에서는 제목이 보이지만 콘텐츠는 보이지 않습니다.
- 트리거를 클릭하면 콘텐츠가 표시됩니다.
- 다시 클릭하면 접힙니다.
- 접근성 속성은 현재 표시 상태와 일치합니다.
마지막 항목은 자주 놓치기 쉽습니다. 컴포넌트가 aria-expanded, aria-controls또는 역할 기반 구조를 사용한다면 이를 검증해야 합니다. 이는 구현 세부 사항이 아니라 사용자 계약의 일부입니다.
최고의 컴포넌트 테스트는 절대 보고 싶지 않은 버그 리포트처럼 읽힙니다.
의도에 따른 쿼리 선택하기
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');
});
무엇이 빠져 있는지도 그만큼 중요합니다. 내부 상태에 대한 어설션이 없습니다. setOpen 이것이 호출되었는지 확인하는 것도 없습니다. 렌더링된 전체 트리의 스냅샷도 없습니다. 그런 테스트는 확신이 아니라 유지보수 부담만 늘립니다.
컴포넌트 테스트를 더 강력하게 만드는 몇 가지 습관이 있습니다:
- 역할 기반 쿼리를 우선하세요: 버튼, 헤딩, 다이얼로그, 알림, 입력 필드는 보통 역할로 찾아야 합니다.
- 각 테스트를 좁게 유지하세요: 테스트당 사용자 가시적 동작 하나만 검증하면 실패 원인을 읽기 쉽습니다.
- 테스트 이름을 결과로 지으세요: “열리면 aria-expanded가 업데이트된다”는 “정상 작동한다”보다 훨씬 유용합니다.
DOM을 통해 테스트하기 어려운 컴포넌트는 설계 문제를 드러내는 경우가 많습니다. 상태가 잘못된 곳에 숨겨져 있거나, 시맨틱 마크업이 부족할 수 있습니다. 좋은 테스트는 팀을 더 나은 컴포넌트로 이끕니다.
Custom Hook 및 애플리케이션 로직 테스트
React 앱은 컴포넌트 밖에도 중요한 동작이 많습니다. 상태 전이는 훅에, 검증과 포맷팅은 헬퍼 함수에, 데이터整形은 렌더링 전에 이루어집니다. 가시적 컴포넌트만 테스트하면 프로덕션 동작을 깨뜨릴 수 있는 code의 상당 부분을 놓칩니다.
훅은 React 인식 하네스가 필요합니다
커스텀 훅도 제대로 실행하려면 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 자체가 단위이므로 이 테스트는 유용합니다. React 내부 구조를 테스트하는 것이 아니라, hook의 외부 동작을 검증하는 것입니다.
재사용 가능한 UI나 기능 프리미티브를 구축하는 제품 팀에게는 이 패턴이 매우 중요합니다. Hook은 앱, 디자인 시스템, 내부 도구 전반에서 공유되는 인터페이스가 되는 경우가 많습니다. 상업적 의도로 재사용 가능한 동작을 설계하고 있다면, 메이커 제품용 hooks 리소스가 hook을 단순 구현 세부 사항이 아닌 제품화된 빌딩 블록으로 포지셔닝하는 데 도움이 될 수 있습니다.
순수 로직은 테스트에서도 순수하게 유지해야 합니다
모든 것에 jsdomReact나 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도 사용합니다. - 유틸리티: DOM 없이 일반 Jest를 사용하세요.
- 상태가 있는 cross-cutting 로직: 컴포넌트 테스트가 너무 복잡해지면 로직을 테스트 가능한 헬퍼로 분리하세요.
팀들은 스택 하위에 있어야 할 로직 검증까지 컴포넌트 테스트에 과도하게 넣는 경우가 많습니다. 이 로직을 분리하면 두 가지 이점이 있습니다. 컴포넌트 테스트가 깔끔해지고, 로직 테스트 속도가 빨라집니다.
고급 기법 마스터하기: 목킹과 비동기
가장 불안정한 React 테스트 스위트는 두 곳에서 깨집니다. 의존성 경계에서, 그리고 시간 관련 처리에서입니다.
그래서 async 테스트와 mocking은 장난감 테스트 스위트와 릴리스 전 신뢰할 수 있는 테스트를 가르는 기준입니다. 한 분석에 따르면 테스트 불안정성의 46.5%가 async 타이밍과 같은 환경 또는 리소스 관련 문제에서 비롯된다고 합니다 에서 이 React 유닛 테스트 분석에서 확인됩니다. React 앱에서는 이것이 상태 전이, 지연 렌더링, 네트워크 기반 UI, 그리고 결정적으로 대기하기보다 추측하는 테스트로 직접 연결됩니다.

모든 레이어가 아닌 경계만 Mock하세요
오해의 소지가 있는 테스트를 작성하는 가장 빠른 방법은 컴포넌트 트리의 절반을 mock한 뒤, 자신의 mock이 제대로 작동했는지 단언하는 것입니다.
계정 데이터를 가져오는 컴포넌트라면 네트워크 클라이언트나 API 모듈을 mock하세요. 테스트에서 해당 지점의 격리가 반드시 필요한 경우가 아니라면 hook, 자식 row 컴포넌트, 로딩 스피너, 유틸리티 함수 3개를 모두 mock할 필요는 없습니다.
다음 규칙을 적용하세요:
- 외부 서비스 mock하기: HTTP 클라이언트, analytics, 브라우저 전용 API, native bridge
- 불안정한 플랫폼 API mock하기:
matchMedia타이머, Electron preload 인터페이스, jsdom에서 사용할 수 없는 Capacitor 플러그인 - 기본적으로 자체 내부 구현은 mock하지 않기: custom hooks, 단순한 자식 컴포넌트, 로컬 유틸리티
어려운 부분을 모두 fake로 대체해 테스트가 통과됐다면, 릴리스 신뢰도를 크게 높여주지는 못합니다.
runner API 관련 예시와 패턴을 찾는 팀이라면, Capgo 테스트 튜토리얼은 실용적인 레퍼런스 라이브러리입니다. 특히 React는 알지만 테스트 메커니즘은 아직 익숙하지 않은 개발자를 onboarding할 때 유용합니다.
타이밍이 모호하면 비동기 테스트가 실패합니다
비동기 실패는 보통 다음 세 가지 실수 중 하나로 발생합니다:
- 테스트가 너무 일찍 assert를 수행합니다.
- 테스트가 임의의 타이머로 대기합니다.
- 컴포넌트가 여러 번 업데이트되지만, 테스트는 하나의 전환만 모델링합니다.
안정적인 비동기 테스트는 보통 이런 형태를 가집니다:
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() 업데이트와 관련된 시맨틱을 고려해야 합니다. Testing Library가 많은 부분을 처리해 주지만, 상태를 직접 제어하거나 타이머를 진행시키는 경우에는 업데이트가 언제 flush되는지 여전히 고려해야 합니다.
상황에 맞는 모킹 도구 선택하기
모킹 도구마다 해결하는 문제가 다릅니다:
| 도구 | 적합한 용도 | 흔한 실수 |
|---|---|---|
jest.fn() |
독립적인 fake 콜백 또는 주입된 함수 | 단순한 콜백이면 충분한데 모듈 전체를 교체하는 데 사용하는 것 |
jest.spyOn() |
실제 객체나 모듈의 메서드 하나를 관찰하거나 오버라이드하기 | 원본 구현 복원을 잊는 것 |
jest.mock() |
import 경계에서 모듈 의존성 교체하기 | 기본적으로 대규모 모듈을 모킹해 의미 있는 동작을 잃는 것 |
예시 도움:
- 사용하세요
jest.fn()컴포넌트가onSubmitprop을 받을 때. - 사용
jest.spyOn()검증이 필요할 때console.error, 저장 방식, 또는 내보낸 API 호출일 때. - 사용
jest.mock()모듈 가져오기가 I/O, 네이티브 code, 또는 단위 경계 밖의 동작에 도달할 때.
많은 가이드가 소홀히 하는 고급 영역 중 하나는 최신 React의 에러 경로 테스트입니다. 에러 바운더리, 지연된 상태 변경, 비동기 폴백 UI는 '해피 패스' 클릭 예시뿐 아니라 일급 테스트가 필요합니다. 자식이 예외를 던지면 폴백 UI를, 요청이 실패하면 표시되는 복구 상태를, 로딩 중 버튼이 비활성화되면 그 사실도 각각 검증하세요. 사용자가 기억하는 버그는 바로 이런 것들입니다.
테스트 품질 및 전략 개선
많은 팀이 여전히 커버리지를 신뢰도와 동일시하며 쫓습니다. 하지만 둘은 다릅니다.
커버리지 목표를 달성해도 중요한 회귀 버그를 놓칠 수 있습니다. 얕은 어설션, 광범위한 스냅샷, 내부 목(mock)으로 가득한 스위트는 안전을 가장하면서도 유지보수 비용을 높입니다.

커버리지는 지도일 뿐, 목표가 아닙니다
커버리지 리포트는 '아직 보호되지 않은 중요한 경로는 어디인가?'라는 질문에 답할 때 유용합니다.
개발자를 사소한 래퍼, 정적 마크업, 또는 한 줄짜리 패스스루 파일 테스트로 몰아넣어 단순히 퍼센트만 올리게 만드는 것은 무의미합니다. 커버리지를 발견 도구로 활용하세요. 인증 상태, 결제 액션, 기능 플래그, 업데이트 프롬프트에 테스트가 없다면 이는 신호입니다. 반면, 프레젠테이션용 아이콘 컴포넌트에 테스트가 없다는 것은 보통 신호가 아닙니다.
건강한 리뷰 질문은 단순합니다: 이 테스트가 릴리스 리스크를 줄여주는가?
- 네: 임계 경로에서 사용자 가시적 동작을 검증합니다.
- 예: 리팩토링 중 쉽게 깨질 수 있는 비즈니스 로직을 보호합니다.
- 아니요: 구현 세부 사항을 검증하거나 다른 테스트의 가치를 중복합니다.
단위 테스트하지 말아야 할 것
많은 React 가이드는 생략에 충분한 시간을 할애하지 않습니다. 이 공백은 중요합니다. 과도한 목킹과 구현 세부 사항 테스트는 사용자 경험은 여전히 깨지는데도 통과하는 취약한 테스트 스위트를 만듭니다. BrowserStack의 가이드에서 지적하듯 React에서 단위 테스트하지 말아야 할 것.
다음 패턴은 건너뛰거나 엄격히 제한하세요:
- 내부 상태 단언: 직접 테스트하지 마세요
isOpen패널이 열렸는지 테스트할 수 있다면 - 프레임워크 동작: React가 effect를 호출했는지 테스트하지 마세요. effect가 바꾸는 결과를 테스트하세요.
- 서드파티 라이브러리 내부: 라이브러리 자체의 렌더링 로직이 아니라, 날짜 선택기나 라우터와의 통합을 테스트하세요.
- 과도하게 잘게 쪼갠 단위: 모든 자식 컴포넌트와 헬퍼를 mock했다면, 더 이상 의미 있는 동작을 테스트하고 있지 않을 수 있습니다.
나쁜 테스트는 테스트가 없는 것보다 더 나쁩니다. 리팩터링을 방해하면서도 정작 프로덕션 버그는 잡아내지 못하기 때문입니다.
유용한 휴리스틱은 경계 소유권입니다. code이 소유한 것을 테스트하세요. 통합 레이어가 계약을 바꾸지 않는 한, React, 브라우저, 성숙한 라이브러리가 이미 소유한 것은 테스트하지 마세요.
스냅샷이 도움이 될 때와 해가 될 때
스냅샷이 무용지물인 것은 아닙니다. 단지 잘못 사용하기 쉬울 뿐입니다.
광범위한 구조적 diff가 의미 있는, 출력이 안정적이고 단순한 컴포넌트에만 제한적으로 사용하세요. 상호작용이 많거나 매우 동적인 컴포넌트에는 피하세요. 노이즈가 되어 개발자들이 내용을 읽지 않고 습관적으로 업데이트하게 되기 때문입니다.
더 나은 대안이 보통 있습니다:
- 조건부 렌더링의 경우, 주요 텍스트의 존재 여부를 확인하세요.
- 시각적 상태 변화의 경우, 중요한 role, label, attribute를 확인하세요.
- 에러와 폴백의 경우, 실제 메시지나 alert 영역을 확인하세요.
팀에 유닛 테스트를 넘어 더 넓은 품질 프로세스가 필요하다면, 좋은 동반자는 앱 품질 보증 워크플로우 테스트, 릴리스 체크, 롤백 계획을 하나의 시스템으로 다루는 것입니다. 테스트 품질을 가장 빠르게 높이는 마인드셋 전환입니다. 테스트 개수를 묻는 것을 멈추고, 어떤 실패가 사용자에게 도달할 수 있는지부터 묻기 시작하세요.
크로스 플랫폼 CI/CD 파이프라인에 테스트 통합하기
개발자 노트북에서만 실행되는 테스트 스위트는 제안일 뿐, 통제가 아닙니다.
모든 pull request가 깨끗한 환경에서 동일한 체크를 실행하고, 체크 실패 시 머지를 차단할 때 비로소 스위트가 운영 수준이 됩니다. 당연해 보이지만, 여전히 많은 팀에 치명적인 공백이 있습니다. 테스트는 수동으로 실행되고, 커버리지 리포트는 선택 사항이며, 패키징 및 릴리스 작업이 테스트 작업 완료 전에 시작됩니다. 작은 UI 회귀가 큰 릴리스 실패로 이어지는 방식입니다.

풀 리퀘스트는 매번 동일한 게이트를 트리거해야 합니다
React 단위 테스트가 안전망 역할을 하려면 CI에 몇 가지 필수 요소가 필요합니다:
- 모든 풀 리퀘스트에서 실행
- lockfile에서 의존성 설치
- 매번 동일한 테스트 명령 사용
- 테스트 실패 시 빠르게 실패 처리
- 테스트 통과 후에만 아티팩트 게시
이것은 앱 팀의 지속적 배포(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 앱: 렌더러 컴포넌트는 의도적으로 mock하지 않는 한 일반 브라우저 테스트에 없는 preload API, window 메시징, 데스크톱 전용 상태에 의존할 수 있습니다.
- 공유 릴리스 트레인: 배포 프로세스에서 게시를 엄격히 통제하지 않으면 잘못된 번들 하나가 여러 타깃에 영향을 줄 수 있습니다.
그래서 단위 테스트는 패키징 작업 전에, 패키징 작업은 배포 작업 전에 실행되어야 합니다. 각 단계는 리스크를 좁혀줍니다. 단위 테스트는 로컬 회귀를 빠르게 잡고, 플랫폼 패키징은 환경 전제를 검증하며, 최종 릴리스 신뢰성은 수동 승인이나 단계적 롤아웃으로 확보합니다.
실용적인 GitHub Actions 워크플로
더 성숙한 파이프라인은 보통 책임을 분리합니다:
- 테스트 잡: 빠른 유닛 및 훅 테스트
- 빌드 잡: 테스트 통과 후 프로덕션 빌드만 수행
- 패키징 잡: Capacitor 동기화, Electron 패키징 또는 아티팩트 번들링
- 릴리스 잡: 승인된 브랜치나 태그에서만 게시
Capacitor 또는 Electron 앱에 라이브 업데이트를 배포하는 팀이라면, 여기서 릴리스 툴링이 중요합니다. 해당 워크플로우의 한 옵션은 Capgo입니다. CapacitorJS 및 Electron 앱용 서명된 웹 번들을 게시하며, 롤백 지원과 채널 기반 롤아웃 제어를 제공합니다. 실제로는 React 테스트 잡이 웹 번들이 스테이징 또는 프로덕션 배포로 승격되기 전의 첫 번째 하드 게이트 역할을 하게 됩니다.
운영 원칙은 단순합니다. 취약한 테스트를 릴리스 인프라로 보완하지 마세요. 신뢰할 수 있는 테스트가 이미 잘못된 변경 사항을 걸러낸 후에야 릴리스 인프라를 사용해야 합니다.
신뢰할 수 있는 테스트 시스템은 팀의 행동 방식을 바꿉니다. 엔지니어는 망설임 없이 머지를 진행합니다. 리뷰어는 수동으로 기본 사항을 재실행하는 대신 엣지 케이스에 집중합니다. 릴리스 매니저는 모든 배포를 도박처럼 취급하지 않게 됩니다. 이것이 React 유닛 테스트를 잘 수행했을 때 얻는 결과입니다.
팀이 Capacitor 또는 Electron을 통해 React를 배포한다면, 릴리스 안전성은 로컬 테스트가 모두 통과하는 것 이상의 요소에 달려 있습니다. Capgo 팀이 서명된 웹 업데이트를 게시하고, 롤아웃 채널을 지정하며, 스토어 리뷰를 기다리지 않고 잘못된 번들을 롤백할 수 있는 통제된 방법을 제공합니다. 이는 배포 전 유닛 테스트 통과를 요구하는 기존 CI 파이프라인 뒤에 자연스럽게 배치됩니다.