본문으로 건너뛰기
안동민 개발노트 아이콘

안동민 개발노트

본문 시작
14장 : 테스팅

단위 테스트 설정

Jest와 Testing Library를 Next.js에 설정하고 함수·컴포넌트의 출력과 의존성 mock을 빠르게 검증합니다.

품질 좋은 소프트웨어를 만들기 위해 테스팅(Testing)은 필수 과정입니다.

특히 함수와 컴포넌트 같은 작은 단위를 검증하는 단위 테스트(Unit Testing)는 버그 조기 발견과 리팩터링 안전성 확보에 큰 도움을 줍니다.

Next.js 프로젝트에서는 주로 Jest를 사용합니다.

이 절에서는 단위 테스트의 중요성, Jest를 Next.js 프로젝트에 설정하는 방법, 기본적인 Jest 사용법, 그리고 Next.js 환경에서 컴포넌트 테스트를 위한 @testing-library/react와의 연동에 대해 상세히 알아보겠습니다.

먼저 단위 테스트가 어떤 코드 경계를 검증하고 어떤 안전망을 만드는지 정리합니다.


Next.js 단위 테스트의 중요성

  • 버그 조기 발견: 개발 초기 단계에서 코드의 작은 부분에 숨어 있는 버그를 신속하게 식별하고 수정할 수 있습니다. 이는 통합 테스트나 실제 배포 후에 버그를 발견하는 것보다 훨씬 비용 효율적입니다.
  • 코드 품질 향상: 테스트 가능한 코드를 작성하려면 모듈화와 재사용성이 높은 코드를 설계해야 합니다. 이는 자연스럽게 코드의 응집도를 높이고 결합도를 낮추어 전반적인 코드 품질을 향상시킵니다.
  • 리팩터링의 안전망: 기존 코드를 개선하거나 변경할 때, 단위 테스트가 있다면 변경 사항이 기존 기능에 영향을 미치지 않는지 빠르게 확인할 수 있습니다.
  • 변경 영향 확인: 테스트를 통해 변경 사항이 기존 동작에 미치는 영향을 빠르게 확인할 수 있습니다.
  • 동작 문서화: 단위 테스트는 코드의 입력, 출력, 예외 조건을 실행 가능한 예시로 남깁니다.

Jest란?

Jest는 Facebook에서 개발한 JavaScript 테스팅 프레임워크로, React 애플리케이션 테스트에 특히 인기가 많습니다.

Jest는 Zero-configuration을 지향하며, 테스트 러너, 어설션 라이브러리, Mocking 도구 등을 모두 내장하고 있어 별도의 추가 설정 없이 바로 테스트를 시작할 수 있다는 장점이 있습니다.


Next.js 프로젝트에 Jest 설정하기

Next.js는 Jest를 위한 통합된 설정을 제공하므로, 설정 과정이 비교적 간단합니다.

Jest 테스트 패키지 설치

먼저 Jest와 React 컴포넌트 테스트에 필요한 @testing-library/react 및 관련 패키지들을 설치합니다.

npm install --save-dev jest @types/jest @testing-library/react @testing-library/dom @testing-library/jest-dom jest-environment-jsdom
# 또는
yarn add --dev jest @types/jest @testing-library/react @testing-library/dom @testing-library/jest-dom jest-environment-jsdom
  • jest: Jest 테스팅 프레임워크 본체.
  • @types/jest: TypeScript가 describe, test, expect, jest 전역 타입을 인식하게 합니다.
  • @testing-library/react: React 컴포넌트를 테스트하기 위한 유틸리티. 사용자가 컴포넌트와 상호작용하는 방식에 가깝게 테스트를 작성할 수 있도록 돕습니다.
  • @testing-library/dom: Testing Library의 DOM 쿼리와 React 패키지의 직접 peer 의존성을 제공합니다.
  • @testing-library/jest-dom: Jest에 DOM 관련 매처(matcher)를 추가하여 DOM 요소를 더 쉽게 테스트할 수 있게 합니다.
  • jest-environment-jsdom: Jest가 브라우저 환경을 시뮬레이션할 수 있도록 하는 환경. React 컴포넌트 테스트에 필수적입니다.

