테마 전환

Next.js 단위 테스트 실전: Jest + React Testing Library 완전 설정 가이드

Easton editorial illustration: component assembly loom

월요일 오전 10시, 기술 리더가 단체 채팅방에 한마디를 남겼습니다. “이번 주부터 프로젝트에 단위 테스트를 추가합니다. Next.js에는 Jest를 쓰세요.”

React 테스트를 작성해 본 적은 있었지만 Next.js의 App Router와 Server Components는 어떻게 테스트해야 할지 전혀 몰랐습니다. 프로젝트를 열고 자신 있게 npm install jest를 입력한 뒤 npm test를 실행했습니다. 그러자 화면이 수많은 빨간 오류로 가득 찼습니다.

Error: Cannot use import statement outside a module
SyntaxError: Unexpected token 'export'
Cannot find module 'next/navigation'

이틀 내내 설정 파일과 Stack Overflow, GitHub Issues를 오갔습니다. 열 가지가 넘는 설정 방식을 시도하고 jest.config.js를 셀 수 없이 수정한 끝에 테스트가 통과한 순간에는 키보드를 집어 던지며 축하하고 싶을 정도였습니다.

Next.js 테스트 환경 설정은 생각보다 복잡하지만 인터넷에서 말하는 것처럼 신비한 일은 아닙니다. 몇 가지 핵심 설정을 이해하고 자주 빠지는 함정만 피하면 10분 안에도 실행할 수 있습니다. 이 글에서는 Next.js 15 + Jest + React Testing Library 환경을 처음부터 설정하고, Client Components, Server Components, Hooks, API Mock 테스트 방법과 직접 겪었던 문제를 함께 설명합니다.

테스트 환경 설정: 처음부터 실행까지

우선 환경부터 실행해 봅시다. 조금 지루해 보일 수 있지만 각 설정 항목이 왜 필요한지 설명하므로 코드를 복사한 뒤에도 막막한 상황을 피할 수 있습니다.

의존성 설치

터미널을 열고 다음 패키지를 한 번에 설치합니다.

npm install -D jest jest-environment-jsdom @testing-library/react @testing-library/dom @testing-library/jest-dom ts-node @types/jest

각 패키지의 역할은 다음과 같습니다.

  • jest: 테스트 프레임워크 본체
  • jest-environment-jsdom: 브라우저 환경 시뮬레이션(React 컴포넌트에는 DOM 필요)
  • @testing-library/react: React 컴포넌트 테스트 도구
  • @testing-library/jest-dom: toBeInTheDocument() 같은 추가 assertion 메서드
  • ts-node, @types/jest: TypeScript 지원(JS를 사용한다면 생략 가능)

설치가 끝나도 바로 테스트를 실행하지 마세요. 아직 설정 파일을 만들어야 합니다.

jest.config.ts 생성

가장 중요한 설정 파일입니다. 프로젝트 루트에 jest.config.ts를 새로 만듭니다. JS를 사용한다면 확장자는 .js입니다.

import type { Config } from 'jest'
import nextJest from 'next/jest'

// 이 함수는 Next.js 설정을 자동으로 불러옵니다.
const createJestConfig = nextJest({
  dir: './', // Next.js 프로젝트 루트 디렉터리
})

const config: Config = {
  coverageProvider: 'v8', // 코드 커버리지 도구
  testEnvironment: 'jsdom', // 브라우저 환경 시뮬레이션
  setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'], // 테스트 시작 전 설정 파일
}

// createJestConfig로 설정을 감싸 Next.js의 다양한 변환을 자동 처리합니다.
export default createJestConfig(config)

여기서 핵심은 왜 next/jest로 설정을 감싸야 하는가입니다.

next/jest는 다음 작업을 자동으로 처리합니다.

  • .css, .module.css 파일 처리(자동 Mock으로 테스트 오류 방지)
  • 이미지와 글꼴 같은 정적 리소스 처리
  • .env 환경 변수 로드
  • TypeScript와 JSX 변환
  • node_modules.next 디렉터리 제외

이를 사용하지 않으면 이 모든 항목을 직접 설정해야 합니다. 직접 해 보면 상당히 고통스럽습니다.

jest.setup.ts 생성

루트 디렉터리에 jest.setup.ts 파일을 하나 더 만들고 다음 한 줄을 입력합니다.

import '@testing-library/jest-dom'

이 코드는 Jest DOM의 사용자 정의 matcher를 가져오므로 다음과 같은 assertion을 사용할 수 있게 합니다.

  • expect(element).toBeInTheDocument()
  • expect(element).toHaveClass('active')
  • expect(element).toBeVisible()

이 파일이 없으면 위 메서드를 인식하지 못합니다.

경로 별칭 설정: @/ 같은 경로를 사용하는 경우

import Button from '@/components/Button'처럼 경로 별칭을 사용한다면 Jest가 이 경로를 해석하는 방법도 알려 줘야 합니다.

먼저 tsconfig.json 또는 jsconfig.json에 다음과 같은 설정이 있는지 확인합니다.

{
  "compilerOptions": {
    "baseUrl": "./",
    "paths": {
      "@/components/*": ["components/*"],
      "@/lib/*": ["lib/*"]
    }
  }
}

그런 다음 jest.config.tsmoduleNameMapper를 추가합니다.

const config: Config = {
  coverageProvider: 'v8',
  testEnvironment: 'jsdom',
  setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
  // 다음 부분을 추가합니다.
  moduleNameMapper: {
    '^@/components/(.*)$': '<rootDir>/components/$1',
    '^@/lib/(.*)$': '<rootDir>/lib/$1',
  },
}

