👩🏻‍💻 Develop/Test

[Next.js] Jest와 React Testing Library로 단위 테스트코드 작성하기

dev.daisy 2025. 9. 23. 12:00
문서로만 공부했던 테스트코드를 직접 작성해보며 안정성 향상, 리팩터링 안전성 보장, 개발 속도 단축이라는 성과를 직접 체감해보았습니다. 특히 테스트 코드를 작성함으로써 코드의 입력과 결과값을 명확하게 보여준다는 점에서 코드를 처음 보는 사람에게는 사용법을 문서화하는 효과가 있을 것 같다고 느꼈고, 앞으로는 E2E 테스트와 성능 테스트를 통해 개발 생산성과 코드 품질을 끊임없이 개선해 나갈 예정입니다.

이번에는 Jest와 React Testing Library를 중심으로 한 견고한 테스트 환경을 구축했는데, Next.js, React Query, Zustand 등 최신 라이브러리들과의 통합 테스트를 진행하며 상호작용 하는 모습이 새롭게 다가왔습니다. 앞으로도 지속적인 테스트 커버리지 향상과 더 나은 도구 도입을 통해 개발 생산성과 코드 품질을 끊임없이 개선해 나갈 예정입니다.

개발 중인 프로젝트에서 안정적이고 유지보수 가능한 애플리케이션을 구축하기 위해 테스트 전략을 수립하고 효과적인 테스트 환경을 구축해보았습니다. 이번 포스팅에서는 채택한 테스트 전략과 라이브러리 선택 이유, 그리고 실제 적용 사례를 상세하게 작성해보겠습니다.


1. 테스트 라이브러리 선택 가이드

1.1 Jest (테스트 프레임워크)

수많은 테스트 라이브러리 중에서 저희 프로젝트의 요구사항에 가장 적합한 테스트 프레임워크인 Jest를 사용했습니다. Jest는 Next.js 공식 지원(next/jest)을 통해 완벽하게 통합되며, 최소한의 설정만으로도 바로 사용할 수 있는 Zero Configuration이 큰 장점입니다. 또한 모킹, 스냅샷 테스트, 커버리지 리포트 등 테스트에 필요한 모든 기능이 내장되어 있고, TypeScript와 큰 생태계를 지원한다는 점이 선정한 이유가 되었습니다. jest.config.js를 통해 Next.js 설정과 함께 코드 커버리지 기준을 설정했습니다.

// jest.config.js
const nextJest = require('next/jest');

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

const customJestConfig = {
  testEnvironment: 'jsdom',
  setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
  moduleNameMapper: {
    '^@/(.*)$': '<rootDir>/src/$1',
    // 경로 별칭 설정
  },
  collectCoverageFrom: [
    'src/**/*.{ts,tsx}',
    '!src/**/*.d.ts',
    '!src/app/**/layout.tsx', // Next.js 특수 파일 제외
  ],
  coverageThreshold: {
    global: {
      branches: 50,
      functions: 50,
      lines: 50,
      statements: 50,
    },
  },
};

1.2 React Testing Library

다음으로, React Testing Library를 사용해 컴포넌트 테스트를 진행했습니다. 이 라이브러리는 컴포넌트의 내부 구현보다 사용자가 실제로 경험하는 방식으로 테스트를 작성하게 유도하는 덕분에 자연스럽게 접근성을 고려하게 되며, DOM 구조 변경에 덜 민감해 유지보수성이 뛰어납니다. 

// test-utils.tsx
import { render, RenderOptions } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';

const createTestQueryClient = () =>
  new QueryClient({
    defaultOptions: {
      queries: { retry: false, gcTime: 0 },
      mutations: { retry: false },
    },
  });

const customRender = (
  ui: ReactElement,
  { queryClient = createTestQueryClient(), ...renderOptions }: CustomRenderOptions = {},
) => {
  const Wrapper = ({ children }: { children: React.ReactNode }) => {
    return React.createElement(QueryClientProvider, { client: queryClient }, children);
  };
  return render(ui, { wrapper: Wrapper, ...renderOptions });
};

1.3 @testing-library/jest-dom 및 @testing-library/user-event