Jest 설정 파일 생성

프로젝트 루트에 jest.config.js 파일을 생성합니다.

Next.js는 Jest 설정에 대한 가이드를 제공하며, 이를 기반으로 설정할 수 있습니다.

jest.config.js
package.json
...
jest.config.js 예시
jest.config.js
const nextJest = require('next/jest');

// Next.js Jest 설정 유틸리티를 초기화합니다.
const createJestConfig = nextJest({
  // Next.js 애플리케이션의 루트 디렉토리를 제공합니다.
  dir: './',
});

// Jest에 추가적인 커스텀 설정을 추가할 수 있습니다.
const customJestConfig = {
  // 테스트 환경을 jsdom으로 설정하여 DOM 환경을 시뮬레이션합니다.
  testEnvironment: 'jsdom',
  // 테스트 파일의 패턴을 정의합니다. (e.g., .test.js, .spec.js)
  testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|js?|tsx?|ts?)$',
  // 모듈 해석 방식을 설정합니다.
  moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
  // 테스트 전 전역적으로 실행될 setup 파일을 지정합니다.
  setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
  // 특정 경로를 모듈로 인식하도록 합니다. Next.js의 path alias와 유사.
  moduleNameMapper: {
    '^@/(.*)$': '<rootDir>/src/$1',
  },
  // 테스트 커버리지 보고서를 생성할지 여부를 설정합니다.
  collectCoverage: false,
  // 테스트 커버리지 보고서를 생성할 파일 패턴을 지정합니다.
  collectCoverageFrom: [
    'src/components/**/*.{js,jsx,ts,tsx}',
    'src/app/**/*.{js,jsx,ts,tsx}',
    '!/node_modules/',
    '!/vendor/',
  ],
};

// Next.js Jest 설정과 커스텀 설정을 병합하여 내보냅니다.
module.exports = createJestConfig(customJestConfig);

Jest Setup 파일 생성

jest.config.js에서 지정한 setupFilesAfterEnv 경로에 jest.setup.ts 파일을 생성합니다.

이 파일은 각 테스트가 실행되기 전에 전역적으로 실행될 코드를 포함합니다.

주로 @testing-library/jest-dom을 임포트하여 DOM 매처를 확장하는 데 사용됩니다.

jest.setup.ts
...
jest.setup.ts 예시
jest.setup.ts
import '@testing-library/jest-dom';
// 여기서는 @testing-library/jest-dom의 확장된 매처를 임포트합니다.
// 예: toBeInTheDocument(), toHaveClass() 등을 사용할 수 있게 됩니다.

