console.log()가 테스트 코드 아닌가요(3)

본 글은 코드 위주로 작성되어 있어 데스크탑 환경을 권장드립니다

successful test

요즘 신입 개발자들의 필수 덕목 중 하나인 테스팅에 대해 지금까지 이론은 알아보았다. 이제는 직접 내 프로젝트에 적용해보자.

7. 프로젝트에 Jest 설치하기

Next.js의 Jest 설치 공식문서를 참고해서 설치해보자.

프로젝트에 맞는 패키지 매니저를 선택해서 devDependencies로 설치하면 된다.

npm install -D jest jest-environment-jsdom @testing-library/react @testing-library/dom @testing-library/jest-dom ts-node
# or
yarn add -D jest jest-environment-jsdom @testing-library/react @testing-library/dom @testing-library/jest-dom ts-node
# or
pnpm install -D jest jest-environment-jsdom @testing-library/react @testing-library/dom @testing-library/jest-dom ts-node

그리고 jest를 init하여 프로젝트에 기본 파일을 생성한다.

npm init jest@latest
# or
yarn create jest@latest
# or
pnpm create jest@latest

init jest

그럼 위처럼 Jest에 관한 추가적인 설정할 수 있도록 도와준다. 각 항목을 하나씩 살펴보자.



✅ Would you like to use Jest when running "test" script in "package.json"?

  • yes / no
  • yes를 선택하면 package.json"scripts""test: jest"를 추가한다
  • npm test를 실행하면 Jest가 자동으로 실행된다

✅ Would you like to use Typescript for the configuration file?

  • yes / no
  • yes를 선택하면 jest.config.js 대신 jest.config.ts로 생성한다
  • TypeScript 프로젝트에서 Jest 설정을 TypeScript 문법으로 작성할 수 있다

✅ Choose the test environment that will be used for testing

  • node / jsdom (browser-like)
  • node는 백엔드를 위한 테스팅, jsdom은 이전에도 설명했듯이 브라우저 환경에서 테스팅을 도와준다
  • 프론트엔드 테스팅에서는 jsdom이 필수적이다
  • jest.config.tstestEnvironment: "jsdom"가 추가된다

✅ Do you want Jest to add coverage reports?

  • yes / no
  • 코드 커버리지 보고서의 생성 유무를 선택할 수 있다
  • jest.config.tscollectCoverage: true를 추가한다

✅ Which provider should be used to instrument code for coverage?

  • v8 / babel
  • v8은 Node.js의 JavaScript 엔진이 제공하는 고속 커버리지 도구, Babel 보다 성능이 더 뛰어나다
  • Next.js 환경에서는 v8을 사용하는 것이 권장된다
  • jest.config.tscoverageProvider: 'v8'를 추가한다

✅ Automatically clear mock calls, instances, contexts and results before every test?

  • yes / no
  • 각 테스트 실행 전에 mock 데이터를 초기화해서 독립적인 테스팅을 보장한다
  • 이전 테스트에서 남은 mock 데이터가 다음 테스트에 영향을 주지 않도록 방지한다
  • jest.config.tsclearMocks: true가 추가된다

터미널에서 init이 완료되었으면 루트 디렉토리에 jest.config.ts가 생성되는 것을 확인할 수 있다.

8. next/jest로 환경 설정하기

jest.config.ts를 생성했음에도 불구하고 Jest가 Next.js가 함께 작동할 수 있도록 구성 옵션을 제공하는 next/jest를 사용해야 한다.

next/jest는 내부적으로 다음 작업들을 자동으로 구성한다.

  • Next.js Compiler를 사용하여 transform 설정
  • 스타일 시트(.css, .module.css 및 해당 scss 변형), 이미지 가져오기 및 next/font를 자동으로 모킹
  • .env 및 모든 변경을 process.env로 로드
  • 테스트 해결 및 변환에서 node_modules 무시
  • 테스트 해결에서 .next 무시
  • SWC 변환을 활성화하는 플래그에 대해 next.config.js 로드

