
들어가며
스터디에서 테스트 코드를 접했을 때 이런 생각을 했어요.
"코드가 잘 돌아가는 거 눈으로 확인했는데, 굳이 테스트 코드까지 써야 하나?"
근데 프로젝트가 커지면서 기능 하나 수정했다가 다른 기능이 터지고, 어디서 터진 건지 추적하는 데 시간을 다 쓰는 경험을 하고 나서
버그는 일찍 발견할수록 수정 비용이 작아요. 개발 중에 발견하면 커밋 하나로 끝나는 걸, 운영 중에 발견하면 hotfix 배포 리스크까지 감수해야 하니
결국 테스트 코드는 규모가 큰 프로젝트에서 장기적으로 보았을 떄 유지보수를 위해 필요한 역량이겠구나.. 라고 생각했어요.
이 글에서는 React 프로젝트에서 어떤 테스트를 어떻게 작성하면 좋은지 정리해보았습니다!
테스트의 핵심 원칙
테스트를 처음 배우면 내부 구현을 검증하고 싶어져요.
- 상태가 이 값으로 바뀌었는가
useEffect가 몇 번 실행됐는가- 특정 함수가 호출됐는가
근데 이렇게 짜면 리팩토링할 때마다 테스트가 깨지죠. 로직을 개선했는데 동작은 그대로인데도 테스트가 실패하는 상황이 생기는 거예요.
그렇다면 실제로 우리가 보장해야 하는 건 하나예요.
사용자가 경험하는 결과가 의도한 대로인가?
내부 구현이 어떻게 바뀌든, 화면에 렌더링된 결과와 상호작용이 동일하다면 테스트는 통과해야 한다는 기준이 세워져요.
테스트 유형 3가지
컴포넌트 테스트
개별 UI 컴포넌트가 제 역할을 하는지 검증해요.
Button, Input처럼 여러 곳에서 재사용되는 공통 컴포넌트가 주대상이에요. 특정 props를 넘겼을 때 렌더링이 올바른지, 이벤트 핸들러가 호출되는지를 확인해요.
개별 컴포넌트가 신뢰성을 가져야 이를 조합한 통합 테스트도 의미가 있어요.
통합 테스트
여러 컴포넌트와 비즈니스 로직이 얽혀서 하나의 기능을 완수하는지 확인해요.
로그인 시나리오를 예로 들면 이런 흐름이에요.
이메일 입력 → 버튼 클릭 → 로딩 상태 → 페이지 이동
위처럼 실제 사용자 흐름을 따라 결과를 검증할 수 있어요!
E2E 테스트
실제 브라우저에서 전체 흐름을 처음부터 끝까지 테스트해요.
단위 테스트와 통합 테스트를 통과했더라도, 실제 서버 API 연동 오류나 환경 설정 문제는 여기서 잡히게 돼요. 가장 비용이 크지만, 효과도 가장 크기 때문에
구글은 E2E 10%, 통합 20%, 단위 70% 비율로, 비용에 비례해서 비중을 조절해야 한다는 의미예요.
도구 선택
Vitest + React Testing Library (RTL)
단위 테스트와 통합 테스트에 사용해요.
Vitest는 Vite 기반 프로젝트에 최적화된 테스트 프레임워크라고 해요. Jest와 호환되는 API를 제공하면서 속도가 훨씬 빨라요. Node.js 환경에서 실행되기 때문에 jsdom으로 가상 DOM을 구성해요.
// vite.config.ts
const vitestConfig = {
test: {
globals: true,
environment: "jsdom",
setupFiles: "./src/test/setup.ts",
},
};
RTL은 렌더링된 DOM을 사용자 관점에서 탐색하는 라이브러리예요. id나 class가 아닌 Role, Label, Text 기반으로 요소를 찾아요.
RTL 쿼리는 상황에 따라 세 가지를 구분해서 써요.
| 쿼리 | 요소가 없을 때 | 비동기 | 용도 |
|---|---|---|---|
getBy... |
에러 발생 | X | 반드시 존재한다고 확신할 때 |
queryBy... |
null 반환 | X | 없어야 함을 검증할 때 |
findBy... |
에러(Promise) | O | API 통신 등 비동기 요소 |
사용자 인터랙션은 user-event를 권장해요. fireEvent는 저수준 DOM 이벤트만 발사하는 반면, user-event는 실제 브라우저처럼 전체 이벤트 시퀀스를 재현해요.
const user = userEvent.setup();
await user.type(screen.getByRole('textbox'), 'Hello Vitest');
간단한 클릭은 fireEvent로 충분하지만, 타이핑이나 복잡한 인터랙션은 user-event를 써야 실제 동작과 가까워요.
Cypress
E2E 테스트에 사용해요.
브라우저 내부에서 직접 실행되는 게 핵심이에요. 덕분에 실행 과정을 실시간으로 확인하고 디버깅하기가 훨씬 편해요.
- Time Travel — 테스트 단계별 UI 스냅샷을 되돌아볼 수 있어요
- Automatic Waiting — 요소가 나타날 때까지 자동으로 기다려줘요
describe("Todo 핵심 기능 테스트", () => {
it("할 일을 추가하고 완료 상태를 변경할 수 있다.", () => {
cy.visit("/");
cy.findByRole("textbox").type("Cypress 학습");
cy.findByRole("button", { name: /추가/ }).click();
cy.findByText("Cypress 학습").should("exist").click();
cy.findByLabelText("Cypress 학습").should("be.checked");
});
});
비슷한 도구로 Playwright도 있어요. Safari 지원, 병렬 실행, 다양한 언어 지원 등 기능은 더 많아요. 처음 E2E 테스트를 도입한다면 레퍼런스가 많고 GUI가 직관적인 Cypress가 진입 장벽이 낮아요.
Vitest + RTL vs Cypress
| 항목 | Vitest + RTL | Cypress |
|---|---|---|
| 주요 대상 | 단위 컴포넌트, 개별 기능 | 사용자 시나리오 전체 흐름 |
| 실행 환경 | Node.js (가상 DOM) | 실제 브라우저 |
| 실행 속도 | 매우 빠름 | 상대적으로 느림 |
| API 의존성 | MSW로 모킹 | 실제 서버 연동 |
한 도구만으로는 세밀한 모듈 검증부터 브라우저 전체 흐름까지 커버할 수 없기 때문에 두개의 도구를 상호 보완적으로 함께 쓸 수 있어요
MSW로 API mocking하기
통합 테스트를 짤 때 실제 API 서버에 의존하면 문제가 생겨요. 서버가 없으면 테스트를 못 돌리고, 에러 상황을 재현하기도 어렵고, 테스트 속도도 느려져요.
MSW(Mock Service Worker) 는 네트워크 요청을 가로채서 가짜 응답을 보내줘요. 코드 자체는 건드리지 않고 모킹이 필요한 환경에서만 인터셉터를 실행하는 방식이에요.
// src/mocks/handlers.ts
import { http, HttpResponse } from 'msw';
export const handlers = [
http.get('/api/users', () => {
return HttpResponse.json([
{ id: 1, name: 'jay', role: 'Frontend' },
]);
}),
http.post('/api/login', async ({ request }) => {
const info = await request.json();
if (!info) return new HttpResponse(null, { status: 400 });
return HttpResponse.json({ success: true }, { status: 200 });
}),
];
환경에 따라 설정을 분리해야 해요.
- 브라우저 →
msw/browser의setupWorker - Vitest(Node.js) →
msw/node의setupServer
MSW의 진짜 장점은 500 에러, 401 인증 만료처럼 재현하기 어려운 상황을 강제로 시뮬레이션할 수 있다는 거예요! 이걸 활용하면 커버리지를 크게 높일 수 있어요.
커버리지 전략
커버리지 100%가 목표가 아니에요.
아직 테스트되지 않은 위험 구역이 어디인지 파악하는 용도로 쓰는 게 맞아요. 숫자가 낮은 곳이 복잡한 비즈니스 로직인지, 단순 UI인지 판단해서 테스트를 추가하거나 보류하면 돼요.
라인 커버리지만 보는 건 함정이에요. if, switch 같은 분기를 모두 검증했는지를 따지는 브랜치 커버리지를 우선시해야 논리적 무결성을 검증할 수 있어요.
레이어별로 기준을 다르게 가져가는 것도 방법이에요.
- 핵심 비즈니스 로직, 공통 유틸 → 80~100% 엄격하게
- 단순 UI, 스타일 코드 → 렌더링과 핵심 인터랙션 위주로만
모든 파일에 같은 기준을 적용하면 비용 대비 효과가 떨어져요.
마무리
테스트 전략에 대해 공부하며 느낀 것은,
처음에는 테스트 코드를 작성하는 것 자체가 불필요한 리소스라고 생각했어요
사용자 흐름은 개발 구현 후에 스토리북같은 라이브러리를 통해 직접 검증하면 되니까요
그치만 스터디원들과 얘기하고, 글을 작성하며 정리해보니
테스트를 많이 짜는 것보다 올바른 것을 테스트하는 것이 제일 중요하다는 결론을 내렸어요.
커버리지 100%를 향해 달리기보다, 결제/로그인처럼 타협할 수 없는 핵심 기능에 집중하는 게 현실적으로 효율적이에요. 테스트 코드를 짜는 과정 자체가 이 코드는 정말 테스트하기 쉬운 구조인가?를 돌아보게 해줘요.
테스트하기 어려운 코드는 결합도가 높거나 책임이 비대하다는 신호니, 어떻게 보면 테스트는 코드 설계를 개선하는 가장 객관적인 지표라고 생각했어요.
'web' 카테고리의 다른 글
| 폴더 구조와 아키텍처 (0) | 2026.07.08 |
|---|---|
| 렌더링 최적화 가이드 및 적용 (ft. Profiler, LightHouse, Memoization, 그리고 React Compiler) (0) | 2026.07.08 |
| TanStack Query 원리부터 제대로 쓰기 (0) | 2026.07.08 |
| 컴포넌트를 설계하는 원칙 (0) | 2026.07.08 |
| 엄격한 FSD 아키텍쳐 구조와 커스터 마이징 (0) | 2026.07.08 |