왜 이렇게 해야 할까요? Jest는 기본적으로 @/ 같은 경로를 알지 못하고 상대 경로나 절대 경로만 인식합니다. 따라서 “@/components/Button을 보면 <rootDir>/components/Button에서 파일을 찾으라”고 알려 줘야 합니다.

테스트 스크립트 추가

마지막으로 package.json에 두 개의 스크립트를 추가합니다.

{
  "scripts": {
    "test": "jest",
    "test:watch": "jest --watch"
  }
}
  • npm test: 모든 테스트를 한 번 실행
  • npm test:watch: 감시 모드로 파일이 바뀔 때 자동 재실행

설정 검증

간단한 테스트로 설정이 제대로 되었는지 확인해 봅시다. 프로젝트에 __tests__/example.test.ts를 만듭니다.

describe('Example Test', () => {
  it('should pass', () => {
    expect(1 + 1).toBe(2)
  })
})

npm test를 실행합니다. 초록색 PASS1 passed가 보이면 설정에 성공한 것입니다.

오류가 발생해도 당황하지 마세요. 먼저 뒤의 자주 발생하는 문제 해결 섹션을 확인하면 됩니다. 오류의 90%는 그곳에서 해결할 수 있습니다.

컴포넌트 테스트 실전

설정이 끝났으니 이제 실제 테스트를 작성해 봅시다. 컴포넌트 테스트는 Next.js 테스트의 핵심이지만 Client Components와 Server Components는 테스트 방법이 완전히 다릅니다.

Client Components 테스트: 표준 절차

Client Components는 익숙한 'use client'가 붙은 컴포넌트입니다. 테스트 방법도 직관적입니다.

예를 들어 로그인 폼 컴포넌트 LoginForm.tsx가 있다고 합시다.

'use client'

import { useState } from 'react'

export default function LoginForm() {
  const [email, setEmail] = useState('')
  const [error, setError] = useState('')

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault()
    if (!email.includes('@')) {
      setError('请输入有效的邮箱')
    }
  }

  return (
    <form onSubmit={handleSubmit}>
      <input
        type="email"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
        placeholder="邮箱"
      />
      {error && <span role="alert">{error}</span>}
      <button type="submit">登录</button>
    </form>
  )
}

테스트 파일 LoginForm.test.tsx는 다음과 같습니다.

import { render, screen, fireEvent } from '@testing-library/react'
import LoginForm from '@/components/LoginForm'

describe('LoginForm', () => {
  it('should render input and button', () => {
    render(<LoginForm />)

    // 입력 필드가 존재하는지 확인
    const emailInput = screen.getByPlaceholderText('邮箱')
    expect(emailInput).toBeInTheDocument()

    // 버튼이 존재하는지 확인
    const submitButton = screen.getByRole('button', { name: '登录' })
    expect(submitButton).toBeInTheDocument()
  })

  it('should show error for invalid email', () => {
    render(<LoginForm />)

    const emailInput = screen.getByPlaceholderText('邮箱')
    const submitButton = screen.getByRole('button', { name: '登录' })

    // 사용자 입력 시뮬레이션
    fireEvent.change(emailInput, { target: { value: 'invalid-email' } })
    fireEvent.click(submitButton)

    // 오류 메시지 확인
    const errorMessage = screen.getByRole('alert')
    expect(errorMessage).toHaveTextContent('请输入有效的邮箱')
  })
})

테스트 흐름은 다음과 같습니다.

  1. render()로 컴포넌트를 렌더링합니다.
  2. screen.getByXxx()로 역할, 텍스트, placeholder 등을 기준으로 요소를 찾습니다.
  3. fireEvent로 사용자 동작을 시뮬레이션합니다.
  4. expect()로 결과를 검증합니다.

작은 팁이 하나 있습니다. 가능하면 getByTestId보다 getByRole을 사용하세요. button, alert 같은 role은 사용자가 실제로 보는 요소에 더 가깝고 테스트도 더 안정적입니다.

Server Components 테스트: 다소 난감한 부분

Server Components는 Next.js 15의 핵심 기능이지만 솔직히 Jest의 지원은 그리 친절하지 않습니다.

핵심 문제는 Jest가 async Server Components를 지원하지 않는다는 것입니다.

데이터베이스에서 데이터를 가져오는 컴포넌트를 예로 들어 보겠습니다.

// app/posts/page.tsx (Server Component)
async function getPosts() {
  const res = await fetch('https://api.example.com/posts')
  return res.json()
}