또한 @testing-library/jest-dom을 통해 toBeInTheDocument() 같은 직관적인 DOM 매처를 사용하고, @testing-library/user-event로 실제 사용자의 행동과 유사한 비동기 이벤트를 시뮬레이션하여 현실적인 테스트를 가능하게 했습니다. React Query와 같은 라이브러리를 사용하는 컴포넌트를 쉽게 테스트하기 위해 공통 Wrapper를 포함한 test-utils.tsx 파일을 구성했습니다.


2. 프로젝트별 고민과 해결책

2.1 Next.js 특화 설정

Next.js의 특수 파일(layout.tsx, page.tsx)과 컴포넌트(Image, Link, Router)는 테스트 환경에서 처리하기 위해 jest.setup.js에서 모킹(Mocking)하여 테스트 환경을 격리했습니다.

// jest.setup.js
// Next.js Image 컴포넌트 모킹
jest.mock('next/image', () => ({
  __esModule: true,
  default: (props) => <img {...props} />,
}));

// Next.js Router 모킹
jest.mock('next/navigation', () => ({
  useRouter() {
    return {
      push: jest.fn(),
      replace: jest.fn(),
      prefetch: jest.fn(),
      back: jest.fn(),
      forward: jest.fn(),
      refresh: jest.fn(),
    };
  },
  useSearchParams() {
    return new URLSearchParams();
  },
  usePathname() {
    return '/';
  },
}));

2.2 React Query 통합

  • 문제점: Tanstack Query를 사용하는 컴포넌트 테스트 시 QueryClient가 필요했습니다. 각 테스트가 독립적으로 실행되려면 개별적인 QueryClient 인스턴스가 필요했습니다.
  • 해결책: 위에서 언급된 test-utils.tsx 파일에 createTestQueryClient 함수를 만들어 테스트마다 독립적인 클라이언트를 생성하도록 했습니다.

2.3 Zustand 스토어 테스트

  • 문제점: 전역 상태 관리 라이브러리인 Zustand의 스토어 테스트 시, 각 테스트 간 상태가 오염되지 않도록 초기화 및 격리가 필요했습니다.
  • 해결책: beforeEach 훅을 사용하여 각 테스트가 시작되기 전에 스토어 상태를 초기화했습니다.
// 각 테스트 전에 스토어 상태 초기화
beforeEach(() => {
  useSSEStore.setState({
    reconnect: () => {},
    setReconnect: (fn) => useSSEStore.setState({ reconnect: fn }),
  });
});

2.4 외부 라이브러리 모킹

  • 문제점: Firebase, Socket.IO, react-spinners 등 테스트 환경에 불필요한 외부 라이브러리들을 처리해야 했습니다.
  • 해결책: jest.setup.js에서 Jest의 모킹 기능을 활용하여 불필요한 외부 모듈을 가상으로 대체했습니다.
// jest.setup.js에서 전역 모킹
// Firebase 모킹
jest.mock('@/lib/firebase', () => ({
  auth: {
    currentUser: null,
    onAuthStateChanged: jest.fn(),
    signInWithEmailAndPassword: jest.fn(),
    signOut: jest.fn(),
  },
}));

// Socket.IO 모킹
jest.mock('socket.io-client', () => ({
  io: jest.fn(() => ({
    on: jest.fn(),
    off: jest.fn(),
    emit: jest.fn(),
    connect: jest.fn(),
    disconnect: jest.fn(),
  })),
}));

3. 테스트 전략 및 원칙

AAA 패턴(Arrange, Act, Assert)을 준수하여 테스트 코드의 가독성을 높였습니다. 각 테스트는 beforeEachafterEach를 통해 독립적으로 실행되도록 격리했으며, 테스트 목적을 명확히 드러내는 의미 있는 테스트명을 사용했습니다. 코드 커버리지 목표는 전체 50% 이상, 중요 로직은 80% 이상, 유틸리티 함수는 90% 이상으로 설정하고 Next.js 특수 파일 등은 측정에서 제외했습니다.