next/jest를 사용하기 위해서는 공식문서대로 jest.config.ts 파일을 수정해줘야한다. init을 할 때 설정했던 옵션들까지 날라가지 않도록 사용자 정의 구성을 잘 남겨놓는다. (공식 문서 참고)

import nextJest from 'next/jest.js';
import type { Config } from 'jest';

const createJestConfig = nextJest({
  // next.config.js 및 .env 파일을 테스트 환경에 로드하기 위해 Next.js 앱 경로를 제공합니다.
  dir: './',
});

// Jest에 전달할 사용자 정의 구성을 추가합니다.
const config: Config = {
  coverageProvider: 'v8',
  testEnvironment: 'jsdom',
  clearMocks: true,
  collectCoverage: true,
  coverageDirectory: 'coverage',
};

// next/jest가 Next.js 구성을 로드할 수 있도록 createJestConfig가 이 방식으로 내보내집니다.
export default createJestConfig(config);

(선택 사항)


  1. 프로젝트에 Path Alias가 설정되어 있다면 jest에도 일치시켜주어야 한다.

TypeScript는 tsconfig.jsonpaths를 컴파일할 때 적용하지만, Jest는 TypeScript의 트랜스파일 없이 실행된다. 따라서 Jest가 파일을 찾을 수 있도록 별도의 경로 매핑이 필요하다.

만약 tsconfig.json에 다음과 같이 Path Alias가 설정되어 있다면, jest.config.ts에 다음과 같이 설정을 추가해준다.

{
  "compilerOptions": {
    "module": "esnext",
    "moduleResolution": "bundler",
    "baseUrl": "./",
    "paths": {
      "@/components/*": ["components/*"]
    }
  }
}
moduleNameMapper: {
  // ...
  '^@/components/(.*)$': '<rootDir>/components/$1',
}

  1. toBeInTheDocument()와 같은 커스텀 매처를 사용하기 위해서는 @testing-library/jest-dom을 구성 환경에 추가해줘야 한다.

jest.config.tssetupFilesAfterEnv 옵션을 추가하여 각 테스트 실행 전에 jest.setup.ts가 실행되도록 설정한다.

jest.setup.ts을 루트 디렉토리에 생성한 후 다음의 코드를 추가한 후, jest.config.ts에도 옵션을 추가해준다.

import '@testing-library/jest-dom';
setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'];

최종적으로 생성된 jest.config.ts는 다음과 같다.

import nextJest from 'next/jest.js';
import type { Config } from 'jest';

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

const config: Config = {
  coverageProvider: 'v8',
  testEnvironment: 'jsdom',
  clearMocks: true,
  collectCoverage: true,
  coverageDirectory: 'coverage',
  setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
  moduleNameMapper: {
    '@/(.*)$': '<rootDir>/$1',
  },
};

export default createJestConfig(config);




취향에 맞는 설정을 마치고 jest.config.ts 생성을 완료했다면, 아직 한 발 남았다...

package.json"scripts""test:watch": "jest --watch"를 추가해준다. 이는 파일이 변경될 때 테스트를 다시 실행하게 해주는 옵션이다. 만약 파일이 변경될 때 전체 테스트를 진행하고 싶다면 --watchAll을 대신 사용하면 된다.

"scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "next lint",
    "test": "jest",
    "test:watch": "jest --watch"
  },

마지막으로 루트 디렉토리에 __tests__ 폴더를 만들어주면 정말 테스팅 할 준비가 끝났다.

9. 프로젝트에 테스트 적용하기

9.1 트러블 슈팅

9.1.1 @types/jest 설치

트러블이라기엔 애매하지만, 공식문서대로만 라이브러리를 설치하면 테스트코드를 작성했을 때 describe, test, expect 등 기본적인 문법을 찾을 수 없다는 에러가 발생한다.

이를 해결하기 위해서는 @types/jest 라이브러리를 추가로 설치해야하는데, 내 생각에는 공식문서의 기본적인 설치 라이브러리에 해당 라이브러리가 포함되어야 하는게 맞는 것 같아서 이슈를 작성했다. 관련 이슈 링크

9.1.2 transformIgnorePatterns