export default async function PostsPage() {
  const posts = await getPosts()

  return (
    <ul>
      {posts.map(post => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  )
}

이 컴포넌트를 그대로 테스트하면 Jest에서 Objects are not valid as a React child 오류가 발생합니다.

그렇다면 어떻게 해야 할까요? 세 가지 방법이 있습니다.

방법 1: 비즈니스 로직을 분리하고 순수 함수 테스트하기

// lib/posts.ts
export async function getPosts() {
  const res = await fetch('https://api.example.com/posts')
  if (!res.ok) throw new Error('Failed to fetch')
  return res.json()
}

// lib/posts.test.ts
import { getPosts } from './posts'

describe('getPosts', () => {
  it('should fetch posts successfully', async () => {
    global.fetch = jest.fn(() =>
      Promise.resolve({
        ok: true,
        json: () => Promise.resolve([{ id: 1, title: 'Test Post' }]),
      })
    ) as jest.Mock

    const posts = await getPosts()
    expect(posts).toHaveLength(1)
    expect(posts[0].title).toBe('Test Post')
  })
})

이렇게 하면 컴포넌트 자체가 아니라 데이터 조회 로직을 테스트하게 됩니다. 복잡한 비동기 로직을 분리해서 따로 테스트하면 컴포넌트에는 단순 렌더링만 남습니다.

방법 2: 동기 Server Components 테스트하기

Server Component가 비동기 작업을 포함하지 않는다면 정상적으로 테스트할 수 있습니다.

// components/Title.tsx (Server Component, async 없음)
export default function Title({ text }: { text: string }) {
  return <h1 className="title">{text}</h1>
}

// components/Title.test.tsx
import { render, screen } from '@testing-library/react'
import Title from './Title'

describe('Title', () => {
  it('should render title', () => {
    render(<Title text="Hello World" />)
    const heading = screen.getByRole('heading', { level: 1 })
    expect(heading).toHaveTextContent('Hello World')
  })
})

방법 3: E2E 테스트로 보완하기

복잡한 Server Components는 솔직히 Playwright나 Cypress로 E2E 테스트를 작성하는 편이 더 안정적입니다. Jest 단위 테스트는 프론트엔드 로직을, E2E 테스트는 전체 흐름을 맡기면 각 도구가 제 역할을 할 수 있습니다.

저는 핵심 비즈니스 로직을 순수 함수로 분리해 테스트하고 Server Components는 단순 렌더링만 담당하게 한 뒤 E2E 테스트로 전체를 검증합니다.

상호작용 테스트 기법

사용자 상호작용을 테스트할 때 @testing-library/react는 다양한 메서드를 제공합니다.

import { render, screen, fireEvent, waitFor } from '@testing-library/react'

// 클릭
fireEvent.click(button)

// 입력
fireEvent.change(input, { target: { value: 'test' } })

// 비동기 업데이트 대기
await waitFor(() => {
  expect(screen.getByText('Success')).toBeInTheDocument()
})

// 요소가 보이는지 확인
expect(element).toBeVisible()

// 클래스 이름 확인
expect(element).toHaveClass('active')

비동기 상호작용 테스트의 전체 예제는 다음과 같습니다.

it('should submit form successfully', async () => {
  // API Mock
  global.fetch = jest.fn(() =>
    Promise.resolve({
      ok: true,
      json: () => Promise.resolve({ success: true }),
    })
  ) as jest.Mock

  render(<LoginForm />)

  const emailInput = screen.getByPlaceholderText('邮箱')
  const submitButton = screen.getByRole('button', { name: '登录' })

  fireEvent.change(emailInput, { target: { value: '[email protected]' } })
  fireEvent.click(submitButton)

  // 성공 메시지가 나타날 때까지 대기
  await waitFor(() => {
    expect(screen.getByText('登录成功')).toBeInTheDocument()
  })
})

여기서 waitFor에 주목하세요. 비동기 작업이 완료될 때까지 기다린 뒤 검증합니다. 컴포넌트에 useEffect나 비동기 상태 업데이트가 있다면 반드시 사용해야 합니다. 그렇지 않으면 상태가 업데이트되기 전에 assertion이 실행되어 잘못된 실패가 발생합니다.

Hook 테스트 전략

사용자 정의 Hook은 React의 핵심 기능이지만 어떻게 테스트해야 할까요? 컴포넌트 안에서 직접 테스트하면 Hook 로직과 컴포넌트 로직이 뒤섞입니다. 더 나은 방법은 renderHook을 사용하는 것입니다.

간단한 Hook 테스트

카운터 Hook을 작성했다고 가정해 봅시다.

// hooks/useCounter.ts
import { useState } from 'react'

export function useCounter(initialValue = 0) {
  const [count, setCount] = useState(initialValue)

  const increment = () => setCount(c => c + 1)
  const decrement = () => setCount(c => c - 1)
  const reset = () => setCount(initialValue)

  return { count, increment, decrement, reset }
}

다음과 같이 테스트합니다.

// hooks/useCounter.test.ts
import { renderHook, act } from '@testing-library/react'
import { useCounter } from './useCounter'

describe('useCounter', () => {
  it('should initialize with default value', () => {
    const { result } = renderHook(() => useCounter())
    expect(result.current.count).toBe(0)
  })

  it('should initialize with custom value', () => {
    const { result } = renderHook(() => useCounter(10))
    expect(result.current.count).toBe(10)
  })

  it('should increment count', () => {
    const { result } = renderHook(() => useCounter())

    act(() => {
      result.current.increment()
    })

    expect(result.current.count).toBe(1)
  })

  it('should reset count', () => {
    const { result } = renderHook(() => useCounter(5))

    act(() => {
      result.current.increment()
      result.current.increment()
    })

    expect(result.current.count).toBe(7)

    act(() => {
      result.current.reset()
    })

    expect(result.current.count).toBe(5)
  })
})

핵심은 다음과 같습니다.

  • renderHook으로 Hook을 렌더링합니다.
  • act로 상태 업데이트 작업을 감쌉니다. 이는 상태 업데이트가 끝난 뒤 assertion을 실행하기 위한 React 규칙입니다.
  • result.current로 Hook이 반환한 값을 가져옵니다.

Context에 의존하는 Hook 테스트

Hook이 Auth Context 같은 Context에 의존한다면 Provider를 제공해야 합니다.

// hooks/useAuth.ts
import { useContext } from 'react'
import { AuthContext } from '@/contexts/AuthContext'

export function useAuth() {
  const context = useContext(AuthContext)
  if (!context) {
    throw new Error('useAuth must be used within AuthProvider')
  }
  return context
}

테스트에서는 Mock Provider를 제공합니다.

// hooks/useAuth.test.tsx
import { renderHook } from '@testing-library/react'
import { useAuth } from './useAuth'
import { AuthContext } from '@/contexts/AuthContext'

describe('useAuth', () => {
  it('should return auth context value', () => {
    const mockAuthValue = {
      user: { id: 1, name: 'Test User' },
      login: jest.fn(),
      logout: jest.fn(),
    }

    const wrapper = ({ children }: { children: React.ReactNode }) => (
      <AuthContext.Provider value={mockAuthValue}>
        {children}
      </AuthContext.Provider>
    )

    const { result } = renderHook(() => useAuth(), { wrapper })

    expect(result.current.user).toEqual({ id: 1, name: 'Test User' })
    expect(result.current.login).toBeDefined()
  })

  it('should throw error when used outside provider', () => {
    // 오류 캡처
    const { result } = renderHook(() => useAuth())
    expect(result.error).toEqual(
      Error('useAuth must be used within AuthProvider')
    )
  })
})

팁: wrapper 매개변수로 Provider를 감싸면 Hook이 Context에 접근할 수 있습니다.

비동기 Hook 테스트: 데이터 조회

요즘은 다음과 같이 데이터를 가져오는 Hook을 많이 사용합니다.

// hooks/useFetch.ts
import { useState, useEffect } from 'react'

export function useFetch<T>(url: string) {
  const [data, setData] = useState<T | null>(null)
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState<Error | null>(null)

  useEffect(() => {
    const fetchData = async () => {
      try {
        setLoading(true)
        const response = await fetch(url)
        if (!response.ok) throw new Error('Network error')
        const json = await response.json()
        setData(json)
      } catch (err) {
        setError(err as Error)
      } finally {
        setLoading(false)
      }
    }

    fetchData()
  }, [url])

  return { data, loading, error }
}

비동기 Hook을 테스트하려면 fetch를 Mock하고 상태 업데이트를 기다려야 합니다.

// hooks/useFetch.test.ts
import { renderHook, waitFor } from '@testing-library/react'
import { useFetch } from './useFetch'

describe('useFetch', () => {
  beforeEach(() => {
    // 각 테스트 전에 fetch Mock 초기화
    jest.resetAllMocks()
  })

  it('should fetch data successfully', async () => {
    const mockData = { id: 1, title: 'Test' }

    global.fetch = jest.fn(() =>
      Promise.resolve({
        ok: true,
        json: () => Promise.resolve(mockData),
      })
    ) as jest.Mock

    const { result } = renderHook(() => useFetch('/api/data'))

    // 초기 상태: loading = true
    expect(result.current.loading).toBe(true)
    expect(result.current.data).toBeNull()

    // 데이터 로드 완료 대기
    await waitFor(() => {
      expect(result.current.loading).toBe(false)
    })

    expect(result.current.data).toEqual(mockData)
    expect(result.current.error).toBeNull()
  })

  it('should handle fetch error', async () => {
    global.fetch = jest.fn(() =>
      Promise.resolve({
        ok: false,
      })
    ) as jest.Mock

    const { result } = renderHook(() => useFetch('/api/data'))

    await waitFor(() => {
      expect(result.current.loading).toBe(false)
    })

    expect(result.current.error).toBeTruthy()
    expect(result.current.error?.message).toBe('Network error')
    expect(result.current.data).toBeNull()
  })
})

주의할 점은 다음과 같습니다.

  • waitFor로 비동기 작업 완료를 기다립니다.
  • beforeEach에서 Mock을 초기화해 테스트끼리 영향을 주지 않게 합니다.
  • 성공과 실패 두 경우를 모두 테스트합니다.

Hook 테스트의 핵심은 의존성을 시뮬레이션하고, 상태 변화를 일으킨 뒤, 결과를 검증하는 것입니다. 이 세 단계를 익히면 어떤 Hook도 테스트할 수 있습니다.

Mock 기법 총정리

Mock은 테스트의 핵심입니다. Mock을 사용하지 않으면 테스트가 실제 API, 데이터베이스, 외부 서비스에 의존해 느리고 불안정해집니다. Next.js에는 특별히 Mock해야 하는 요소가 적지 않으므로 이 섹션에서 각각의 설정 방법을 살펴보겠습니다.

Next.js 라우터 Mock: 가장 자주 사용

Next.js 라우팅 Hooks인 useRouter, usePathname, useSearchParams는 테스트 환경에서 기본적으로 사용할 수 없으므로 반드시 Mock해야 합니다.

useRouter Mock: App Router

// __mocks__/next/navigation.ts
export const useRouter = jest.fn()
export const usePathname = jest.fn()
export const useSearchParams = jest.fn()

테스트 파일에서는 다음과 같이 사용합니다.

import { useRouter } from 'next/navigation'

// 라우팅 동작 Mock
jest.mock('next/navigation', () => ({
  useRouter: jest.fn(),
  usePathname: jest.fn(),
  useSearchParams: jest.fn(),
}))

describe('NavigationComponent', () => {
  it('should navigate to home on button click', () => {
    const pushMock = jest.fn()
    ;(useRouter as jest.Mock).mockReturnValue({
      push: pushMock,
      back: jest.fn(),
      forward: jest.fn(),
    })

    render(<NavigationComponent />)

    const button = screen.getByRole('button', { name: '回到首页' })
    fireEvent.click(button)

    expect(pushMock).toHaveBeenCalledWith('/')
  })
})

usePathname Mock: 현재 경로 가져오기

import { usePathname } from 'next/navigation'

jest.mock('next/navigation', () => ({
  usePathname: jest.fn(),
}))

describe('HeaderComponent', () => {
  it('should highlight active nav item', () => {
    ;(usePathname as jest.Mock).mockReturnValue('/about')

    render(<Header />)

    const aboutLink = screen.getByRole('link', { name: '关于' })
    expect(aboutLink).toHaveClass('active')
  })
})

Next.js Image 컴포넌트 Mock

next/image는 Next.js 이미지 최적화 서비스에 의존하므로 테스트 환경에서도 오류가 발생할 수 있습니다.

방법 1: 일반 img 태그로 Mock

// __mocks__/next/image.tsx
const Image = ({ src, alt }: { src: string; alt: string }) => {
  return <img src={src} alt={alt} />
}

export default Image

방법 2: jest.config.ts에서 전역 Mock

const config: Config = {
  // ... 다른 설정
  moduleNameMapper: {
    '^next/image$': '<rootDir>/__mocks__/next/image.tsx',
  },
}

이렇게 설정하면 next/image를 사용하는 모든 위치가 자동으로 Mock 버전으로 바뀝니다.

API 요청 Mock: 세 가지 방법

방법 1: 전역 fetch Mock — 가장 간단한 방식입니다.

global.fetch = jest.fn(() =>
  Promise.resolve({
    ok: true,
    json: () => Promise.resolve({ data: 'mock data' }),
  })
) as jest.Mock

방법 2: MSW(Mock Service Worker) 사용 — 더 강력한 방식입니다.

npm install -D msw
// mocks/handlers.ts
import { http, HttpResponse } from 'msw'

export const handlers = [
  http.get('/api/posts', () => {
    return HttpResponse.json([
      { id: 1, title: 'Test Post' },
    ])
  }),

  http.post('/api/login', async ({ request }) => {
    const { email } = await request.json()
    if (email === '[email protected]') {
      return HttpResponse.json({ success: true })
    }
    return HttpResponse.json({ error: 'Invalid email' }, { status: 400 })
  }),
]
// mocks/server.ts
import { setupServer } from 'msw/node'
import { handlers } from './handlers'

export const server = setupServer(...handlers)

jest.setup.ts에서 Mock Server를 시작합니다.

import '@testing-library/jest-dom'
import { server } from './mocks/server'

// 테스트 전에 Mock Server 시작
beforeAll(() => server.listen())

// 각 테스트 후 handlers 초기화
afterEach(() => server.resetHandlers())

// 모든 테스트가 끝난 뒤 Server 종료
afterAll(() => server.close())

MSW의 장점은 모든 네트워크 요청을 가로챌 수 있어 테스트마다 global.fetch를 작성할 필요가 없다는 것입니다.

방법 3: axios Mock — 프로젝트에서 axios를 사용하는 경우입니다.

npm install -D axios-mock-adapter
import axios from 'axios'
import MockAdapter from 'axios-mock-adapter'

const mock = new MockAdapter(axios)

describe('API Test', () => {
  afterEach(() => {
    mock.reset()
  })

  it('should fetch posts', async () => {
    mock.onGet('/api/posts').reply(200, [{ id: 1, title: 'Test' }])

    const response = await axios.get('/api/posts')
    expect(response.data).toHaveLength(1)
  })
})

환경 변수 Mock

Next.js 환경 변수도 테스트에서는 Mock해야 합니다.

방법 1: process.env 직접 설정

describe('Config Test', () => {
  const originalEnv = process.env

  beforeEach(() => {
    jest.resetModules()
    process.env = { ...originalEnv }
  })

  afterEach(() => {
    process.env = originalEnv
  })

  it('should use API URL from env', () => {
    process.env.NEXT_PUBLIC_API_URL = 'https://test-api.com'

    const { getApiUrl } = require('@/lib/config')
    expect(getApiUrl()).toBe('https://test-api.com')
  })
})

방법 2: .env.test 파일 사용

.env.test 파일을 만들면 next/jest가 자동으로 불러옵니다.

NEXT_PUBLIC_API_URL=https://test-api.com
DATABASE_URL=postgresql://test:test@localhost:5432/test

외부 모듈 Mock: Prisma 예제

Prisma로 데이터베이스를 조회한다면 테스트 중에는 실제 데이터베이스에 연결하고 싶지 않을 것입니다.

Prisma Client Mock

// __mocks__/prisma.ts
export const prisma = {
  user: {
    findMany: jest.fn(),
    findUnique: jest.fn(),
    create: jest.fn(),
    update: jest.fn(),
    delete: jest.fn(),
  },
  post: {
    findMany: jest.fn(),
    create: jest.fn(),
  },
}

테스트에서는 다음과 같이 사용합니다.

import { prisma } from '@/lib/prisma'

jest.mock('@/lib/prisma', () => ({
  prisma: {
    user: {
      findUnique: jest.fn(),
    },
  },
}))

describe('getUserById', () => {
  it('should return user', async () => {
    const mockUser = { id: 1, name: 'Test User' }
    ;(prisma.user.findUnique as jest.Mock).mockResolvedValue(mockUser)

    const result = await getUserById(1)
    expect(result).toEqual(mockUser)
    expect(prisma.user.findUnique).toHaveBeenCalledWith({
      where: { id: 1 },
    })
  })
})

자주 발생하는 Mock 오류와 해결 방법

문제 1: Cannot find module 'next/router'

원인: Next.js 라우터 모듈을 Mock하지 않았습니다.

해결: 테스트 파일 상단에 jest.mock('next/navigation')을 추가합니다.

문제 2: Mock이 적용되지 않음

원인: jest.mock의 위치가 잘못되었습니다. 반드시 파일 상단, import 뒤에 두어야 합니다.

import { useRouter } from 'next/navigation'

// 반드시 이 위치에서 Mock
jest.mock('next/navigation')

describe('Test', () => {
  // ...
})

문제 3: 환경 변수를 읽을 수 없음

원인: 테스트가 .env 파일을 불러오지 않았습니다.

해결: jest.config.ts 설정을 next/jest로 감쌌는지 확인합니다. 그러면 환경 변수를 자동으로 불러옵니다.

이러한 Mock 기법을 익히면 Next.js의 특수 기능도 대부분 테스트할 수 있습니다.

자주 발생하는 문제 해결

솔직히 Jest를 설정하는 동안 오류는 흔하게 발생합니다. 다행히도 오류의 90%는 몇 가지 유형으로 정해져 있고 표준 해결 방법도 있습니다.

오류 1: Cannot use import statement outside a module

전체 오류 메시지는 다음과 같습니다.

SyntaxError: Cannot use import statement outside a module

원인: Jest는 기본적으로 ES Modules를 지원하지 않지만 코드나 의존성에서 import/export를 사용하고 있습니다.

해결 방법: jest.config.ts에 다음 설정을 추가합니다.

const config: Config = {
  // ... 다른 설정
  extensionsToTreatAsEsm: ['.ts', '.tsx'],
  transformIgnorePatterns: [
    'node_modules/(?!(module-that-uses-esm)/)',
  ],
}

특정 npm 패키지, 예를 들어 nanoiduuid에서 문제가 생겼다면 패키지 이름을 transformIgnorePatterns 예외 목록에 추가합니다.

transformIgnorePatterns: [
  'node_modules/(?!(nanoid|uuid)/)',
]

오류 2: Unexpected token ‘export’

원인: 앞의 오류와 마찬가지로 특정 파일이 Jest에서 변환되지 않았습니다.

해결 방법: jest.config.tstransformtransformIgnorePatterns 설정을 확인합니다. 또한 설정을 next/jest로 감쌌는지 확인합니다.

import nextJest from 'next/jest'

const createJestConfig = nextJest({ dir: './' })

export default createJestConfig(config)

next/jest는 TypeScript와 JSX 변환을 자동으로 처리합니다.

오류 3: Cannot find module ’@/components/…’

원인: Jest가 @/ 경로 별칭을 알지 못합니다.

해결 방법: jest.config.tsmoduleNameMapper를 설정해 Jest에 @/가 가리키는 위치를 알려 줍니다.

moduleNameMapper: {
  '^@/components/(.*)$': '<rootDir>/components/$1',
  '^@/lib/(.*)$': '<rootDir>/lib/$1',
  '^@/(.*)$': '<rootDir>/$1',
}

이 경로들이 tsconfig.jsonpaths 설정과 일치하는지 확인하세요.

오류 4: Objects are not valid as a React child

원인: Jest가 지원하지 않는 async Server Component를 테스트했습니다.

해결 방법:

  1. 비동기 로직을 순수 함수로 분리해 별도로 테스트합니다.
  2. 또는 Playwright나 Cypress로 E2E 테스트를 작성합니다.

Server Components를 무리하게 Jest로 테스트할 필요는 없습니다. 어떤 도구도 모든 상황에 맞지는 않습니다.

오류 5: act(…) warning

전체 오류 메시지는 다음과 같습니다.

Warning: An update to Component inside a test was not wrapped in act(...).

원인: 컴포넌트에 useEffectsetTimeout 같은 비동기 상태 업데이트가 있는데 테스트가 업데이트 완료를 기다리지 않았습니다.

해결 방법: waitFor를 사용하거나 비동기 작업을 act로 감쌉니다.

import { waitFor } from '@testing-library/react'

it('should update state', async () => {
  render(<Component />)

  await waitFor(() => {
    expect(screen.getByText('Updated')).toBeInTheDocument()
  })
})

또는 act를 사용합니다.

import { act } from '@testing-library/react'

it('should trigger callback', async () => {
  await act(async () => {
    render(<Component />)
  })
})

테스트가 느리다면 이렇게 최적화하세요

  1. 테스트 병렬 실행
{
  "scripts": {
    "test": "jest --maxWorkers=4"
  }
}
  1. 변경된 파일만 테스트
npm test -- --onlyChanged
  1. 코드 커버리지 비활성화 — 디버깅할 때 사용합니다.
{
  "scripts": {
    "test:fast": "jest --no-coverage"
  }
}
  1. test.skip으로 느린 테스트 건너뛰기
describe.skip('Slow Tests', () => {
  // 이 테스트들은 건너뜁니다.
})

문제 진단 체크리스트

오류가 발생하면 다음 순서로 확인하세요.

  1. jest.config.ts 설정을 next/jest로 감쌌는가?
  2. jest.setup.ts에서 @testing-library/jest-dom을 올바르게 import했는가?
  3. ✅ 경로 별칭 설정이 tsconfig.json과 일치하는가?
  4. ✅ Mock이 필요한 모듈인 next/navigation, next/image를 Mock했는가?
  5. ✅ 비동기 작업에 waitForact를 사용했는가?
  6. ✅ 의존성 버전이 호환되는가? 특히 React 19와 Jest를 확인하세요.

이 체크리스트를 한 번 따라가면 대부분의 문제를 해결할 수 있습니다.

마무리

테스트 환경 설정은 번거롭지만 한 번 실행되기 시작하면 코드 품질을 한 단계 끌어올릴 수 있습니다.

처음 테스트를 작성할 때는 저도 시간 낭비라고 생각했습니다. 기능은 5분 만에 만들었는데 테스트를 작성하는 데 10분이 걸리기도 했습니다. 하지만 테스트가 생기고 나니 코드를 리팩터링하거나 버그를 수정할 때 훨씬 안심할 수 있었습니다. 코드를 고친 뒤 테스트를 한 번 실행해 모두 초록색이면 안심할 수 있고, 빨간색이 나타나면 무엇을 망가뜨렸는지 바로 알 수 있습니다.

제가 드리는 조언은 다음과 같습니다.

  • 프로젝트가 커진 뒤에야 테스트를 시작하지 마세요. 지금부터 새 기능마다 테스트를 추가해 습관으로 만드는 것이 좋습니다.
  • 100% 커버리지를 고집할 필요는 없습니다. 핵심 비즈니스 로직과 버그가 발생하기 쉬운 부분을 제대로 검증하면 됩니다.
  • Server Components를 테스트하기 어렵다면 억지로 시도하지 말고 로직을 분리하거나 E2E 테스트로 보완하세요.
  • 오류가 발생해도 당황하지 말고 앞의 체크리스트를 따라가세요. 대부분 해결할 수 있습니다.

이 글의 설정 파일을 저장해 두었다가 다음 프로젝트에서 그대로 복사하면 10분 안에 테스트 환경을 실행할 수 있습니다. 테스트를 꾸준히 작성하면 버그는 줄고 코드 품질은 자연스럽게 높아집니다.

설정하거나 테스트를 작성하는 과정에서 문제가 생기면 댓글로 이야기해 주세요. 저도 같은 시행착오를 겪었으니 도울 수 있는 부분은 기꺼이 돕겠습니다.

Next.js Jest 테스트 환경 전체 설정 절차

Next.js 15 + Jest + React Testing Library 테스트 환경을 처음부터 설정하는 상세 절차

⏱️ Estimated time: 15 min

  1. 1

    Step 1: 테스트 의존성 패키지 설치

    Jest와 React Testing Library 관련 패키지를 설치합니다.

    • npm install -D jest jest-environment-jsdom
    • npm install -D @testing-library/react @testing-library/dom @testing-library/jest-dom
    • npm install -D ts-node @types/jest(TypeScript 프로젝트에 필요)

    패키지 설명:
    • jest: 테스트 프레임워크 핵심
    • jest-environment-jsdom: 브라우저 DOM 환경 시뮬레이션
    • @testing-library/react: React 컴포넌트 테스트 도구
    • @testing-library/jest-dom: toBeInTheDocument 같은 확장 matcher 제공

    한 번에 설치를 마쳐도 바로 테스트를 실행하지 말고 먼저 설정 파일을 만듭니다.
  2. 2

    Step 2: Jest 설정 파일 생성

    프로젝트 루트에 jest.config.ts를 만들고 next/jest로 Next.js 기능을 자동 처리합니다.

    ```typescript
    import type { Config } from 'jest'
    import nextJest from 'next/jest'

    const createJestConfig = nextJest({ dir: './' })

    const config: Config = {
    coverageProvider: 'v8',
    testEnvironment: 'jsdom',
    setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
    // 경로 별칭을 사용한다면 moduleNameMapper 추가
    moduleNameMapper: {
    '^@/components/(.*)$': '<rootDir>/components/$1',
    '^@/lib/(.*)$': '<rootDir>/lib/$1',
    },
    }

    export default createJestConfig(config)
    ```

    next/jest는 CSS와 이미지 Mock, 환경 변수 로드, TypeScript 변환, node_modules 제외를 자동 처리합니다.
  3. 3

    Step 3: Jest 시작 설정 생성

    프로젝트 루트에 jest.setup.ts를 만들고 Jest DOM 확장을 가져옵니다.

    ```typescript
    import '@testing-library/jest-dom'
    ```

    이 한 줄을 추가하면 다음과 같은 확장 assertion을 사용할 수 있습니다.
    • expect(element).toBeInTheDocument()
    • expect(element).toHaveClass('active')
    • expect(element).toBeVisible()
    • expect(element).toHaveTextContent('text')

    이 파일을 가져오지 않으면 위 메서드에서 "not a function" 오류가 발생합니다.
  4. 4

    Step 4: 테스트 스크립트 설정

    package.json에 테스트 명령을 추가합니다.

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

    세 명령의 용도:
    • npm test: 모든 테스트 실행(CI/CD용)
    • npm run test:watch: 감시 모드로 파일 변경 시 자동 테스트(개발용)
    • npm run test:coverage: 코드 커버리지 보고서 생성
  5. 5

    Step 5: 예제 테스트로 설정 검증

    __tests__/example.test.ts를 만들어 환경 설정이 성공했는지 확인합니다.

    ```typescript
    describe('Example Test', () => {
    it('should pass basic assertion', () => {
    expect(1 + 1).toBe(2)
    })
    })
    ```

    npm test를 실행했을 때 초록색 PASS가 보이면 설정에 성공한 것입니다.

    오류가 발생하면 다음 순서로 확인합니다.
    1. jest.config.ts를 createJestConfig로 감쌌는지
    2. jest.setup.ts에서 올바르게 import했는지
    3. package.json의 테스트 스크립트가 올바른지
    4. 경로 별칭 설정이 tsconfig.json과 일치하는지

FAQ

왜 반드시 next/jest로 설정을 감싸야 하나요?
next/jest는 Next.js가 공식 제공하는 Jest 설정 도구이며 다음과 같은 복잡한 설정을 자동 처리합니다.

• CSS와 이미지 파일을 자동으로 Mock해 테스트 오류 방지
• .env 환경 변수 자동 로드
• TypeScript와 JSX 자동 변환
• node_modules와 .next 디렉터리 자동 제외
• Next.js Compiler 변환 규칙 설정

next/jest를 사용하지 않으면 이 모든 항목을 수동으로 설정해야 하므로 번거롭고 오류도 발생하기 쉽습니다. 공식 문서도 createJestConfig로 설정을 감싸는 방식을 권장합니다.
Server Components를 Jest로 테스트할 수 있나요?
일부는 가능하지만 제약이 있습니다.

• 동기 Server Components: 정상적으로 테스트 가능
• async Server Components: Jest가 지원하지 않으며 "Objects are not valid as a React child" 오류 발생

권장 방식:
1. 비동기 로직을 순수 함수로 분리해 데이터 조회 로직을 별도로 테스트합니다.
2. Server Components는 복잡한 로직 없이 단순 렌더링만 담당하게 합니다.
3. Playwright나 Cypress로 E2E 테스트를 작성해 전체 흐름을 검증합니다.

모든 Server Components를 Jest로 억지로 테스트하기보다 목적에 맞는 도구를 선택하는 것이 더 중요합니다.
테스트에서 Next.js의 useRouter를 어떻게 Mock하나요?
Next.js App Router의 라우팅 Hooks는 테스트하려면 반드시 Mock해야 합니다.

```typescript
import { useRouter } from 'next/navigation'

jest.mock('next/navigation', () => ({
useRouter: jest.fn(),
usePathname: jest.fn(),
useSearchParams: jest.fn(),
}))

// 테스트에서 반환값 설정
const pushMock = jest.fn()
;(useRouter as jest.Mock).mockReturnValue({
push: pushMock,
back: jest.fn(),
forward: jest.fn(),
})
```

jest.mock은 반드시 테스트 파일 상단, 즉 import 뒤에 두어야 하며 describe나 it 안에 넣으면 안 됩니다.
왜 'Cannot use import statement outside a module' 오류가 발생하나요?
Jest가 기본적으로 ES Modules를 지원하지 않아 생기는 문제이며 두 가지 해결 방법이 있습니다.

방법 1: transformIgnorePatterns 설정(권장)
```typescript
// jest.config.ts
const config: Config = {
extensionsToTreatAsEsm: ['.ts', '.tsx'],
transformIgnorePatterns: [
'node_modules/(?!(nanoid|uuid)/)', // ESM을 사용하는 패키지 나열
],
}
```

방법 2: next/jest로 설정을 올바르게 감쌌는지 확인
```typescript
import nextJest from 'next/jest'
const createJestConfig = nextJest({ dir: './' })
export default createJestConfig(config)
```

90%는 특정 npm 패키지가 ESM을 사용해서 생기므로 해당 패키지 이름을 transformIgnorePatterns 예외 목록에 추가하면 됩니다.
API 요청은 어떻게 Mock하며 어떤 방식을 권장하나요?
복잡도에 따라 세 가지 방식을 자주 사용합니다.

방법 1: 전역 fetch Mock(가장 간단하며 소규모 프로젝트에 적합)
```typescript
global.fetch = jest.fn(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve({ data: 'test' }),
})
) as jest.Mock
```

방법 2: MSW(Mock Service Worker, 권장, 중대형 프로젝트에 적합)
• 모든 네트워크 요청 가로채기 가능
• 복잡한 요청/응답 로직 지원
• 테스트마다 Mock을 반복해서 작성할 필요 없음
• 설치: npm install -D msw

방법 3: axios-mock-adapter(프로젝트에서 axios를 사용하는 경우)
• axios 전용 Mock 라이브러리
• API가 간결하고 사용하기 쉬움

실제 네트워크 요청과 더 유사하고 재사용성이 높아 팀 협업에 적합한 MSW를 권장합니다.
테스트 실행이 느릴 때는 어떻게 최적화하나요?
다음 네 가지 방법이 실용적입니다.

1. 테스트 병렬 실행(가장 효과적)
```json
{ "scripts": { "test": "jest --maxWorkers=4" } }
```

2. 변경된 파일만 테스트
```bash
npm test -- --onlyChanged
```

3. 코드 커버리지 비활성화(디버깅 시)
```json
{ "scripts": { "test:fast": "jest --no-coverage" } }
```

4. 느린 테스트 건너뛰기
```typescript
describe.skip('Slow E2E Tests', () => {
// 이 테스트들은 건너뜁니다
})
```

개발 중에는 --onlyChanged와 --no-coverage를 사용하고 CI/CD에서는 전체 테스트를 실행하는 방식을 권장합니다.
경로 별칭 @/를 설정했는데도 테스트 오류가 발생하면 어떻게 하나요?
jest.config.ts와 tsconfig.json의 경로 설정이 일치하는지 확인합니다.

tsconfig.json 또는 jsconfig.json:
```json
{
"compilerOptions": {
"baseUrl": "./",
"paths": {
"@/components/*": ["components/*"],
"@/lib/*": ["lib/*"]
}
}
}
```

jest.config.ts:
```typescript
moduleNameMapper: {
'^@/components/(.*)$': '<rootDir>/components/$1',
'^@/lib/(.*)$': '<rootDir>/lib/$1',
}
```

주의할 점:
1. tsconfig에서는 상대 경로를 사용하고 <rootDir>를 붙이지 않습니다.
2. jest.config에서는 <rootDir>를 포함한 절대 경로를 사용합니다.
3. 와일드카드 표기(.*)가 일치해야 합니다.

수정 후 테스트를 다시 시작하면 경로 별칭을 정상적으로 인식합니다.

9분 읽기 · 게시일: 2026년 1월 7일 · 수정일: 2026년 9월 4일

댓글

GitHub로 로그인하여 댓글을 남기세요

Easton BlogEaston Blog