리스트로 돌아가기
Jest와 React Testing Library로 시작하는 단위 테스트 thumbnail

Jest와 React Testing Library로 시작하는 단위 테스트

Jest와 React Testing Library를 사용해서 Next.js 프로젝트에서 단위 테스트와 통합 테스트를 작성하는 방법을 실무 관점에서 알아봤어요. 설정부터 실전 예시까지 한 번에 정리했습니다.

2026-01-12 00:00

Jest와 React Testing Library로 시작하는 단위 테스트

안녕하세요! 포테코입니다.

이전 글에서 테스트의 필요성과 전략에 대해 알아봤어요. 이번 글에서는 Jest와 React Testing Library를 사용해서 실제로 테스트를 작성하는 방법을 실무 관점에서 다뤄볼게요.

Next.js 프로젝트에서 바로 적용할 수 있는 설정 방법부터 유틸리티 함수, React 컴포넌트, 커스텀 훅 테스트까지 단계별로 정리했어요.

Jest는 테스트 실행 환경을 제공하고, React Testing Library는 컴포넌트를 사용자 관점에서 테스트할 수 있게 해줍니다. 두 도구를 함께 사용하면 효율적인 테스트를 작성할 수 있어요.

Jest와 React Testing Library란?

Jest: 테스트 프레임워크

Jest는 Facebook에서 개발한 JavaScript 테스트 프레임워크예요. 테스트 실행, 모킹, 커버리지 측정 등 테스트에 필요한 모든 기능을 제공합니다.

Jest의 주요 기능:

  • 테스트 실행 환경 제공
  • 자동 모킹 기능
  • 스냅샷 테스트 지원
  • 코드 커버리지 자동 측정
  • 빠른 실행 속도

React Testing Library: 컴포넌트 테스트 도구

React Testing Library는 컴포넌트를 사용자 관점에서 테스트하는 도구예요. 구현 세부사항보다는 사용자가 보는 것과 상호작용하는 방식에 집중합니다.

React Testing Library의 철학:

  • 구현 세부사항이 아닌 사용자 행동 테스트
  • 접근성과 사용성을 고려한 테스트
  • 실제 DOM 환경에서 테스트

Next.js 프로젝트 설정

Next.js는 기본적으로 Jest를 지원해요. 설정부터 시작해볼게요.

1. 필요한 패키지 설치

pnpm add -D jest jest-environment-jsdom @testing-library/react @testing-library/jest-dom @testing-library/user-event

2. jest.config.js 설정

const nextJest = require('next/jest')
 
const createJestConfig = nextJest({
  // Next.js 앱의 경로를 제공하여 next.config.js와 .env 파일을 로드합니다
  dir: './',
})
 
// Jest에 전달할 커스텀 설정 추가
const customJestConfig = {
  setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
  testEnvironment: 'jest-environment-jsdom',
  moduleNameMapper: {
    '^@/(.*)$': '<rootDir>/$1',
  },
  collectCoverageFrom: [
    'app/**/*.{js,jsx,ts,tsx}',
    'components/**/*.{js,jsx,ts,tsx}',
    'lib/**/*.{js,jsx,ts,tsx}',
    '!**/*.d.ts',
    '!**/node_modules/**',
  ],
}
 
// createJestConfig는 이렇게 내보내는 것이 Next.js의 비동기 설정을 로드하는 데 사용됩니다
module.exports = createJestConfig(customJestConfig)

3. jest.setup.js 설정

// jest.setup.js
import '@testing-library/jest-dom'

4. package.json 스크립트 추가

{
  "scripts": {
    "test": "jest",
    "test:watch": "jest --watch",
    "test:coverage": "jest --coverage"
  }
}

단위 테스트 작성하기

유틸리티 함수 테스트

가장 간단한 단위 테스트부터 시작해볼게요. 유틸리티 함수는 다른 코드에 의존하지 않아서 테스트하기 쉬워요.

// lib/utils.ts
export function formatDate(date: Date): string {
  return new Intl.DateTimeFormat('ko-KR', {
    year: 'numeric',
    month: 'long',
    day: 'numeric',
  }).format(date)
}
 
export function calculateTotal(items: { price: number; quantity: number }[]): number {
  return items.reduce((sum, item) => sum + item.price * item.quantity, 0)
}
// lib/__tests__/utils.test.ts
import { formatDate, calculateTotal } from '../utils'
 
