좋은 테스트 코드, 나쁜 테스트 코드 — component편
지난 글에서 util 함수를 기준으로 네 가지 원칙을 다뤘습니다. 테스트 이름은 문서여야 하고, 구현이 아니라 동작을 검증해야 하며, 각 테스트는 독립적이어야 하고, 실패할 수 있어야 의미가 있다는 것이었습니다.
util 함수는 입력과 출력이 명확하기 때문에 이 원칙들을 적용하기가 비교적 수월했습니다. component와 Hook 테스트는 사정이 다릅니다. 비동기 rendering, 외부 API mocking, 사용자 interaction 시뮬레이션이 추가되면서 같은 원칙이라도 지키기가 훨씬 어려워집니다. 이 글에서는 testing-library와 msw(Mock Service Worker)를 기준으로, component와 Hook 테스트에서 자주 마주치는 나쁜 패턴과 그 개선 방법을 정리합니다.
모든 테스트 이름은 "사용자 관점의 시나리오"입니다
util편에서 테스트 이름이 문서 역할을 해야 한다고 했습니다. component 테스트에서는 한 단계 더 나아가야 합니다. 함수의 입출력이 아니라, 사용자가 화면에서 경험하는 시나리오를 이름에 담아야 합니다.
개선 전: 내부 동작을 이름에 쓴 경우
describe('SearchForm', () => { it('state를 업데이트한다', async () => { render(<SearchForm />); await userEvent.type(screen.getByRole('textbox'), 'react'); expect(screen.getByRole('textbox')).toHaveValue('react'); }); it('onSubmit을 호출한다', async () => { const onSubmit = vi.fn(); render(<SearchForm onSubmit={onSubmit} />); await userEvent.type(screen.getByRole('textbox'), 'react'); await userEvent.click(screen.getByRole('button', { name: '검색' })); expect(onSubmit).toHaveBeenCalledWith('react'); }); });
"state를 업데이트한다"나 "onSubmit을 호출한다"는 개발자의 관점입니다. CI 로그에서 이 이름을 보고 사용자에게 어떤 문제가 생겼는지 파악하기 어렵습니다.
개선 후: 사용자 시나리오를 이름에 담은 경우
describe('SearchForm', () => { it('검색어를 입력하면 입력 필드에 반영된다', async () => { render(<SearchForm />); await userEvent.type(screen.getByRole('textbox'), 'react'); expect(screen.getByRole('textbox')).toHaveValue('react'); }); it('검색 버튼을 클릭하면 입력된 검색어로 검색을 실행한다', async () => { const onSubmit = vi.fn(); render(<SearchForm onSubmit={onSubmit} />); await userEvent.type(screen.getByRole('textbox'), 'react'); await userEvent.click(screen.getByRole('button', { name: '검색' })); expect(onSubmit).toHaveBeenCalledWith('react'); }); });
"검색 버튼을 클릭하면 입력된 검색어로 검색을 실행한다 — FAILED"가 뜨면, 코드를 열기 전에 사용자의 어떤 흐름이 깨졌는지 바로 알 수 있습니다. component 테스트의 이름은 "사용자가 ~하면 ~가 보인다/실행된다"라는 문장이 되어야 합니다.
구현이 아니라 동작을 테스트합니다
util편에서
Array.prototype.sort를 spy하는 대신 정렬 결과를 검증해야 한다고 했습니다. component 테스트에서 이 원칙은 더 중요해집니다. component는 내부 구현이 바뀔 여지가 util보다 훨씬 넓기 때문입니다. data fetching 라이브러리 교체, state 관리 방식 변경, Server Components 마이그레이션 등 내부 구조는 언제든 바뀔 수 있지만, 사용자가 화면에서 보는 것은 동일해야 합니다.개선 전: 내부 구현에 결합된 component 테스트
it('useEffect로 API를 호출한다', () => { const spy = vi.spyOn(React, 'useEffect'); render(<UserProfile userId="1" />); expect(spy).toHaveBeenCalled(); });
useEffect를 spy한다는 것은 "이 component가 내부적으로 useEffect를 사용한다"는 구현 세부사항을 테스트하는 것입니다. useSWR이나 React Query로 교체하면 기능은 동일한데 테스트가 깨집니다.개선 후: 사용자가 보는 동작을 테스트
it('userId에 해당하는 사용자 이름을 화면에 rendering한다', async () => { server.use( http.get('/api/users/1', () => HttpResponse.json({ name: '홍규진' }) ) ); render(<UserProfile userId="1" />); await waitFor(() => { expect(screen.getByText('홍규진')).toBeInTheDocument(); }); });
이 테스트는 "userId를 넘기면 해당 사용자 이름이 화면에 나타난다"는 동작을 검증합니다. 내부에서 useEffect를 쓰든, useSWR을 쓰든, React Query를 쓰든 상관없습니다. msw로 API 응답을 mocking하고, 화면에 rendering된 결과만 확인합니다. 구현이 바뀌어도 동작이 같으면 테스트는 통과합니다.
이 원칙은 Hook 테스트에서도 동일하게 적용됩니다. 내부 state의 구체적인 값이나 업데이트 횟수를 추적하는 것은 구현에 결합된 테스트입니다. 예를 들어, 검색 Hook을 테스트할 때 "useState가 몇 번 호출되었는가"를 검증하는 것은 구현 세부사항에 의존하는 것이고, "검색어를 입력하면 필터링된 결과가 반환되는가"를 검증하는 것이 동작 기반 테스트입니다.
개선 전: 내부 state를 직접 검사
it('setState가 2번 호출된다', () => { const setStateSpy = vi.fn(); vi.spyOn(React, 'useState').mockReturnValue([[], setStateSpy]); renderHook(() => useSearch('react')); expect(setStateSpy).toHaveBeenCalledTimes(2); });
개선 후: Hook이 노출하는 interface를 테스트
it('검색어를 입력하면 필터링된 결과를 반환한다', async () => { server.use( http.get('/api/search', ({ request }) => { const url = new URL(request.url); const q = url.searchParams.get('q'); return HttpResponse.json({ items: [{ id: '1', title: `${q} 튜토리얼` }], }); }) ); const { result } = renderHook(() => useSearch('react')); await waitFor(() => { expect(result.current.items).toEqual([ { id: '1', title: 'react 튜토리얼' }, ]); }); });
Hook이 외부로 노출하는 값(
result.current.items)만 검증합니다. 내부에서 useState를 쓰든 useReducer를 쓰든, msw로 mocking한 API 응답이 올바르게 가공되어 반환되는지만 확인하면 됩니다.각 테스트는 독립적으로 실행 가능해야 합니다
util편에서 테스트 간 state 공유와 시간 의존성 문제를 다뤘습니다. component 테스트에서는 여기에 하나가 더 추가됩니다. API mocking state입니다. msw의 handler를 전역으로 설정하면, 한 테스트에서 설정한 handler가 다른 테스트에 영향을 줄 수 있습니다.
개선 전: 전역 handler에 의존
// setupTests.ts에서 전역으로 설정 server.use( http.get('/api/users/1', () => HttpResponse.json({ name: '홍규진' }) ) ); // 테스트 파일 it('사용자 이름을 렌더링한다', async () => { render(<UserProfile userId="1" />); await waitFor(() => { expect(screen.getByText('홍규진')).toBeInTheDocument(); }); }); it('에러 시 에러 메시지를 보여준다', async () => { // 전역 handler를 덮어쓰지만, resetHandlers를 안 하면 // 다음 테스트에도 이 handler가 남을 수 있다 server.use( http.get('/api/users/1', () => new HttpResponse(null, { status: 500 }) ) ); render(<UserProfile userId="1" />); await waitFor(() => { expect( screen.getByText('사용자 정보를 불러올 수 없습니다') ).toBeInTheDocument(); }); });
두 번째 테스트에서
server.use로 handler를 덮어쓴 뒤 resetHandlers를 호출하지 않으면, 이후 테스트에서도 500 응답이 반환됩니다. 테스트 순서가 바뀌면 결과가 달라지는, 전형적인 독립성 위반입니다.개선 후: 각 테스트가 필요한 handler를 직접 설정
afterEach(() => { server.resetHandlers(); }); it('사용자 이름을 렌더링한다', async () => { server.use( http.get('/api/users/1', () => HttpResponse.json({ name: '홍규진' }) ) ); render(<UserProfile userId="1" />); await waitFor(() => { expect(screen.getByText('홍규진')).toBeInTheDocument(); }); }); it('API가 500을 반환하면 에러 메시지를 보여준다', async () => { server.use( http.get('/api/users/1', () => new HttpResponse(null, { status: 500 }) ) ); render(<UserProfile userId="1" />); await waitFor(() => { expect( screen.getByText('사용자 정보를 불러올 수 없습니다') ).toBeInTheDocument(); }); });
afterEach에서 server.resetHandlers()를 호출하면, 매 테스트가 끝날 때마다 handler가 초기 상태로 돌아갑니다. 각 테스트가 자기 시나리오에 필요한 handler를 직접 설정하므로, 실행 순서와 무관하게 결과가 동일합니다.component 테스트에서 시간 의존성도 util편에서 다룬 것과 동일한 방식으로 해결합니다. 토스트 메시지처럼 일정 시간 후 사라지는 UI가 대표적입니다.
개선 전: 실제 시간에 의존
it('3초 후에 토스트가 사라진다', async () => { const { result } = renderHook(() => useToast()); act(() => result.current.show('저장되었습니다')); expect(result.current.visible).toBe(true); await new Promise((r) => setTimeout(r, 3000)); expect(result.current.visible).toBe(false); });
이 테스트는 실행할 때마다 실제 3초를 소모합니다. 이런 테스트가 10개만 쌓여도 CI가 30초 느려지고, 시스템 부하에 따라 간헐적으로 실패하는 flaky test가 됩니다.
개선 후: fake timers로 시간을 통제
beforeEach(() => { vi.useFakeTimers(); }); afterEach(() => { vi.useRealTimers(); }); it('3초 후에 토스트가 사라진다', () => { const { result } = renderHook(() => useToast()); act(() => result.current.show('저장되었습니다')); expect(result.current.visible).toBe(true); act(() => vi.advanceTimersByTime(3000)); expect(result.current.visible).toBe(false); });
vi.useFakeTimers()로 시간을 테스트의 통제 아래 둡니다. 실제로 3초를 기다리는 것이 아니라 "3초가 흘렀다"고 선언하는 것입니다. 실행 시간은 밀리초 단위이고, 어떤 환경에서 돌려도 결과가 동일합니다.테스트는 실패할 수 있어야 의미가 있습니다
util편에서
try-catch로 에러를 삼키는 패턴과 toBeDefined() 같은 느슨한 assertion을 다뤘습니다. component 테스트에서 이 원칙이 특히 중요한 지점은 에러 시나리오 검증입니다. happy path만 테스트하면, production에서 API가 실패했을 때 사용자가 빈 화면만 보게 되는 상황을 방지할 수 없습니다.개선 전: happy path만 존재
describe('UserProfile', () => { it('사용자 정보를 불러온다', async () => { server.use( http.get('/api/users/1', () => HttpResponse.json({ name: '홍규진' }) ) ); render(<UserProfile userId="1" />); await waitFor(() => { expect(screen.getByText('홍규진')).toBeInTheDocument(); }); }); });
production에서 API가 500을 반환하거나 timeout이 발생하면 어떻게 되는지, 이 테스트 스위트로는 알 수 없습니다. "테스트가 전부 통과하니까 배포해도 되겠지"라는 판단이, 에러 페이지 없이 빈 화면만 보여주는 상황으로 이어집니다.
개선 후: 성공, 서버 에러, 네트워크 에러를 각각 검증
describe('UserProfile', () => { afterEach(() => { server.resetHandlers(); }); it('사용자 정보를 불러와 이름을 rendering한다', async () => { server.use( http.get('/api/users/1', () => HttpResponse.json({ name: '홍규진' }) ) ); render(<UserProfile userId="1" />); await waitFor(() => { expect(screen.getByText('홍규진')).toBeInTheDocument(); }); }); it('API가 500을 반환하면 에러 메시지를 보여준다', async () => { server.use( http.get('/api/users/1', () => new HttpResponse(null, { status: 500 }) ) ); render(<UserProfile userId="1" />); await waitFor(() => { expect( screen.getByText('사용자 정보를 불러올 수 없습니다') ).toBeInTheDocument(); }); }); it('네트워크 에러 시 재시도 버튼을 노출한다', async () => { server.use( http.get('/api/users/1', () => HttpResponse.error()) ); render(<UserProfile userId="1" />); await waitFor(() => { expect( screen.getByRole('button', { name: '다시 시도' }) ).toBeInTheDocument(); }); }); });
성공, 서버 에러, 네트워크 에러의 세 가지 시나리오를 각각 독립적으로 검증합니다. msw의
HttpResponse.error()로 네트워크 단절까지 시뮬레이션할 수 있습니다. 이 테스트 스위트가 전부 통과한다면, 에러 상황에서도 사용자에게 적절한 피드백이 제공된다는 것을 확신할 수 있습니다.component 테스트에서 또 하나 놓치기 쉬운 것은 loading state입니다. API 호출 후 응답이 올 때까지 사용자가 보는 화면도 검증 대상입니다.
it('데이터를 불러오는 동안 로딩 indicator를 보여준다', async () => { server.use( http.get('/api/users/1', async () => { await delay(100); return HttpResponse.json({ name: '홍규진' }); }) ); render(<UserProfile userId="1" />); expect(screen.getByRole('progressbar')).toBeInTheDocument(); await waitFor(() => { expect(screen.getByText('홍규진')).toBeInTheDocument(); }); expect(screen.queryByRole('progressbar')).not.toBeInTheDocument(); });
delay로 응답을 지연시키면, rendering 직후의 loading state를 자연스럽게 포착할 수 있습니다. loading indicator가 노출되었다가, 데이터가 도착하면 사라지는 전체 흐름을 하나의 테스트에서 검증합니다. 이런 전환 시나리오는 util 함수에는 없는, component 테스트만의 영역입니다.util 테스트와 무엇이 달랐는가
지난 글과 이 글에서 다룬 원칙은 동일합니다. 테스트 이름은 문서이고, 구현이 아니라 동작을 검증해야 하며, 각 테스트는 독립적이어야 하고, 실패할 수 있어야 의미가 있습니다. 차이는 원칙의 적용 난이도입니다.
util 함수는 입력을 넣으면 출력이 나옵니다. 순수하고, 동기적이며, 외부 의존성이 없습니다. component는 다릅니다. 비동기 rendering을 기다려야 하고(
waitFor), 외부 API를 mocking해야 하며(msw), 사용자 interaction을 시뮬레이션해야 하고(userEvent), 시간을 통제해야 합니다(fake timers). 같은 원칙이지만, 지켜야 할 도구와 context가 훨씬 많아집니다.그렇기 때문에, util 테스트에서 원칙을 먼저 체화하는 것이 중요합니다. "이름을 잘 짓고, 동작을 검증하고, 독립적으로 만들고, 실패 가능하게 만든다"는 습관이 잡혀 있으면, component 테스트에서 도구가 달라져도 판단 기준은 흔들리지 않습니다.
kyu-log