지금까지의 모든 테스팅 환경을 설정했음에도 불구하고, 프로젝트에 테스트 코드를 작성하고 터미널에 npm test를 입력하면 다음과 같은 에러가 발생한다.

import error message

에러의 내용을 요약하면 다음과 같다.

  • 내 프로젝트는 lucide-react 라이브러리를 사용하고 있는데, 이 라이브러리가 ES 모듈 형식으로 제공되고 있어, Jest가 정상적으로 import 해오지 못하고 있다
  • Jest는 코드를 파싱할 때, 비표준 자바스크립트 문법(ECMAScript 모듈, TypeScript, JSX 등)을 변환하지 못한다
  • Babel과 같은 트랜스컴파일러를 이용해서 코드를 올바르게 변환해라
  • transform 또는 transformIgnorePatterns 옵션을 Jest 설정에 추가해서 변환 대상에 필요한 파일들을 포함시켜라

이 에러가 말하고자하는 내용들은 대부분 next/jest를 사용함으로써 해결할 수 있는 이슈에 속한다. 해당 내용과 관련된 공식문서의 이슈를 확인해보았으나 이전에도 똑같은 형태로 발생했던 문제였고, 히스토리를 확인해보니 이미 이전 버전에서 해결되었다고 하는데 여전히 발생하는 것으로 보아 next/jest에 어떠한 설정 문제가 있는 것으로 보인다.

또한, StackOverflow에서 비슷한 문제를 겪고 있는 개발자들이 많음을 확인할 수 있었다. 나는 추가적인 라이브러리나 루트 디렉토리에 새로운 파일을 생성하는 것은 거부감이 들어서 transformIgnorePatterns 옵션을 적용하기로 했고, 최종적으로 package.json에 test 스크립트를 수정해서 문제를 해결할 수 있었다.

// ...
"scripts": {
        "dev": "next dev",
        "build": "next build",
        "start": "next start",
        "lint": "next lint",
        "test": "jest --transformIgnorePatterns 'node_modules/(?!lucide-react)/'",
        "test:watch": "jest --watch"
    },
// ...

이 설절은 정규표현식 node_modules/(?!lucide-react)/를 사용해 lucide-react 모듈만은 예외로 두어 Babel 등의 트랜스컴파일러가 해당 파일을 변환하도록 하였다. 이를 통해 Jest가 코드를 올바르게 파싱할 수 있게 되어 테스트가 정상적으로 실행된다.

9.2 인터랙션 컴포넌트 테스트

테스팅하고자하는 프로젝트는 shadcn/ui를 기반으로 구현되었다. 테스트 코드를 통해 shadcn/uiHover Card를 사용한 인터랙션 컴포넌트가 사용자가 hover 했을 때 정상적으로 렌더링되는지 확인해보자.

1. 트리거 요소 선택 및 인터랙션 시뮬레이션

  • beforeEach를 사용해 각 테스트 전에 HoverCardTrigger 안에 임시의 버튼 요소를 넣고, 그 버튼을 trigger 변수에 할당한다
  • userEvent.hover를 사용해서 해당 버튼에 hover 이벤트를 발생시킨다

2. 비동기 렌더링 처리

  • InstructionsDialog 컴포넌트는 사용법을 알려주는 정적 컴포넌트로 hover 이벤트 발생 후 비동기적으로 렌더링된다
  • 따라서 waitFor을 사용해서 컴포넌트가 렌더링될 때까지 기다린 뒤, 정상적으로 요소들이 의도한데로 렌더링되는지 테스트한다

3. 요소 검증

  • 제목, 단계별 텍스트, 설명 등이 나타나는지 확인한다
  • 텍스트가 <b>와 같은 여러 요소로 쪼개지거나, 대소문자의 구분등에 의해 테스트가 실패할 수 있으니 이를 고려하여 { exact: false} 옵션이나 정규표현식을 사용하여 유연하게 매칭한다

작성한 테스트 코드와 테스트 결과는 다음과 같다.

import { HoverCard, HoverCardTrigger } from '@/components/ui/hover-card';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import InstructionsDialog from '../InstructionDialog';

