컴포넌트 테스트 (React Testing Library)
React Testing Library는 React 컴포넌트를 테스트하기 위한 강력한 도구로, 사용자 중심의 접근 방식을 통해 접근성과 실제 사용 시나리오에 초점을 맞춥니다.
이 절에서는 Next.js App Router 환경에서 React Testing Library를 사용한 효과적인 컴포넌트 테스트 전략에 대해 알아보겠습니다.
React Testing Library의 주요 개념
- 사용자 중심 테스트 : 내부 구현보다는 사용자가 보고 상호작용하는 방식에 초점을 맞춥니다.
- 접근성 쿼리 : 접근성 속성을 사용하여 요소를 선택합니다.
- 비동기 유틸리티 : 비동기 작업을 쉽게 테스트할 수 있는 도구를 제공합니다.
기본 사용법
Next.js App Router 프로젝트에서의 기본적인 컴포넌트 테스트 예제
'use client'
export function Button({ onClick, children }) {
return <button onClick={onClick}>{children}</button>
}
import { render, screen, fireEvent } from '@testing-library/react'
import { Button } from './Button'
describe('Button', () => {
it('renders with correct text', () => {
render(<Button>Click me</Button>)
expect(screen.getByText('Click me')).toBeInTheDocument()
})
it('calls onClick prop when clicked', () => {
const handleClick = jest.fn()
render(<Button onClick={handleClick}>Click me</Button>)
fireEvent.click(screen.getByText('Click me'))
expect(handleClick).toHaveBeenCalledTimes(1)
})
})
사용자 중심의 테스트 케이스 작성
사용자의 실제 사용 패턴을 시뮬레이션하는 테스트를 작성하는 것이 중요합니다.
'use client'
export function SearchBar({ onSearch }) {
return (
<form onSubmit={(e) => { e.preventDefault(); onSearch(e.target.search.value); }}>
<label htmlFor="search">Search:</label>
<input id="search" name="search" type="text" />
<button type="submit">Search</button>
</form>
)
}
import { render, screen, fireEvent } from '@testing-library/react'
import { SearchBar } from './SearchBar'
describe('SearchBar', () => {
it('allows users to submit a search query', () => {
const handleSearch = jest.fn()
render(<SearchBar onSearch={handleSearch} />)
const input = screen.getByLabelText('Search:')
fireEvent.change(input, { target: { value: 'test query' } })
const submitButton = screen.getByRole('button', { name: /search/i })
fireEvent.click(submitButton)
expect(handleSearch).toHaveBeenCalledWith('test query')
})
})
비동기 작업 테스트
waitFor
와 findBy
쿼리를 사용하여 비동기 작업을 테스트할 수 있습니다.
'use client'
import { useState, useEffect } from 'react'
export function UserProfile({ userId }) {
const [user, setUser] = useState(null)
useEffect(() => {
async function fetchUser() {
const response = await fetch(`/api/users/${userId}`)
const data = await response.json()
setUser(data)
}
fetchUser()
}, [userId])
if (!user) return <div>Loading...</div>
return <div>Name: {user.name}</div>
}
import { render, screen } from '@testing-library/react'
import { UserProfile } from './UserProfile'
describe('UserProfile', () => {
it('displays user data after fetching', async () => {
global.fetch = jest.fn(() =>
Promise.resolve({
json: () => Promise.resolve({ name: 'John Doe' }),
})
)
render(<UserProfile userId={1} />)
expect(screen.getByText('Loading...')).toBeInTheDocument()
expect(await screen.findByText('Name: John Doe')).toBeInTheDocument()
})
})
컴포넌트 상호작용 테스트
사용자 상호작용을 시뮬레이션하고 결과를 검증하는 테스트
'use client'
import { useState } from 'react'
export function Counter() {
const [count, setCount] = useState(0)
return (
<div>
<span>Count: {count}</span>
<button onClick={() => setCount(count + 1)}>Increment</button>
</div>
)
}
import { render, screen, fireEvent } from '@testing-library/react'
import { Counter } from './Counter'
describe('Counter', () => {
it('increments count when button is clicked', () => {
render(<Counter />)
const button = screen.getByText('Increment')
fireEvent.click(button)
expect(screen.getByText('Count: 1')).toBeInTheDocument()
})
})
5장 페이지, 레이아웃 컴포넌트와의 연관성
14장의 컴포넌트 테스트는 5장에서 다룬 페이지 및 레이아웃 컴포넌트와 직접적으로 연관됩니다.
Next.js App Router에서 페이지 컴포넌트(page.js
)와 레이아웃 컴포넌트(layout.js
)도 React 컴포넌트이므로 React Testing Library를 사용하여 테스트할 수 있습니다.
특히 레이아웃 컴포넌트의 경우, 다양한 화면 크기에서의 렌더링을 테스트하거나, 조건부 렌더링을 검증하는 테스트를 작성할 수 있습니다.
실습 : 복잡한 컴포넌트 테스트 Suite 작성
폼 제출, 상태 변경, 조건부 렌더링을 포함하는 복잡한 컴포넌트에 대한 종합적인 테스트 suite를 작성해보겠습니다.
'use client'
import { useState } from 'react'
export function RegistrationForm({ onSubmit }) {
const [formData, setFormData] = useState({ username: '', email: '', password: '' })
const [errors, setErrors] = useState({})
const [isSubmitting, setIsSubmitting] = useState(false)
const handleChange = (e) => {
setFormData({ ...formData, [e.target.name]: e.target.value })
}
const validateForm = () => {
const newErrors = {}
if (!formData.username) newErrors.username = 'Username is required'
if (!formData.email) newErrors.email = 'Email is required'
if (!formData.password) newErrors.password = 'Password is required'
setErrors(newErrors)
return Object.keys(newErrors).length === 0
}
const handleSubmit = async (e) => {
e.preventDefault()
if (validateForm()) {
setIsSubmitting(true)
await onSubmit(formData)
setIsSubmitting(false)
}
}
return (
<form onSubmit={handleSubmit}>
<div>
<label htmlFor="username">Username</label>
<input
id="username"
name="username"
value={formData.username}
onChange={handleChange}
/>
{errors.username && <span role="alert">{errors.username}</span>}
</div>
<div>
<label htmlFor="email">Email</label>
<input
id="email"
name="email"
type="email"
value={formData.email}
onChange={handleChange}
/>
{errors.email && <span role="alert">{errors.email}</span>}
</div>
<div>
<label htmlFor="password">Password</label>
<input
id="password"
name="password"
type="password"
value={formData.password}
onChange={handleChange}
/>
{errors.password && <span role="alert">{errors.password}</span>}
</div>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Submitting...' : 'Register'}
</button>
</form>
)
}
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { RegistrationForm } from './RegistrationForm'
describe('RegistrationForm', () => {
it('renders form fields correctly', () => {
render(<RegistrationForm onSubmit={() => {}} />)
expect(screen.getByLabelText('Username')).toBeInTheDocument()
expect(screen.getByLabelText('Email')).toBeInTheDocument()
expect(screen.getByLabelText('Password')).toBeInTheDocument()
})
it('shows error messages for empty fields on submit', async () => {
render(<RegistrationForm onSubmit={() => {}} />)
fireEvent.click(screen.getByText('Register'))
await waitFor(() => {
expect(screen.getByText('Username is required')).toBeInTheDocument()
expect(screen.getByText('Email is required')).toBeInTheDocument()
expect(screen.getByText('Password is required')).toBeInTheDocument()
})
})
it('calls onSubmit with form data when valid', async () => {
const handleSubmit = jest.fn()
render(<RegistrationForm onSubmit={handleSubmit} />)
fireEvent.change(screen.getByLabelText('Username'), { target: { value: 'testuser' } })
fireEvent.change(screen.getByLabelText('Email'), { target: { value: 'test@example.com' } })
fireEvent.change(screen.getByLabelText('Password'), { target: { value: 'password123' } })
fireEvent.click(screen.getByText('Register'))
await waitFor(() => {
expect(handleSubmit).toHaveBeenCalledWith({
username: 'testuser',
email: 'test@example.com',
password: 'password123'
})
})
})
it('disables submit button while submitting', async () => {
const handleSubmit = jest.fn(() => new Promise(resolve => setTimeout(resolve, 1000)))
render(<RegistrationForm onSubmit={handleSubmit} />)
fireEvent.change(screen.getByLabelText('Username'), { target: { value: 'testuser' } })
fireEvent.change(screen.getByLabelText('Email'), { target: { value: 'test@example.com' } })
fireEvent.change(screen.getByLabelText('Password'), { target: { value: 'password123' } })
fireEvent.click(screen.getByText('Register'))
expect(screen.getByText('Submitting...')).toBeInTheDocument()
expect(screen.getByText('Submitting...')).toBeDisabled()
await waitFor(() => {
expect(screen.getByText('Register')).toBeInTheDocument()
expect(screen.getByText('Register')).not.toBeDisabled()
})
})
})
이 실습에서는 다음과 같은 시나리오를 테스트합니다.
- 폼 필드가 올바르게 렌더링되는지 확인
- 빈 필드에 대한 유효성 검사 및 에러 메시지 표시
- 유효한 데이터로 폼 제출 시
onSubmit
함수 호출 확인 - 제출 중 버튼 비활성화 및 텍스트 변경 확인
React Testing Library를 사용한 컴포넌트 테스트는 사용자 중심의 접근 방식을 통해 컴포넌트의 동작을 효과적으로 검증할 수 있게 해줍니다.
이는 단순히 기능이 작동하는지 확인하는 것을 넘어, 실제 사용자 경험을 시뮬레이션하고 접근성 문제를 사전에 발견할 수 있게 해줍니다.
Next.js App Router 환경에서는 이러한 테스트 방식을 적용함으로써 서버 컴포넌트와 클라이언트 컴포넌트를 모두 효과적으로 테스트할 수 있으며, 이는 애플리케이션의 품질을 크게 향상시킬 수 있습니다.