3.1 테스트 유형별 접근

  • 컴포넌트 테스트: 기본 렌더링, 사용자 상호작용, 조건부 렌더링, 접근성 등을 테스트했습니다.
  • 유틸리티 함수 테스트: 정상 및 에러 케이스, 경계값 처리, TypeScript 타입 안전성을 검증했습니다.
  • API 함수 테스트: 실제 API 호출 없이 성공/에러 케이스를 모킹하여 테스트했습니다.
  • 스토어 테스트: 상태 관리 로직과 액션 함수의 동작을 확인하고, 각 테스트 간 상태를 격리했습니다.

3.2 테스트 작성 원칙

  • AAA 패턴: Arrange(준비), Act(실행), Assert(검증) 패턴을 준수하여 테스트 코드의 가독성을 높였습니다.
  • 테스트 격리: beforeEach, afterEach를 통해 각 테스트가 독립적으로 실행되도록 했습니다.
  • 의미 있는 테스트명 사용하기: should display loading message when data is being fetched처럼 테스트 목적을 명확히 드러내는 이름을 사용했습니다.

3.3 커버리지 전략

  • 커버리지 목표: 전체 커버리지 50% 이상, 중요 로직 80% 이상, 유틸리티 함수 90% 이상을 목표로 설정했습니다.
  • 제외 대상: Next.js 특수 파일, 타입 정의 파일 등은 커버리지 측정에서 제외했습니다.

4. 실제 적용 사례

4.1 컴포넌트 테스트 예시

// KeywordTag.test.tsx
describe('KeywordTag', () => {
  describe('기본 렌더링', () => {
    it('단일 키워드를 렌더링해야 한다', () => {
      const keywords = ['AGE_AGE_20S'];
      render(<KeywordTag keywords={keywords} />);

      const tag = screen.getByText('# 20대');
      expect(tag).toBeInTheDocument();
    });
  });

  describe('스타일링', () => {
    it('올바른 CSS 클래스가 적용되어야 한다', () => {
      const keywords = ['AGE_AGE_20S'];
      render(<KeywordTag keywords={keywords} />);

      const tag = screen.getByText('# 20대');
      expect(tag).toHaveClass('inline-block', 'rounded-full', 'border');
    });
  });
});

4.2 유틸리티 함수 테스트 예시

// format.test.ts
describe('formatKoreanDate', () => {
  describe('유효한 날짜 문자열 처리', () => {
    it('ISO 8601 형식의 날짜 문자열을 한국어 형식으로 변환해야 한다', () => {
      const isoDate = '2024-01-15T10:30:00.000Z';
      const result = formatKoreanDate(isoDate);

      expect(result).toMatch(/2024년 1월 15일/);
      expect(result).toMatch(/월요일|화요일|수요일|목요일|금요일|토요일|일요일/);
    });
  });

  describe('잘못된 입력 처리', () => {
    it('null을 처리해야 한다', () => {
      const result = formatKoreanDate(null as unknown as string);
      expect(result).toBe('Invalid Date');
    });
  });
});

5. 회고

문서로만 공부했던 테스트코드를 직접 작성해보며 안정성 향상, 리팩터링 안전성 보장, 개발 속도 단축이라는 성과를 직접 체감해보았습니다. 특히 테스트 코드를 작성함으로써 코드의 입력과 결과값을 명확하게 보여준다는 점에서 코드를 처음 보는 사람에게는 사용법을 문서화하는 효과가 있을 것 같다고 느꼈고, 앞으로는 E2E 테스트와 성능 테스트를 통해 개발 생산성과 코드 품질을 끊임없이 개선해 나갈 예정입니다.

이번에는 Jest와 React Testing Library를 중심으로 한 견고한 테스트 환경을 구축했는데, Next.js, React Query, Zustand 등 최신 라이브러리들과의 통합 테스트를 진행하며 상호작용 하는 모습이 새롭게 다가왔습니다. 앞으로도 지속적인 테스트 커버리지 향상과 더 나은 도구 도입을 통해 개발 생산성과 코드 품질을 끊임없이 개선해 나갈 예정입니다.

'👩🏻‍💻 Develop > Test' 카테고리의 다른 글

[React] 테스트코드 작성하기  (0) 2025.09.15