describe('설명서 다이얼로그 컴포넌트 테스트', () => {
  let trigger;

  beforeEach(async () => {
    render(
      <HoverCard>
        <HoverCardTrigger>
          <button type="button">Hover me</button>
        </HoverCardTrigger>
        <InstructionsDialog />
      </HoverCard>
    );

    // 트리거 요소를 찾고 hover 이벤트를 발생시키고
    trigger = screen.getByRole('button', { name: /hover me/i });
    await userEvent.hover(trigger);

    // hover 애니메이션이나 지연 효과로 인해 콘텐츠가 나타날 때까지 기다림
    await waitFor(() =>
      expect(screen.getByRole('heading', { level: 1 })).toBeVisible()
    );
  });

  test('제목 테스트', () => {
    expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent(
      '국내 도착가 계산기 사용법'
    );
  });

  test('단계별 렌더링 테스트', async () => {
    const steps = [
      '1. 통화 선택',
      '2. SKU 입력',
      '3. 컨디션 설정',
      '4. 가격 입력',
      '5. 카테고리 선택',
      '6. 관세 정보',
      '7. 부가세',
    ];

    for (const step of steps) {
      await waitFor(() =>
        expect(screen.getByText(step, { exact: false })).toBeVisible()
      );
    }
  });

  test('설명 렌더링 테스트', async () => {
    await waitFor(() => {
      expect(
        screen.getByText('라인시트에 맞는 기준 통화', { exact: false })
      ).toBeVisible();
      expect(
        screen.getByText('계산하려는 상품의', { exact: false })
      ).toBeVisible();
      expect(
        screen.getByText('부가세는 자동으로', { exact: false })
      ).toBeVisible();
    });
  });
});

인터랙션 컴포넌트 테스트 결과

9.3 서버 액션 테스트

이번 테스트는 ISR(Incremental Static Regeneration)을 구현한 서버 액션 함수가 정상적으로 작동하는지 테스트 코드를 통해 검증해보자.

테스트 대상 함수는 외부 API에서 환율 데이터를 받아오고 송금 스프레드 적용 및 환율 변환 결과를 반환한다. 대상 함수의 코드는 다음과 같다.

'use server';

export async function fetchExchangeRates() {
  try {
    const response = await fetch(
      'https://latest.currency-api.pages.dev/v1/currencies/eur.json',
      {
        next: { revalidate: 1800 }, // 30분 마다 갱신
      }
    );

    if (!response.ok) {
      throw new Error('Failed to fetch exchange rates');
    }

    const data = (await response.json()) as ExchangeRateByEuro;

    const krwRate = data.eur.krw; // 1유로당 KRW
    const usdRate = data.eur.usd; // 1유로당 USD

    if (!krwRate || !usdRate) {
      throw new Error('Missing KRW or USD rate in the response');
    }

    // 1% 송금 스프레드 가산
    const krwWithSpread = krwRate * 1.01;

    // Calculate 1달러당 KRW
    const usdToKrwRate = krwWithSpread / usdRate;

    return {
      date: data.date,
      rates: {
        eurToKrw: parseFloat(krwWithSpread.toFixed(2)), // 1유로당 한화 금액 (송금 수수료 포함)
        usdToKrw: parseFloat(usdToKrwRate.toFixed(2)), // 1달러당 한화 금액
      },
    };
  } catch (error) {
    console.error('Exchange rate fetch error:', error);
    return {
      date: '',
      rates: {
        eurToKrw: null,
        usdToKrw: null,
      },
    };
  }
}

테스트에서는 fetch를 모킹해서 다양한 시나리오를 검증한다.

1. 독립적인 테스트 유지

  • 각 테스트는 다른 테스트의 실행에 영향을 받지 않아야한다
  • beforeEachafterEach를 사용해 테스트 환경을 초기화한다

