Prologue: 리팩터링했더니 테스트가 전부 깨졌다
컴포넌트를 리팩터링했다. 동작은 완전히 동일한데, 내부 구현을 좀 깔끔하게 정리했을 뿐이었다. 근데 테스트 20개가 전부 빨간불이 됐다.
원인을 분석해보니 테스트들이 컴포넌트 내부 state 이름, CSS 클래스, props 이름에 의존하고 있었다. wrapper.find('.submit-btn'), component.state().isLoading, 이런 식으로.
이때 Testing Library를 제대로 알게 됐다. Testing Library의 창시자 Kent C. Dodds가 말한 한 문장:
"The more your tests resemble the way your software is used, the more confidence they can give you."
테스트가 소프트웨어의 실제 사용 방식을 닮을수록, 더 큰 자신감을 줄 수 있다.
구현이 아닌 동작을 테스트하라. 이게 Testing Library의 전부다.
Testing Library의 핵심 철학
구현 세부사항을 테스트하지 마라
// 나쁜 예: 구현 세부사항에 의존
const { container } = render(<LoginForm />)
// CSS 클래스 이름이 바뀌면 테스트 실패
expect(container.querySelector('.form-submit-btn')).toBeTruthy()
// state 이름이 바뀌면 테스트 실패
expect(wrapper.state('isSubmitting')).toBe(true)
// prop 이름이 바뀌면 테스트 실패
expect(wrapper.find('Button').props().disabled).toBe(true)
// 좋은 예: 사용자 관점에서 테스트
render(<LoginForm />)
// 사용자가 보는 것: "제출 중..." 텍스트가 있는 버튼이 비활성화
const submitButton = screen.getByRole('button', { name: '제출 중...' })
expect(submitButton).toBeDisabled()
첫 번째 테스트는 구현을 바꾸면 깨진다. 두 번째 테스트는 버튼이 비활성화되고 적절한 레이블이 있는 한, 어떻게 구현하든 통과한다.
getByRole이 최선인 이유
Testing Library는 쿼리 우선순위를 공식 문서에서 명확히 정해놨다:
- 접근성 쿼리 (모든 사용자가 접근 가능):
getByRole,getByLabelText,getByPlaceholderText,getByText,getByDisplayValue - 시맨틱 쿼리:
getByAltText,getByTitle - 테스트 ID (최후 수단):
getByTestId
getByRole이 1순위인 이유는 두 가지다:
- ARIA 역할로 요소를 찾으므로 마크업이 접근성을 지키는지도 함께 검증한다
- 클래스명, ID, 구조에 의존하지 않아 리팩터링에 강하다
// 이런 버튼이 있을 때
<button type="submit" className="btn btn-primary">
로그인
</button>
// getByRole로 찾기
screen.getByRole('button', { name: '로그인' })
// getByText로 찾기 (괜찮지만 차선)
screen.getByText('로그인')
// querySelector로 찾기 (하지 말 것)
document.querySelector('.btn-primary')
자주 쓰는 ARIA 역할들
// 버튼
screen.getByRole('button', { name: '저장' })
// 링크
screen.getByRole('link', { name: '블로그로 이동' })
// 입력 필드 (label과 연결된 경우)
screen.getByRole('textbox', { name: '이메일' })
screen.getByRole('spinbutton', { name: '나이' }) // number input
screen.getByRole('combobox', { name: '국가' }) // select
// 체크박스
screen.getByRole('checkbox', { name: '약관 동의' })
// 제목
screen.getByRole('heading', { name: '대시보드', level: 1 })
// 테이블
screen.getByRole('table')
screen.getAllByRole('row')
screen.getAllByRole('cell')
// 이미지
screen.getByRole('img', { name: '프로필 사진' })
// 탐색
screen.getByRole('navigation')
screen.getByRole('main')
screen.getByRole('dialog') // 모달
getByLabelText: 폼 필드의 베스트 프랙티스
// 이렇게 마크업하면
<label htmlFor="email">이메일 주소</label>
<input id="email" type="email" />
// 이렇게 찾을 수 있다
screen.getByLabelText('이메일 주소')
// aria-label도 됨
<input aria-label="이메일 주소" type="email" />
screen.getByLabelText('이메일 주소')
// aria-labelledby도 됨
<span id="email-label">이메일</span>
<input aria-labelledby="email-label" type="email" />
screen.getByLabelText('이메일')
getByLabelText를 쓰면 label과 input이 제대로 연결돼 있는지도 함께 테스트된다. 접근성 이슈를 무료로 잡는 셈이다.
userEvent vs fireEvent
둘 다 사용자 인터랙션을 시뮬레이션하는데, 차이가 크다.
fireEvent: 단일 DOM 이벤트를 발생시킨다.
import { fireEvent } from '@testing-library/react'
fireEvent.click(button) // click 이벤트 하나만 발생
fireEvent.change(input, { target: { value: 'hello' } }) // change 이벤트 하나만
userEvent: 실제 사용자가 하는 것처럼 이벤트 시퀀스를 발생시킨다.
import userEvent from '@testing-library/user-event'
// 텍스트 타이핑: keydown, keypress, input, keyup 이벤트 시퀀스
await userEvent.type(input, 'hello')
// 클릭: pointerover, pointerenter, mouseover, mouseenter, pointermove,
// mousemove, pointerdown, mousedown, focus, pointerup, mouseup, click
await userEvent.click(button)
언제 뭘 써야 하나:
| userEvent | fireEvent | |
|---|---|---|
| 실제 사용자 동작 모방 | 정확함 | 단순함 |
| 비동기 처리 | 필요 (await) | 불필요 |
| 복잡한 인터랙션 | 필수 | 부족함 |
| 단순 이벤트 테스트 | 과도함 | 충분 |
결론: 특별한 이유가 없으면 userEvent를 써라. 실제 사용자 경험을 더 정확히 테스트한다.
userEvent 올바르게 사용하기
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import LoginForm from './LoginForm'
test('로그인 폼 제출', async () => {
// userEvent는 setup()으로 초기화 권장 (v14+)
const user = userEvent.setup()
render(<LoginForm onSubmit={handleSubmit} />)
// 타이핑
await user.type(screen.getByLabelText('이메일'), 'user@example.com')
await user.type(screen.getByLabelText('비밀번호'), 'password123')
// 클릭
await user.click(screen.getByRole('button', { name: '로그인' }))
expect(handleSubmit).toHaveBeenCalledWith({
email: 'user@example.com',
password: 'password123',
})
})
실전 예제: 폼 컴포넌트 테스트
실제 프로젝트에서 자주 쓰는 폼 테스트 패턴이다.
// ContactForm.tsx
import { useState } from 'react'
interface ContactFormProps {
onSubmit: (data: { name: string; email: string; message: string }) => Promise<void>
}
export function ContactForm({ onSubmit }: ContactFormProps) {
const [isSubmitting, setIsSubmitting] = useState(false)
const [submitted, setSubmitted] = useState(false)
const [errors, setErrors] = useState<Record<string, string>>({})
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
const formData = new FormData(e.currentTarget)
const data = {
name: formData.get('name') as string,
email: formData.get('email') as string,
message: formData.get('message') as string,
}
const newErrors: Record<string, string> = {}
if (!data.name) newErrors.name = '이름을 입력해주세요'
if (!data.email) newErrors.email = '이메일을 입력해주세요'
if (!data.message) newErrors.message = '메시지를 입력해주세요'
if (Object.keys(newErrors).length > 0) {
setErrors(newErrors)
return
}
setIsSubmitting(true)
try {
await onSubmit(data)
setSubmitted(true)
} finally {
setIsSubmitting(false)
}
}
if (submitted) {
return <p role="status">메시지가 전송됐습니다!</p>
}
return (
<form onSubmit={handleSubmit}>
<div>
<label htmlFor="name">이름</label>
<input id="name" name="name" type="text" />
{errors.name && <span role="alert">{errors.name}</span>}
</div>
<div>
<label htmlFor="email">이메일</label>
<input id="email" name="email" type="email" />
{errors.email && <span role="alert">{errors.email}</span>}
</div>
<div>
<label htmlFor="message">메시지</label>
<textarea id="message" name="message" />
{errors.message && <span role="alert">{errors.message}</span>}
</div>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? '전송 중...' : '전송'}
</button>
</form>
)
}
// ContactForm.test.tsx
import { vi, describe, it, expect } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { ContactForm } from './ContactForm'
describe('ContactForm', () => {
const user = userEvent.setup()
it('폼을 올바르게 채우고 제출하면 성공 메시지 표시', async () => {
const mockSubmit = vi.fn().mockResolvedValue(undefined)
render(<ContactForm onSubmit={mockSubmit} />)
await user.type(screen.getByLabelText('이름'), '김개발')
await user.type(screen.getByLabelText('이메일'), 'dev@example.com')
await user.type(screen.getByLabelText('메시지'), '안녕하세요!')
await user.click(screen.getByRole('button', { name: '전송' }))
await waitFor(() => {
expect(screen.getByRole('status')).toHaveTextContent('메시지가 전송됐습니다!')
})
expect(mockSubmit).toHaveBeenCalledWith({
name: '김개발',
email: 'dev@example.com',
message: '안녕하세요!',
})
})
it('필드를 비우고 제출하면 유효성 검사 에러 표시', async () => {
const mockSubmit = vi.fn()
render(<ContactForm onSubmit={mockSubmit} />)
// 아무것도 입력하지 않고 제출
await user.click(screen.getByRole('button', { name: '전송' }))
// 에러 메시지들 확인
const alerts = screen.getAllByRole('alert')
expect(alerts).toHaveLength(3)
expect(alerts[0]).toHaveTextContent('이름을 입력해주세요')
expect(alerts[1]).toHaveTextContent('이메일을 입력해주세요')
expect(alerts[2]).toHaveTextContent('메시지를 입력해주세요')
// onSubmit은 호출되면 안 됨
expect(mockSubmit).not.toHaveBeenCalled()
})
it('제출 중에는 버튼이 비활성화되고 레이블이 바뀜', async () => {
// resolve를 수동으로 제어하는 Promise
let resolveSubmit!: () => void
const mockSubmit = vi.fn().mockImplementation(
() => new Promise<void>(resolve => { resolveSubmit = resolve })
)
render(<ContactForm onSubmit={mockSubmit} />)
await user.type(screen.getByLabelText('이름'), '김개발')
await user.type(screen.getByLabelText('이메일'), 'dev@example.com')
await user.type(screen.getByLabelText('메시지'), '테스트')
await user.click(screen.getByRole('button', { name: '전송' }))
// 제출 중 상태 확인
expect(screen.getByRole('button', { name: '전송 중...' })).toBeDisabled()
// 제출 완료
resolveSubmit()
await waitFor(() => {
expect(screen.getByRole('button', { name: '전송' })).not.toBeDisabled()
})
})
})
비동기 쿼리: findBy와 waitFor
DOM이 비동기로 업데이트될 때 처리하는 방법이다.
// getBy: 즉시 찾음, 없으면 에러
const button = screen.getByRole('button') // 동기
// queryBy: 즉시 찾음, 없으면 null (에러 없음)
const maybeButton = screen.queryByRole('button') // null이면 없는 것
// findBy: Promise 반환, 나타날 때까지 기다림 (기본 1초 타임아웃)
const asyncButton = await screen.findByRole('button') // 비동기
// waitFor: 특정 조건이 될 때까지 기다림
await waitFor(() => {
expect(screen.getByText('로드 완료')).toBeInTheDocument()
})
언제 뭘 쓰나:
// 항상 있어야 하는 요소: getBy
const title = screen.getByRole('heading', { name: '대시보드' })
// 없을 수도 있는 요소 확인: queryBy
const errorMessage = screen.queryByRole('alert')
expect(errorMessage).not.toBeInTheDocument()
// 비동기로 나타나는 요소: findBy
const successMessage = await screen.findByText('저장 완료')
// 복잡한 비동기 조건: waitFor
await waitFor(() => {
expect(screen.getAllByRole('listitem')).toHaveLength(5)
})
모달/다이얼로그 테스트
// Modal.test.tsx
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { ConfirmDialog } from './ConfirmDialog'
describe('ConfirmDialog', () => {
const user = userEvent.setup()
it('확인 버튼 클릭 시 onConfirm 호출', async () => {
const onConfirm = vi.fn()
const onCancel = vi.fn()
render(
<ConfirmDialog
isOpen={true}
title="삭제 확인"
message="정말 삭제하시겠습니까?"
onConfirm={onConfirm}
onCancel={onCancel}
/>
)
// 다이얼로그가 보이는지 확인
const dialog = screen.getByRole('dialog')
expect(dialog).toBeVisible()
expect(screen.getByRole('heading', { name: '삭제 확인' })).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: '확인' }))
expect(onConfirm).toHaveBeenCalledTimes(1)
expect(onCancel).not.toHaveBeenCalled()
})
it('ESC 키로 모달 닫기', async () => {
const onCancel = vi.fn()
render(<ConfirmDialog isOpen={true} onConfirm={vi.fn()} onCancel={onCancel} />)
await user.keyboard('{Escape}')
expect(onCancel).toHaveBeenCalledTimes(1)
})
it('isOpen이 false이면 렌더링 안 됨', () => {
render(<ConfirmDialog isOpen={false} onConfirm={vi.fn()} onCancel={vi.fn()} />)
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
})
})
자주 쓰는 Custom Matchers
@testing-library/jest-dom이 제공하는 유용한 매처들:
// 존재 여부
expect(element).toBeInTheDocument()
expect(element).not.toBeInTheDocument()
// 가시성
expect(element).toBeVisible()
expect(element).not.toBeVisible() // display:none, visibility:hidden, opacity:0
// 활성화 상태
expect(button).toBeEnabled()
expect(button).toBeDisabled()
// 체크 상태
expect(checkbox).toBeChecked()
expect(checkbox).not.toBeChecked()
// 텍스트 내용
expect(element).toHaveTextContent('안녕하세요')
expect(element).toHaveTextContent(/안녕/) // 정규식도 됨
// 값
expect(input).toHaveValue('user@example.com')
expect(select).toHaveValue('option-value')
// 속성
expect(input).toHaveAttribute('type', 'email')
expect(link).toHaveAttribute('href', '/about')
// 클래스 (마지막 수단으로만)
expect(element).toHaveClass('active')
expect(element).not.toHaveClass('disabled')
// 포커스
expect(input).toHaveFocus()
// ARIA 상태
expect(element).toHaveAccessibleName('이메일 입력')
expect(element).toHaveAccessibleDescription('이메일 형식으로 입력하세요')
흔한 안티패턴들
1. act() 경고 무시하기
// act() 경고가 뜨면 무시하지 말 것
// "Warning: An update to Component inside a test was not wrapped in act(...)"
// → 비동기 업데이트를 제대로 기다리지 않고 있다는 신호
// 잘못된 방법: 경고 억제
jest.spyOn(console, 'error').mockImplementation(() => {})
// 올바른 방법: 비동기를 제대로 처리
await waitFor(() => expect(screen.getByText('로드 완료')).toBeInTheDocument())
2. container.querySelector() 사용
// 하지 말 것
const { container } = render(<MyComponent />)
container.querySelector('.my-button').click()
// 이렇게
render(<MyComponent />)
await userEvent.click(screen.getByRole('button'))
3. 구현 세부사항 검사
// 하지 말 것
const { result } = renderHook(() => useMyHook())
expect(result.current.internalState).toBe(true) // 내부 state 직접 검사
// 이렇게: 상태가 UI에 어떻게 나타나는지 테스트
render(<ComponentUsingHook />)
expect(screen.getByText('활성')).toBeInTheDocument()
4. 불필요한 waitFor 중첩
// 과도한 waitFor
await waitFor(async () => {
await waitFor(() => expect(screen.getByText('로드됨')).toBeInTheDocument())
})
// 단순하게
await screen.findByText('로드됨')
// 또는
await waitFor(() => expect(screen.getByText('로드됨')).toBeInTheDocument())
5. 모든 것에 data-testid 붙이기
// 과도한 testId 사용
<button data-testid="submit-button">로그인</button>
screen.getByTestId('submit-button')
// 이렇게 하면 testId 불필요
<button type="submit">로그인</button>
screen.getByRole('button', { name: '로그인' })
data-testid는 role이나 label로 접근할 수 없는 요소에만 쓴다.
설정: Testing Library + Vitest
// vitest.config.ts
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
environment: 'jsdom',
setupFiles: ['./src/test/setup.ts'],
globals: true,
},
})
// src/test/setup.ts
import '@testing-library/jest-dom'
import { cleanup } from '@testing-library/react'
import { afterEach } from 'vitest'
// 각 테스트 후 DOM 정리
afterEach(() => {
cleanup()
})
마무리: 테스트가 변경에 살아남으려면
"테스트가 구현이 아닌 동작을 테스트해야 한다"는 말이 추상적으로 들릴 수 있다. 실전 기준을 하나 주자면 이거다:
이 테스트가 실패하는 이유가 '사용자가 원하는 것이 작동하지 않아서'인가, 아니면 '내가 코드 구조를 바꿔서'인가?
전자라면 좋은 테스트다. 후자라면 구현 세부사항에 의존하는 취약한 테스트다.
Testing Library의 방식대로 쓰면, 컴포넌트를 완전히 재작성해도 테스트가 살아남는다. 그게 진짜 테스트의 가치다.