React Testing Library는 React 컴포넌트를 테스트하기 위한 강력한 도구로, 사용자 중심의 접근 방식을 통해 접근성과 실제 사용 시나리오에 초점을 맞춥니다.
이 절에서는 Next.js App Router 환경에서 React Testing Library를 사용한 효과적인 컴포넌트 테스트 전략에 대해 알아보겠습니다.
React Testing Library의 주요 개념
사용자 중심 테스트 : 내부 구현보다는 사용자가 보고 상호작용하는 방식에 초점을 맞춥니다.
접근성 쿼리 : 접근성 속성을 사용하여 요소를 선택합니다.
비동기 유틸리티 : 비동기 작업을 쉽게 테스트할 수 있는 도구를 제공합니다.
기본 사용법
Next.js App Router 프로젝트에서의 기본적인 컴포넌트 테스트 예제
// app/components/Button.js
'use client'
export function Button ({ onClick , children }) {
return < button onClick = { onClick } > { children } </ button >
}
// app/components/Button.test.js
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 )
})
})
Copy
사용자 중심의 테스트 케이스 작성
사용자의 실제 사용 패턴을 시뮬레이션하는 테스트를 작성하는 것이 중요합니다.
// app/components/SearchBar.js
'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 >
)
}
// app/components/SearchBar.test.js
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' )
})
})
Copy
비동기 작업 테스트
waitFor
와 findBy
쿼리를 사용하여 비동기 작업을 테스트할 수 있습니다.
// app/components/UserProfile.js
'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 >
}
// app/components/UserProfile.test.js
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 ()
})
})
Copy
컴포넌트 상호작용 테스트
사용자 상호작용을 시뮬레이션하고 결과를 검증하는 테스트
// app/components/Counter.js
'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 >
)
}
// app/components/Counter.test.js
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 ()
})
})
Copy
5장 페이지 및 레이아웃 컴포넌트와의 연관성
14장의 컴포넌트 테스트는 5장에서 다룬 페이지 및 레이아웃 컴포넌트와 직접적으로 연관됩니다. Next.js App Router에서 페이지 컴포넌트(page.js
)와 레이아웃 컴포넌트(layout.js
)도 React 컴포넌트이므로 React Testing Library를 사용하여 테스트할 수 있습니다. 특히 레이아웃 컴포넌트의 경우, 다양한 화면 크기에서의 렌더링을 테스트하거나, 조건부 렌더링을 검증하는 테스트를 작성할 수 있습니다.
실습 : 복잡한 컴포넌트 테스트 Suite 작성
폼 제출, 상태 변경, 조건부 렌더링을 포함하는 복잡한 컴포넌트에 대한 종합적인 테스트 suite를 작성해보겠습니다.
// app/components/RegistrationForm.js
'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 >
)
}
// app/components/RegistrationForm.test.js
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: '[email protected] ' } })
fireEvent . change ( screen . getByLabelText ( 'Password' ), { target: { value: 'password123' } })
fireEvent . click ( screen . getByText ( 'Register' ))
await waitFor (() => {
expect ( handleSubmit ). toHaveBeenCalledWith ({
username: 'testuser' ,
email: '[email protected] ' ,
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: '[email protected] ' } })
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 ()
})
})
})
Copy
이 실습에서는 다음과 같은 시나리오를 테스트합니다.
폼 필드가 올바르게 렌더링되는지 확인
빈 필드에 대한 유효성 검사 및 에러 메시지 표시
유효한 데이터로 폼 제출 시 onSubmit
함수 호출 확인
제출 중 버튼 비활성화 및 텍스트 변경 확인
React Testing Library를 사용한 컴포넌트 테스트는 사용자 중심의 접근 방식을 통해 컴포넌트의 동작을 효과적으로 검증할 수 있게 해줍니다. 이는 단순히 기능이 작동하는지 확인하는 것을 넘어, 실제 사용자 경험을 시뮬레이션하고 접근성 문제를 사전에 발견할 수 있게 해줍니다. Next.js App Router 환경에서 이러한 테스트 방식을 적용함으로써, 서버 컴포넌트와 클라이언트 컴포넌트를 모두 효과적으로 테스트할 수 있으며, 이는 애플리케이션의 품질을 크게 향상시킬 수 있습니다.