기본 Next.js tsconfig.jsoninclude**/*.ts를 포함하면 이 파일의 matcher 타입도 함께 로드됩니다.

includesrc로 좁혀 둔 프로젝트라면 jest.setup.ts를 명시적으로 추가합니다.

package.json에 테스트 스크립트 추가

package.json 파일의 scripts 섹션에 Jest 실행 명령어를 추가합니다.

package.json
{
  "name": "your-nextjs-app",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "eslint .",
    "test": "jest",
    "test:watch": "jest --watch"
  }
}

이제 npm test 또는 yarn test 명령어로 Jest를 실행할 수 있습니다.


기본적인 단위 테스트 작성하기

함수와 컴포넌트 단위 테스트의 예시를 살펴보겠습니다.

순수 함수 테스트

가장 간단한 형태의 단위 테스트입니다.

입력에 대해 항상 동일한 출력을 반환하는 순수 함수를 테스트합니다.

lib/utils.ts
export function sum(a: number, b: number): number {
  return a + b;
}

export function capitalize(str: string): string {
  if (!str) return '';
  return str.charAt(0).toUpperCase() + str.slice(1);
}
__tests__/utils.test.ts
import { sum, capitalize } from '../lib/utils'; // 테스트할 함수 임포트

describe('Utils 함수 테스트', () => {
  test('sum 함수는 두 숫자의 합을 반환해야 합니다.', () => {
    expect(sum(1, 2)).toBe(3);
    expect(sum(-1, 1)).toBe(0);
    expect(sum(0, 0)).toBe(0);
  });

  test('capitalize 함수는 문자열의 첫 글자를 대문자로 만들어야 합니다.', () => {
    expect(capitalize('hello')).toBe('Hello');
    expect(capitalize('world')).toBe('World');
    expect(capitalize('')).toBe('');
    expect(capitalize('a')).toBe('A');
  });
});

React 컴포넌트 테스트

@testing-library/react는 컴포넌트의 내부 구현보다는 사용자 관점에서 컴포넌트가 어떻게 동작하는지를 테스트하도록 권장합니다.

components/Button.tsx
"use client"; // 클라이언트 컴포넌트

import React from 'react';

interface ButtonProps {
  children: React.ReactNode;
  onClick?: () => void;
  disabled?: boolean;
}

export default function Button({ children, onClick, disabled = false }: ButtonProps) {
  return (
    <button
      onClick={onClick}
      disabled={disabled}
      style={{
        padding: '10px 20px',
        backgroundColor: disabled ? '#ccc' : '#007bff',
        color: 'white',
        border: 'none',
        borderRadius: '5px',
        cursor: disabled ? 'not-allowed' : 'pointer',
      }}
    >
      {children}
    </button>
  );
}
__tests__/Button.test.tsx
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react'; // 필요한 유틸리티 임포트
import Button from '../components/Button'; // 테스트할 컴포넌트 임포트

describe('Button 컴포넌트', () => {
  test('버튼 텍스트가 올바르게 렌더링되어야 합니다.', () => {
    render(<Button>클릭하세요</Button>); // 컴포넌트 렌더링
    // screen.getByText를 사용하여 화면에 "클릭하세요" 텍스트가 있는지 확인
    expect(screen.getByText('클릭하세요')).toBeInTheDocument();
  });

  test('버튼 클릭 시 onClick 핸들러가 호출되어야 합니다.', () => {
    const handleClick = jest.fn(); // Jest의 Mock 함수 생성
    render(<Button onClick={handleClick}>Submit</Button>);

    // 화면에서 "Submit" 텍스트를 가진 버튼을 찾고 클릭 이벤트를 발생시킵니다.
    fireEvent.click(screen.getByText('Submit'));

    // handleClick Mock 함수가 호출되었는지 확인합니다.
    expect(handleClick).toHaveBeenCalledTimes(1); // 1번 호출되었는지
  });

  test('disabled prop이 true일 때 버튼이 비활성화되어야 합니다.', () => {
    render(<Button disabled>비활성화 버튼</Button>);

    // "비활성화 버튼" 텍스트를 가진 버튼이 disabled 상태인지 확인
    expect(screen.getByText('비활성화 버튼')).toBeDisabled();
  });

  test('disabled prop이 false일 때 버튼이 활성화되어야 합니다.', () => {
    render(<Button disabled={false}>활성화 버튼</Button>);

    // "활성화 버튼" 텍스트를 가진 버튼이 enabled 상태인지 확인
    expect(screen.getByText('활성화 버튼')).not.toBeDisabled();
  });
});

Mocking (모킹)

다음 다이어그램은 단위 테스트에서 실제 코드와 대역으로 바꿀 외부 의존성을 나누는 기준입니다.

실제 서비스에서 API 호출, 데이터베이스 접근 등 외부 의존성이 있는 코드를 테스트할 때는 모킹(Mocking) 기법을 사용합니다.

Jest는 모킹 기능을 제공하여 외부 의존성 없이 단위 테스트를 독립적으로 실행할 수 있도록 돕습니다.

아래 테스트 코드는 외부 API 대신 로컬 Mock 서버(http://localhost:4000)를 사용한다고 가정합니다.

lib/api.ts (실제 API 호출)
export async function fetchUserData(userId: string) {
  const res = await fetch(`http://localhost:4000/users/${userId}`);
  if (!res.ok) {
    throw new Error('사용자 데이터를 가져오지 못했습니다.');
  }
  return res.json();
}
__tests__/api.test.ts
/** @jest-environment node */

import { fetchUserData } from '../lib/api';