2. 시나리오 별 테스트

  • fetch가 정상 응답을 반환할 때, 올바른 계산 결과(1% 송금 스프레드 적용 후의 환율)을 반환하는지 확인한다
  • fetchok가 false인 경우, 함수가 에러 처리 로직을 수행하고 null을 반환하는지 확인한다
  • 응답 데이터의 필수 값이 누락된 경우에도 함수가 에러 처리 후 null 값을 반환하는지 확인한다
  • fetch 함수의 호출 자체가 실패하는 상황에도 에러를 캐치하고 적절한 결과를 반환하는지 테스트한다

서버 액션의 테스트 코드와 테스트 결과는 다음과 같다.

import { fetchExchangeRates } from '../fetchExchangeRates';

describe('fetchExchangeRates 함수 테스트', () => {
  beforeEach(() => {
    // 전역 fetch를 모킹
    global.fetch = jest.fn();
  });

  afterEach(() => {
    jest.resetAllMocks();
  });

  test('정상 응답일 때 올바른 환율 데이터를 반환해야 함', async () => {
    const mockData = {
      date: '2025-02-24',
      eur: {
        krw: 1350, // 1유로당 KRW
        usd: 1.1, // 1유로당 USD
      },
    };

    // 1% 송금 스프레드 적용: 1350 * 1.01 = 1363.5
    // USD to KRW: 1363.5 / 1.1 ≒ 1239.55
    const expectedEurToKrw = parseFloat((1350 * 1.01).toFixed(2)); // 1363.50
    const expectedUsdToKrw = parseFloat(((1350 * 1.01) / 1.1).toFixed(2)); // 1239.55

    (global.fetch as jest.Mock).mockResolvedValue({
      ok: true,
      json: async () => mockData,
    });

    const result = await fetchExchangeRates();
    expect(result.date).toBe(mockData.date);
    expect(result.rates.eurToKrw).toBe(expectedEurToKrw);
    expect(result.rates.usdToKrw).toBe(expectedUsdToKrw);
  });

  test('응답 ok가 false이면 null 환율 데이터를 반환해야 함', async () => {
    (global.fetch as jest.Mock).mockResolvedValue({
      ok: false,
    });

    const result = await fetchExchangeRates();
    expect(result.date).toBe('');
    expect(result.rates.eurToKrw).toBeNull();
    expect(result.rates.usdToKrw).toBeNull();
  });

  test('필요한 데이터(krw 또는 usd)가 누락되면 null 환율 데이터를 반환해야 함', async () => {
    const mockData = {
      date: '2025-02-26',
      eur: {
        krw: null, // 누락된 경우
        usd: 1.1,
      },
    };

    (global.fetch as jest.Mock).mockResolvedValue({
      ok: true,
      json: async () => mockData,
    });

    const result = await fetchExchangeRates();
    expect(result.date).toBe('');
    expect(result.rates.eurToKrw).toBeNull();
    expect(result.rates.usdToKrw).toBeNull();
  });

  test('fetch 호출 자체가 실패하면 null 환율 데이터를 반환해야 함', async () => {
    (global.fetch as jest.Mock).mockRejectedValue(new Error('Network Error'));

    const result = await fetchExchangeRates();
    expect(result.date).toBe('');
    expect(result.rates.eurToKrw).toBeNull();
    expect(result.rates.usdToKrw).toBeNull();
  });
});

서버 액션 테스트 결과

Outro

지금까지 3편의 글을 통해 React 환경에서 Jest를 이용한 테스팅 기초부터 Next.js 프로젝트에서 적용하는 방법까지 살펴보았다.

최근에는 Vite 기반의 React 개발이 활발해지면서 Vitest도 주목받고 있다. 특히 React v19 이후 CRA가 deprecated되어, 향후 Vite의 점유율이 더 높아질 것으로 전망된다.

이번 글에서는 Jest를 중심으로 다루었지만, Next.js 환경의 프로젝트에 실제로 적용해보며 테스트 환경 설정에 많은 시간이 소요되는 점이 불편했다. Vite와 Vitest를 함께 사용하면 테스트 설정이 간소화되고 빠른 테스팅을 할 수 있다고 하니 기회가 된다면 다음에는 Vitest를 사용해보고 싶다.

Reference