describe('formatDate', () => {
  it('날짜를 한국어 형식으로 포맷합니다', () => {
    const date = new Date('2025-03-05')
    const result = formatDate(date)
    expect(result).toContain('2025')
    expect(result).toContain('3')
    expect(result).toContain('5')
  })
})
 
describe('calculateTotal', () => {
  it('아이템들의 총 금액을 계산합니다', () => {
    const items = [
      { price: 1000, quantity: 2 },
      { price: 2000, quantity: 1 },
    ]
    expect(calculateTotal(items)).toBe(4000)
  })
 
  it('빈 배열일 때 0을 반환합니다', () => {
    expect(calculateTotal([])).toBe(0)
  })
})

React 컴포넌트 테스트

React Testing Library를 사용해서 컴포넌트를 테스트해볼게요.

// components/ui/Button.tsx
"use client"
 
interface ButtonProps {
  children: React.ReactNode
  onClick?: () => void
  disabled?: boolean
  variant?: 'primary' | 'secondary'
}
 
export function Button({ 
  children, 
  onClick, 
  disabled = false,
  variant = 'primary' 
}: ButtonProps) {
  return (
    <button
      onClick={onClick}
      disabled={disabled}
      className={`px-4 py-2 rounded ${
        variant === 'primary' 
          ? 'bg-blue-500 text-white' 
          : 'bg-gray-200 text-gray-800'
      }`}
    >
      {children}
    </button>
  )
}
// components/ui/__tests__/Button.test.tsx
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Button } from '../Button'
 
describe('Button', () => {
  it('버튼이 렌더링됩니다', () => {
    render(<Button>클릭하세요</Button>)
    expect(screen.getByRole('button', { name: /클릭하세요/i })).toBeInTheDocument()
  })
 
  it('클릭 이벤트가 발생합니다', async () => {
    const handleClick = jest.fn()
    const user = userEvent.setup()
    
    render(<Button onClick={handleClick}>클릭하세요</Button>)
    
    await user.click(screen.getByRole('button'))
    expect(handleClick).toHaveBeenCalledTimes(1)
  })
 
  it('disabled 상태일 때 클릭할 수 없습니다', async () => {
    const handleClick = jest.fn()
    const user = userEvent.setup()
    
    render(<Button onClick={handleClick} disabled>클릭하세요</Button>)
    
    const button = screen.getByRole('button')
    expect(button).toBeDisabled()
    
    await user.click(button)
    expect(handleClick).not.toHaveBeenCalled()
  })
 
  it('variant에 따라 스타일이 변경됩니다', () => {
    const { rerender } = render(<Button variant="primary">Primary</Button>)
    let button = screen.getByRole('button')
    expect(button).toHaveClass('bg-blue-500')
 
    rerender(<Button variant="secondary">Secondary</Button>)
    button = screen.getByRole('button')
    expect(button).toHaveClass('bg-gray-200')
  })
})

React Testing Library는 getByRole을 사용해서 요소를 찾는 것을 권장해요. 접근성과 사용성을 고려한 테스트가 됩니다.

커스텀 훅 테스트

커스텀 훅을 테스트할 때는 renderHook을 사용할 수 있어요.

// hooks/useCounter.ts
import { useState } from 'react'
 
export function useCounter(initialValue = 0) {
  const [count, setCount] = useState(initialValue)
 
  const increment = () => setCount(count + 1)
  const decrement = () => setCount(count - 1)
  const reset = () => setCount(initialValue)
 
  return { count, increment, decrement, reset }
}
// hooks/__tests__/useCounter.test.tsx
import { renderHook, act } from '@testing-library/react'
import { useCounter } from '../useCounter'
 
describe('useCounter', () => {
  it('초기값을 설정할 수 있습니다', () => {
    const { result } = renderHook(() => useCounter(5))
    expect(result.current.count).toBe(5)
  })
 
  it('increment가 카운트를 증가시킵니다', () => {
    const { result } = renderHook(() => useCounter())
    
    act(() => {
      result.current.increment()
    })
    
    expect(result.current.count).toBe(1)
  })
 
  it('decrement가 카운트를 감소시킵니다', () => {
    const { result } = renderHook(() => useCounter(5))
    
    act(() => {
      result.current.decrement()
    })
    
    expect(result.current.count).toBe(4)
  })
 
  it('reset이 초기값으로 되돌립니다', () => {
    const { result } = renderHook(() => useCounter(5))
    
    act(() => {
      result.current.increment()
      result.current.increment()
      result.current.reset()
    })
    
    expect(result.current.count).toBe(5)
  })
})