// fetch 함수를 Mocking
// Jest는 전역 fetch를 자동으로 Mocking 해주지는 않습니다.
// 파일 단위 node 환경에서 Node.js 20의 전역 fetch를 Mocking하는 예시입니다.
// 실제 Next.js 앱에서는 Jest 설정에서 'whatwg-fetch' 또는 'node-fetch'를 Mocking할 수 있습니다.
// 혹은 MSW (Mock Service Worker) 같은 라이브러리를 사용하는 것이 더 강력합니다.

describe('API 함수 테스트', () => {
  // 테스트 시작 전에 fetch를 Mocking합니다.
  beforeEach(() => {
    jest.spyOn(global, 'fetch').mockResolvedValue(
      {
        ok: true,
        json: () => Promise.resolve({ id: '123', name: 'Test User' }),
      } as Response,
    );
  });

  // 각 테스트 후 Mocking을 초기화합니다.
  afterEach(() => {
    jest.restoreAllMocks();
  });

  test('fetchUserData는 사용자 데이터를 성공적으로 가져와야 합니다.', async () => {
    const user = await fetchUserData('123');
    expect(user).toEqual({ id: '123', name: 'Test User' });
    expect(global.fetch).toHaveBeenCalledTimes(1);
    expect(global.fetch).toHaveBeenCalledWith('http://localhost:4000/users/123');
  });

  test('fetchUserData는 오류 발생 시 예외를 던져야 합니다.', async () => {
    // 오류 상황을 시뮬레이션하기 위해 fetch Mocking을 재정의
    jest.mocked(global.fetch).mockResolvedValueOnce(
      {
        ok: false,
        status: 404,
        json: () => Promise.resolve({ message: 'Not Found' }),
      } as Response,
    );

    await expect(fetchUserData('nonexistent')).rejects.toThrow('사용자 데이터를 가져오지 못했습니다.');
  });
});

다음 다이어그램은 단위 테스트에서 실제 구현과 모킹 경계를 나누는 기준입니다.


Jest 사용 시 고려사항 및 팁

  • 테스트 파일 위치: 일반적으로 src 디렉토리와 동일한 레벨에 __tests__ 디렉토리를 만들거나, 테스트할 파일과 같은 디렉토리에 .test.ts 또는 .spec.ts 접미사를 붙여 파일을 생성합니다.
  • 테스트 범위: 모든 코드를 100% 테스트할 필요는 없습니다. 비즈니스 로직의 핵심 부분, 복잡한 로직, 버그가 자주 발생하는 부분 등을 우선적으로 테스트합니다.
  • 테스트 코드와 제품 코드 분리: 테스트 코드는 제품 코드와 함께 배포되지 않도록 devDependencies로 관리합니다.
  • 간결하고 명확한 테스트: 각 테스트는 하나의 특정 시나리오만 검증하도록 간결하게 작성합니다. 테스트의 목적과 예상 결과를 명확하게 나타내도록 테스트 이름을 짓습니다.
  • TDD (Test-Driven Development): 테스트 주도 개발은 코드를 작성하기 전에 테스트를 먼저 작성하는 방식입니다. 이는 코드 설계와 품질을 향상시키는 데 도움이 될 수 있습니다.
  • 스냅샷 테스트 (Snapshot Testing): UI 컴포넌트의 변경 사항을 추적하는 데 유용합니다. 컴포넌트가 렌더링된 결과를 스냅샷으로 저장하고, 이후 테스트 실행 시 저장된 스냅샷과 현재 렌더링 결과를 비교합니다. (남용은 피하는 것이 좋습니다.)

다음 다이어그램은 Next.js 프로젝트에서 Jest 설정 파일들이 어떤 역할로 연결되는지 보여줍니다.

다음 다이어그램은 단위 테스트를 추가할 때 우선순위를 정하는 기준입니다.

Jest를 Next.js 프로젝트에 통합하고 단위 테스트를 작성하는 것은 애플리케이션의 신뢰성과 유지보수성을 크게 향상시키는 중요한 단계입니다.

효과적인 테스트 전략을 통해 더 견고한 웹 애플리케이션을 구축할 수 있습니다.

아래 다이어그램은 단위 테스트 설정에서 테스트 경계, 준비 데이터, 실패 재현 위치를 정리합니다.