
Testing Library 패턴: 사용자 관점에서 컴포넌트 테스트
컴포넌트 내부 state를 직접 검사하는 테스트는 리팩터링마다 깨진다. Testing Library의 핵심 철학은 '사용자가 실제로 하는 것만 테스트하라'는 것이다. getByRole이 왜 최선인지, userEvent와 fireEvent의 차이가 뭔지 정리했다.

컴포넌트 내부 state를 직접 검사하는 테스트는 리팩터링마다 깨진다. Testing Library의 핵심 철학은 '사용자가 실제로 하는 것만 테스트하라'는 것이다. getByRole이 왜 최선인지, userEvent와 fireEvent의 차이가 뭔지 정리했다.
프론트엔드 테스트를 처음 도입하면서 겪은 시행착오. Vitest와 Testing Library 조합으로 React 컴포넌트를 테스트하는 실전 패턴을 정리했다.

내 서비스를 키보드만으로 써보려다 탭이 엉뚱한 곳으로 날아갔다. 웹 접근성을 실제로 개선하면서 배운 것들을 정리했다.

새벽 배포 후 회원가입 플로우가 깨진 걸 유저가 먼저 발견했다. Playwright로 E2E 테스트를 구축하면서 배운 실전 패턴과 CI 연동 방법.

백엔드 API가 준비되지 않아 프론트엔드 개발이 멈추는 문제를 MSW(Mock Service Worker)로 해결했다. 네트워크 레벨 API 모킹의 원리와 실전 패턴.

컴포넌트를 리팩터링했다. 동작은 완전히 동일한데, 내부 구현을 좀 깔끔하게 정리했을 뿐이었다. 근데 테스트 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의 전부다.
// 나쁜 예: 구현 세부사항에 의존
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()
첫 번째 테스트는 구현을 바꾸면 깨진다. 두 번째 테스트는 버튼이 비활성화되고 적절한 레이블이 있는 한, 어떻게 구현하든 통과한다.
Testing Library는 쿼리 우선순위를 공식 문서에서 명확히 정해놨다:
getByRole, getByLabelText, getByPlaceholderText, getByText, getByDisplayValuegetByAltText, getByTitlegetByTestIdgetByRole이 1순위인 이유는 두 가지다:
// 이런 버튼이 있을 때
<button type="submit" className="btn btn-primary">
로그인
</button>
// getByRole로 찾기
screen.getByRole('button', { name: '로그인' })
// getByText로 찾기 (괜찮지만 차선)
screen.getByText('로그인')
// querySelector로 찾기 (하지 말 것)
document.querySelector('.btn-primary')
// 버튼
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') // 모달
// 이렇게 마크업하면
<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이 제대로 연결돼 있는지도 함께 테스트된다. 접근성 이슈를 무료로 잡는 셈이다.
둘 다 사용자 인터랙션을 시뮬레이션하는데, 차이가 크다.
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를 써라. 실제 사용자 경험을 더 정확히 테스트한다.
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()
})
})
})
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()
})
})
@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('이메일 형식으로 입력하세요')
// 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())
// 하지 말 것
const { container } = render(<MyComponent />)
container.querySelector('.my-button').click()
// 이렇게
render(<MyComponent />)
await userEvent.click(screen.getByRole('button'))
// 하지 말 것
const { result } = renderHook(() => useMyHook())
expect(result.current.internalState).toBe(true) // 내부 state 직접 검사
// 이렇게: 상태가 UI에 어떻게 나타나는지 테스트
render(<ComponentUsingHook />)
expect(screen.getByText('활성')).toBeInTheDocument()
// 과도한 waitFor
await waitFor(async () => {
await waitFor(() => expect(screen.getByText('로드됨')).toBeInTheDocument())
})
// 단순하게
await screen.findByText('로드됨')
// 또는
await waitFor(() => expect(screen.getByText('로드됨')).toBeInTheDocument())
// 과도한 testId 사용
<button data-testid="submit-button">로그인</button>
screen.getByTestId('submit-button')
// 이렇게 하면 testId 불필요
<button type="submit">로그인</button>
screen.getByRole('button', { name: '로그인' })
data-testid는 role이나 label로 접근할 수 없는 요소에만 쓴다.
// 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의 방식대로 쓰면, 컴포넌트를 완전히 재작성해도 테스트가 살아남는다. 그게 진짜 테스트의 가치다.