통합 테스트 작성하기

통합 테스트는 여러 컴포넌트가 함께 작동하는지 확인해요.

// components/forms/ContactForm.tsx
"use client"
 
import { useState } from 'react'
import { Button } from '@/components/ui/Button'
 
export function ContactForm() {
  const [formData, setFormData] = useState({ name: '', email: '', message: '' })
  const [isSubmitting, setIsSubmitting] = useState(false)
  const [error, setError] = useState('')
 
  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault()
    setIsSubmitting(true)
    setError('')
 
    try {
      // API 호출 시뮬레이션
      await new Promise(resolve => setTimeout(resolve, 1000))
      
      if (!formData.email.includes('@')) {
        throw new Error('올바른 이메일을 입력해주세요')
      }
 
      // 성공 처리
      setFormData({ name: '', email: '', message: '' })
    } catch (err) {
      setError(err instanceof Error ? err.message : '오류가 발생했습니다')
    } finally {
      setIsSubmitting(false)
    }
  }
 
  return (
    <form onSubmit={handleSubmit}>
      <div>
        <label htmlFor="name">이름</label>
        <input
          id="name"
          value={formData.name}
          onChange={(e) => setFormData({ ...formData, name: e.target.value })}
        />
      </div>
      
      <div>
        <label htmlFor="email">이메일</label>
        <input
          id="email"
          type="email"
          value={formData.email}
          onChange={(e) => setFormData({ ...formData, email: e.target.value })}
        />
      </div>
      
      <div>
        <label htmlFor="message">메시지</label>
        <textarea
          id="message"
          value={formData.message}
          onChange={(e) => setFormData({ ...formData, message: e.target.value })}
        />
      </div>
 
      {error && <div role="alert">{error}</div>}
 
      <Button type="submit" disabled={isSubmitting}>
        {isSubmitting ? '전송 중...' : '전송'}
      </Button>
    </form>
  )
}
// components/forms/__tests__/ContactForm.test.tsx
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { ContactForm } from '../ContactForm'
 
describe('ContactForm', () => {
  it('폼이 제출되면 입력값이 초기화됩니다', async () => {
    const user = userEvent.setup()
    render(<ContactForm />)
 
    const nameInput = screen.getByLabelText(/이름/i)
    const emailInput = screen.getByLabelText(/이메일/i)
    const messageInput = screen.getByLabelText(/메시지/i)
    const submitButton = screen.getByRole('button', { name: /전송/i })
 
    await user.type(nameInput, '홍길동')
    await user.type(emailInput, 'hong@example.com')
    await user.type(messageInput, '테스트 메시지')
    await user.click(submitButton)
 
    await waitFor(() => {
      expect(nameInput).toHaveValue('')
      expect(emailInput).toHaveValue('')
      expect(messageInput).toHaveValue('')
    })
  })
 
  it('잘못된 이메일일 때 에러 메시지를 표시합니다', async () => {
    const user = userEvent.setup()
    render(<ContactForm />)
 
    const emailInput = screen.getByLabelText(/이메일/i)
    const submitButton = screen.getByRole('button', { name: /전송/i })
 
    await user.type(emailInput, 'invalid-email')
    await user.click(submitButton)
 
    await waitFor(() => {
      expect(screen.getByRole('alert')).toHaveTextContent('올바른 이메일을 입력해주세요')
    })
  })
 
  it('제출 중에는 버튼이 비활성화됩니다', async () => {
    const user = userEvent.setup()
    render(<ContactForm />)
 
    const emailInput = screen.getByLabelText(/이메일/i)
    const submitButton = screen.getByRole('button', { name: /전송/i })
 
    await user.type(emailInput, 'test@example.com')
    await user.click(submitButton)
 
    expect(submitButton).toBeDisabled()
    expect(submitButton).toHaveTextContent('전송 중...')
 
    await waitFor(() => {
      expect(submitButton).not.toBeDisabled()
    })
  })
})

Next.js 특수 케이스 테스트

서버 컴포넌트 테스트

