icon안동민 개발노트

컴포넌트 테스트 (React Testing Library)


 React Testing Library는 React 컴포넌트를 테스트하기 위한 강력한 도구로, 사용자 중심의 접근 방식을 통해 접근성과 실제 사용 시나리오에 초점을 맞춥니다.

 이 절에서는 Next.js App Router 환경에서 React Testing Library를 사용한 효과적인 컴포넌트 테스트 전략에 대해 알아보겠습니다.

React Testing Library의 주요 개념

  1. 사용자 중심 테스트 : 내부 구현보다는 사용자가 보고 상호작용하는 방식에 초점을 맞춥니다.
  2. 접근성 쿼리 : 접근성 속성을 사용하여 요소를 선택합니다.
  3. 비동기 유틸리티 : 비동기 작업을 쉽게 테스트할 수 있는 도구를 제공합니다.

기본 사용법

 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)
  })
})

사용자 중심의 테스트 케이스 작성

 사용자의 실제 사용 패턴을 시뮬레이션하는 테스트를 작성하는 것이 중요합니다.

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')
  })
})

비동기 작업 테스트

 waitForfindBy 쿼리를 사용하여 비동기 작업을 테스트할 수 있습니다.

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()
  })
})

컴포넌트 상호작용 테스트

 사용자 상호작용을 시뮬레이션하고 결과를 검증하는 테스트

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()
  })
})

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()
    })
  })
})

 이 실습에서는 다음과 같은 시나리오를 테스트합니다.

  1. 폼 필드가 올바르게 렌더링되는지 확인
  2. 빈 필드에 대한 유효성 검사 및 에러 메시지 표시
  3. 유효한 데이터로 폼 제출 시 onSubmit 함수 호출 확인
  4. 제출 중 버튼 비활성화 및 텍스트 변경 확인

 React Testing Library를 사용한 컴포넌트 테스트는 사용자 중심의 접근 방식을 통해 컴포넌트의 동작을 효과적으로 검증할 수 있게 해줍니다.

 이는 단순히 기능이 작동하는지 확인하는 것을 넘어, 실제 사용자 경험을 시뮬레이션하고 접근성 문제를 사전에 발견할 수 있게 해줍니다.

 Next.js App Router 환경에서는 이러한 테스트 방식을 적용함으로써 서버 컴포넌트와 클라이언트 컴포넌트를 모두 효과적으로 테스트할 수 있으며, 이는 애플리케이션의 품질을 크게 향상시킬 수 있습니다.