서버 컴포넌트는 직접 테스트하기 어려워요. 대신 서버 컴포넌트가 렌더링하는 결과물을 클라이언트 컴포넌트로 테스트하거나, 서버 컴포넌트의 로직을 별도 함수로 분리해서 테스트하는 것이 좋아요.

// app/posts/page.tsx (서버 컴포넌트)
import { getPosts } from '@/lib/posts'
import { PostList } from '@/components/posts/PostList'
 
export default async function PostsPage() {
  const posts = await getPosts()
  return <PostList posts={posts} />
}
// lib/__tests__/posts.test.ts
import { getPosts } from '../posts'
 
describe('getPosts', () => {
  it('포스트 목록을 가져옵니다', async () => {
    const posts = await getPosts()
    expect(Array.isArray(posts)).toBe(true)
    expect(posts[0]).toHaveProperty('title')
    expect(posts[0]).toHaveProperty('date')
  })
})

API 라우트 테스트

API 라우트는 서버 환경에서 테스트해야 해요.

// app/api/posts/route.ts
import { NextRequest, NextResponse } from 'next/server'
 
export async function GET(request: NextRequest) {
  const searchParams = request.nextUrl.searchParams
  const page = searchParams.get('page') || '1'
 
  // 데이터 페칭 로직
  const posts = await fetchPosts(parseInt(page))
 
  return NextResponse.json({ posts })
}
// app/api/posts/__tests__/route.test.ts
import { GET } from '../route'
import { NextRequest } from 'next/server'
 
describe('/api/posts', () => {
  it('포스트 목록을 반환합니다', async () => {
    const request = new NextRequest('http://localhost:3000/api/posts?page=1')
    const response = await GET(request)
    const data = await response.json()
 
    expect(response.status).toBe(200)
    expect(data).toHaveProperty('posts')
    expect(Array.isArray(data.posts)).toBe(true)
  })
})

Mock 사용하기

외부 의존성을 Mock으로 대체해서 테스트를 격리할 수 있어요.

함수 Mock

// lib/api.ts
export async function fetchUser(id: number) {
  const response = await fetch(`/api/users/${id}`)
  return response.json()
}
// lib/__tests__/api.test.ts
import { fetchUser } from '../api'
 
// fetch를 Mock으로 대체
global.fetch = jest.fn()
 
describe('fetchUser', () => {
  it('사용자 정보를 가져옵니다', async () => {
    const mockUser = { id: 1, name: 'John' }
    
    ;(global.fetch as jest.Mock).mockResolvedValueOnce({
      json: async () => mockUser,
    })
 
    const user = await fetchUser(1)
    expect(user).toEqual(mockUser)
    expect(global.fetch).toHaveBeenCalledWith('/api/users/1')
  })
})

모듈 Mock

// lib/__tests__/userService.test.ts
import { getUserName } from '../userService'
 
// 외부 모듈을 Mock으로 대체
jest.mock('../api', () => ({
  fetchUser: jest.fn(),
}))
 
import { fetchUser } from '../api'
 
describe('getUserName', () => {
  it('사용자 이름을 반환합니다', async () => {
    ;(fetchUser as jest.Mock).mockResolvedValueOnce({ name: 'John' })
    
    const name = await getUserName(1)
    expect(name).toBe('John')
  })
})

테스트 실행하기

기본 실행

pnpm test

Watch 모드

파일을 수정할 때마다 자동으로 테스트를 실행해요.

pnpm test:watch

커버리지 확인

코드 커버리지를 확인할 수 있어요.

pnpm test:coverage

마무리

Jest와 React Testing Library를 사용하면 효율적인 단위 테스트와 통합 테스트를 작성할 수 있어요. 처음에는 어려워 보일 수 있지만, 하나씩 작성하다 보면 자연스럽게 익숙해집니다.

테스트를 작성할 때는 사용자 관점에서 생각하는 것이 중요해요. 구현 세부사항보다는 사용자가 보는 것과 상호작용하는 방식에 집중하면 더 의미 있는 테스트를 작성할 수 있습니다.

다음 단계

  1. 핵심 기능부터 시작: 가장 중요한 비즈니스 로직부터 테스트를 작성해요
  2. 점진적 확장: 새로운 기능을 추가할 때마다 테스트도 함께 작성해요
  3. 정기적 리뷰: 테스트 코드도 정기적으로 리뷰하고 개선해요

다음 글에서는 Playwright를 사용해서 E2E 테스트를 작성하는 방법을 알아볼게요. 기대해 주세요!