프로젝트를 완료한 후 Jest로 주요 기능에 대한 테스트 코드를 작성하여 테스트 커버리지를 높이고자 노력하고 있습니다. 이 과정에서 OpenWeatherMap API와 Kakao 지도 API를 이용해 날씨 데이터와 위치 데이터를 가져와 전역을 데이터를 관리해주는 Provider에 대한 테스트 코드를 작성하며 어떻게 하면 효과적인 테스트 코드를 작성할 수 있을지 고민했습니다.

 

처음에는 각 API의 쿼리스트링으로 들어가는 경도와 위도를 고정시키고 현시각에 기반하여 먼저 각 API를 이용해 데이터를 불러온 후에 날씨 Provider를 실행시켜서 처음에 불러온 데이터와 일치하는지 테스트 하는 코드를 작성해 볼려고 했습니다. 하지만 테스트 코드에 대해 알아보면서 이런 방식은 외부 의존성을 갖는 코드를 이용해 테스트를 진행하는 것이기 때문에 테스트의 독립성과 신뢰성을 떨어뜨리는 옳지 못한 방식이라는 것을 이해했습니다. 그래서 의존성을 갖는 부분을 모킹( Mocking )하여 날씨 Provider가 모킹한 데이터를 잘 받고 하위 컴포넌트로 잘 전달하는지 테스트 하는 방식으로 코드를 작성하기로 결정했습니다.

 

이러한 효과적인 테스트 코드 작성을 위한 규칙에 대한 내용은 이 글 이후에 따로 정리하여 올리겠습니다. 우선은 이 과정에서 참고한 우하한 기술블로그 링크와 Chat GPT 4o가 알려준 내용을 간단하게 적고 넘어가겠습니다.

 

코드와 함께 살펴보는 프론트엔드 단위 테스트 – Part 1. 이론 편 | 우아한형제들 기술블로그

{{item.name}} “테스트 코드 작성은 파일을 만들어서 한 글자 쓰기 전까지 엄청 귀찮다.” from 언젠가 내가 데일리 스크럼에서 한 말 개발자들에게 테스트 코드 작성은 해야 하지만 손이 잘 가지 않

techblog.woowahan.com

ChatGPT 4o의 답변

1. 단일 책임 원칙
각 테스트는 단일한 동작을 검증해야 합니다. 
즉, 하나의 테스트 케이스는 하나의 기능만 테스트해야 합니다.


2. AAA 패턴 (Arrange, Act, Assert)
테스트 코드는 일반적으로 세 단계로 구성됩니다.
- Arrange : 테스트에 필요한 데이터를 준비하고 설정합니다.
- Act: 테스트하려는 동작을 수행합니다.
- Assert: 동작의 결과를 검증합니다.


3. 독립적인 테스트
테스트는 서로 독립적이어야 하며, 하나의 테스트가 다른 테스트에 영향을 주어서는 안 됩니다. 
테스트 간에 상태가 공유되지 않도록 해야 합니다.


4. 명확하고 설명적인 테스트 이름
테스트 이름은 테스트의 목적을 명확하게 설명해야 합니다. 
이는 나중에 테스트 실패 시 문제를 쉽게 파악하는 데 도움이 됩니다.


5. 테스트 커버리지
가능한 많은 코드를 테스트하려고 노력하세요. 
중요한 로직, 경계 조건, 예외 상황 등을 포함해 다양한 시나리오를 테스트합니다.


6. 적절한 Mocking
외부 의존성을 갖는 코드(예: API 호출, 데이터베이스 접근 등)는 Mocking을 사용하여 테스트합니다. 
이를 통해 테스트의 독립성과 신뢰성을 높일 수 있습니다.


7. 빠른 피드백 루프 (Fast Feedback Loop)
테스트는 빠르게 실행되어야 합니다. 
느린 테스트는 개발자의 피드백 루프를 방해하므로, 가능한 한 테스트 속도를 최적화합니다.


8. 지속적인 통합 (Continuous Integration)
테스트는 지속적인 통합(Continuous Integration, CI) 파이프라인에서 자동으로 실행되어야 합니다. 
이를 통해 코드 변경 시마다 자동으로 테스트가 실행되어 문제를 조기에 발견할 수 있습니다.


9. 경계 값 분석 (Boundary Value Analysis)
경계 값과 같은 중요한 값들을 테스트합니다. 
이는 예기치 않은 버그를 발견하는 데 도움이 됩니다.


10. 테스트 유지보수
테스트 코드는 애플리케이션 코드와 동일한 수준의 유지보수가 필요합니다. 
코드 변경 시 테스트 코드도 함께 업데이트되어야 합니다.

 

 

모킹(Mocking) 이란?

단위 테스트를 작성할 때, 해당 코드가 의존하는 부분을 가짜(mock)으로 대체하는 기법

이러한 모킹 기법은 임의로 객체를 만들고 데이터를 넣어주는 방식으로 할 수 있습니다.

jest.spyOn(window, "fetch").mockImplementation(() => {
	return Promise.resolve({
			json: () => Promise.resolve([
					{ id: 1, title: "Blog 1", author: "Author 1" },
					{ id: 2, title: "Blog 2", author: "Author 2" },
					{ id: 3, title: "Blog 3", author: "Author 3" },
			])
	})
});

하지만 위와 같은 방식은 추후에 호출 방식이 바뀔 때마다 테스트를 하나하나 바꿔줘야 하기 때문에 좋은 방식이라고 하기는 어렵습니다. 이러한 문제를 해결할 수 있는 방법이 MSW를 이용해 API 요청을 모킹하는 방법입니다.

 

 MSW(Mock Service Worker) 란?

프론트엔드 개발에서 API 요청을 모킹할 수 있는 라이브러리입니다. Fetch API와 XHR을 사용한 네트워크 요청을 가로채 요청된 URL 및 HTTP 메소드에 맞추어 모킹된 응답을 제공할 수 있습니다. 이를 이용해 외부에 의존하는 실제 API가 아닌 모킹한 API를 통해 날씨 Provider를 테스트 해보는 코드를 작성할 예정입니다.

 

기초적인 사용법은 따로 정리하여 올리겠습니다. 지금은 프로젝트에 직접 적용해보고 어떤 방식으로 적용했는지에 대해 정리하겠습니다. 이를 통해서 외부 API를 이용하는 코드에 대한 테스트 코드를 작성하는 방법을 함께 알아봅시다.


Next.js + TypeScript 프로젝트에 Jest 테스트 환경 설정

먼저 Next.js + TypeScript에서 테스트 코드를 실행할 수 있는 환경을 설정해 줍시다. Jest와 React Testing Library를 사용하여 테스트 코드를 작성할 것입니다.

 

1. 필요한 패키지를 설치해 줍니다. (-D는 개발 환경에서만 사용하겠다는 의미입니다. --save-dev를 적어도 됩니다.)

npm i -D 
jest
jest-environment-jsdom
@testing-library/jest-dom 
@testing-library/react 
@testing-library/user-event 
@types/jest 
ts-jest
ts-node
msw

 

2. package.json scripts에 test 추가

// package.json

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

 

2. jest.config.ts와 jest.setup.ts 파일 루트 디렉토리에 추가

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

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

const config: Config = {
  coverageProvider: 'v8',
  testEnvironment: 'jest-environment-jsdom',
  setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
  transformIgnorePatterns: [
    '/node_modules/',
    '^.+\\.module\\.(css|sass|scss)$',
  ],
  // 절대 경로 사용시
  moduleNameMapper: {
    '^@/(.*)$': '<rootDir>/src/$1',
  },
}

export default createJestConfig(config)
// jest.setup.ts

import '@testing-library/react'
import '@testing-library/jest-dom'

 

3. TypeScript가 jest 코드들을 인식할 수 있도록 타입 추가

// tsconfig.json
"compilerOptions": {
    "typeRoots": ["./types", "./node_modules/@types"],
    "types": ["jest", "node"],
}

 

이렇게 설정을 끝내고 msw를 이용해 테스트 코드를 작성해 보겠습니다.

 


날씨 Provider에 대한 테스트 코드 작성

테스트 코드를 작성할 날씨 Provider 코드는 다음과 같습니다. (완전 초짜 개발자라 미흡한 부분이 많을 수 있습니다. 코드리뷰는 언제나 환영입니다.)

'use client'
import {
  Dispatch,
  ReactNode,
  SetStateAction,
  createContext,
  useEffect,
  useState,
} from 'react'

type WeatherType =
  | 'Clear'
  | 'Rain'
  | 'Thunderstorm'
  | 'Snow'
  | 'Mist'
  | 'Drizzle'
  | 'Clouds'
  | 'Fog'
  | 'Haze'
  | 'Sand'

interface WeatherProvider {
  icon: string | null
  tempNow: number | null
  tempMax: number | null
  tempMin: number | null
  address: string | null
  weather: WeatherType | null
  setTempMax: Dispatch<SetStateAction<number | undefined>>
  setTempMin: Dispatch<SetStateAction<number | undefined>>
}

export const WeatherContext = createContext<WeatherProvider>({
  icon: null,
  tempMax: null,
  tempMin: null,
  tempNow: null,
  address: null,
  weather: null,
  setTempMax: () => {},
  setTempMin: () => {},
})

export const WeatherProviderByContext = ({
  children,
}: {
  children: ReactNode
}) => {
  const [temperature, setTemp] = useState<number>()
  const [temperatureMin, setTempMin] = useState<number>()
  const [temperatureMax, setTempMax] = useState<number>()
  const [weatherIcon, setIcon] = useState<string>()
  const [latitude_state, setLatitude] = useState<number>()
  const [longitude_state, setLongitude] = useState<number>()
  const [address, setAddress] = useState<string>()
  const [weather, setWeather] = useState<WeatherType>()

  // API Keys
  // openweathermap
  const API_KEY: string = ''
  // KaKao
  const KAKAO_API_KEY: string = ''

  useEffect(() => {
    const getLocation = async () => {
      try {
        // 위치 노출이 허용되어 있는지 확인하고 현재 위치 가져오기
        const position = await new Promise<GeolocationPosition>(
          (resolve, reject) => {
            navigator.geolocation.getCurrentPosition(resolve, reject)
          },
        )

        // 위도와 경도 가져오기
        setLatitude(position.coords.latitude)
        setLongitude(position.coords.longitude)
      } catch (error) {
        console.error('Error getting location:', error)
      }
    }

    getLocation()
  }, [])

  useEffect(() => {
    const getWeather = async () => {
      try {
        // OpenWeatherMap에서 위치 기반 날씨 정보 불러오기
        const weatherResponse = await fetch(
          `https://api.openweathermap.org/data/2.5/weather?lat=${latitude_state}&lon=${longitude_state}&appid=${API_KEY}&units=metric`,
        )
        const weatherData = await weatherResponse.json()

        // 날씨 정보 저장하기
        setIcon(weatherData.weather[0].icon)
        setWeather(weatherData.weather[0].main)
        setTemp(weatherData.main.temp.toFixed(1))
        setTempMax(weatherData.main.temp_max.toFixed(1))
        setTempMin(weatherData.main.temp_min.toFixed(1))
      } catch (error) {
        console.error('Error getting location:', error)
      }
    }

    if (latitude_state && longitude_state) getWeather()
  }, [latitude_state, longitude_state])

  useEffect(() => {
    const getAddress = async () => {
      try {
        // Kakao API에서 위치에 대한 주소 가져오기
        const addressResponse = await fetch(
          `https://dapi.kakao.com/v2/local/geo/coord2address.json?x=${longitude_state}&y=${latitude_state}`,
          {
            method: 'GET',
            headers: { Authorization: `KakaoAK ${KAKAO_API_KEY}` },
          },
        )
        const addressData: LocationResponse = await addressResponse.json()
        
        // 값을 제대로 불러왔다면 주소 state에 넣기
        if (addressData.documents && addressData.documents.length > 0) {
          setAddress(
            addressData.documents[0].address.region_1depth_name +
              ' ' +
              addressData.documents[0].address.region_2depth_name,
          )
        }
      } catch (error) {
        console.error('Error getting location:', error)
      }
    }

    getAddress()
  }, [temperatureMin])

  return (
    <>
      {weatherIcon &&
        temperature &&
        temperatureMax &&
        weather &&
        temperatureMin &&
        address && (
          <WeatherContext.Provider
            value={{
              icon: weatherIcon,
              weather: weather,
              address: address,
              tempMax: temperatureMax,
              tempMin: temperatureMin,
              tempNow: temperature,
              setTempMax,
              setTempMin,
            }}>
            {children}
          </WeatherContext.Provider>
        )}
    </>
  )
}

 

위의 Provider를 가지고 와서 모킹한 API 요청에 대한 데이터를 보내줬을 때 잘 받아서 뿌려주는 지에 대한 테스트 코드를 작성해 보겠습니다.


MSW API 모킹하기

1. msw handlers 설정하기

msw handlers에 원하는 요청 경로와 메서드, 응답값 등을 작성하면 됩니다.

저는 위에서 말씀 드렸다시피 OpenWeatherMap API와 Kakao 지도 API 응답을 모킹하겠습니다.

이전에는 rest를 가지고 와서 사용하는 방식이었는데 msw 공식문서를 보면 지금은 http와 HttpResponse를 가져와 API를 모킹해야 합니다. 더 자세한 내용은 아래 공식 문서를 참고해 주세요.

 

Getting started

Three steps to get started with Mock Service Worker.

mswjs.io

 

1.x → 2.x

Migration guidelines for version 2.0.

mswjs.io

 

 

Provider에서 받고 보내주는 데이터는 다음과 같습니다.

날씨 데이터 :

    setIcon(weatherData.weather[0].icon)
    setWeather(weatherData.weather[0].main)
    setTemp(weatherData.main.temp.toFixed(1))
    setTempMax(weatherData.main.temp_max.toFixed(1))
    setTempMin(weatherData.main.temp_min.toFixed(1))

 

위치 데이터 :

setAddress(
            addressData.documents[0].address.region_1depth_name +
              ' ' +
              addressData.documents[0].address.region_2depth_name,
          )

 

API를 모킹하여 해당하는 응답을 보내주면 됩니다.

// src/mocks/handlers.ts

import { http, HttpResponse } from 'msw';

export const handlers = [
  http.get('https://api.openweathermap.org/data/2.5/weather', () => {
    return HttpResponse.json({
      // 실제 데이터는 배열을 보내주기 때문에 [{}] 형대로 목데이터 설정
      weather: [{ icon: '01d', main: 'Clear' }],
      // 실제 데이터는 객체를 보내주기 때문에 {} 형태로 목데이터 설정
      main: { temp: 23.5, temp_min: 20.7, temp_max: 26.2 },
    });
  }),

  http.get('https://dapi.kakao.com/v2/local/geo/coord2address', () => {
    return HttpResponse.json({
      documents: [
        {
          address: {
            region_1depth_name: '서울',
            region_2depth_name: '관악구',
          },
        },
      ],
    });
  }),
];

 

 

2. msw 서버 설정하기

// scr/mocks/server.ts

import { setupServer } from 'msw/node';
import { handlers } from './handlers';

export const server = setupServer(...handlers);

 

jest.setup.ts에서 서버 설정 (모든 테스트가 msw API를 이용하는 경우)

// jest.setup.ts

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

beforeAll(() => server.listen());

afterEach(() => server.resetHandlers());

afterAll(() => server.close());

beforeAll을 통해 모든 테스트가 시작되기 전에 msw를 시작해주고, afterEach를 통해 테스트가 끝난 후 서버를 리셋해줘 서로에게 영향을 주지 못하게 하여 테스트 독립성을 지키고 테스트가 끝난 후 afterAll로 msw 서버를 종료시켜 줍니다.

 

테스트 전체적으로 msw 서버를 이용해 모킹한 API를 사용해야 한다면 위와 같이 jest.setup.ts에 설정을 해주는 게 좋습니다. 그게 아니라면 테스트 개별적으로 서버를 열어주는 것이 더 나은 것 같습니다. 다른 테스트는 msw 서버를 이용하지 않는데 굳이 setup 파일에 설정하여 모든 테스트에 적용할 필요는 없다고 생각합니다.


테스트 코드 작성하기

이제 Provider에 대한 테스트 코드를 작성해 봅시다.

import { useContext } from 'react'
import { render, screen, waitFor } from '@testing-library/react'
import { WeatherProviderByContext, WeatherContext } from './WeatherContext'
import { server } from '../src/mocks/server'


// 테스트할 때 msw 이용하기
beforeAll(() => server.listen())

afterEach(() => server.resetHandlers())

afterAll(() => server.close())

// 테스트용 컴포넌트 만들기
const TestComponent = () => {
  const { icon, tempNow, tempMax, tempMin, address, weather } =
    useContext(WeatherContext)

  return (
    <>
      <div data-testid="icon">{icon}</div>
      <div data-testid="tempNow">{tempNow}</div>
      <div data-testid="tempMax">{tempMax}</div>
      <div data-testid="tempMin">{tempMin}</div>
      <div data-testid="address">{address}</div>
      <div data-testid="weather">{weather}</div>
    </>
  )
}

test('WeatherContext를 통해 불러온 데이터를 하위 컴포넌트로 잘 보내주는 지 테스트', async () => {
  render(
    <WeatherProviderByContext>
      <TestComponent />
    </WeatherProviderByContext>,
  )

  await waitFor(() => {
    expect(screen.getByTestId('icon')).toHaveTextContent('01d')
    expect(screen.getByTestId('tempNow')).toHaveTextContent('23.5')
    expect(screen.getByTestId('tempMax')).toHaveTextContent('26.2')
    expect(screen.getByTestId('tempMin')).toHaveTextContent('20.7')
    expect(screen.getByTestId('address')).toHaveTextContent('서울 관악구')
    expect(screen.getByTestId('weather')).toHaveTextContent('Clear')
  })
})

 

테스트를 시작할 떄 msw 서버를 켜줘서 API를 모킹해주고 일전에 설정해 줬던 데이터를 잘 전달해주는지 테스트 하는 코드입니다.


테스트 진행 중 발생한 에러 해결 과정

1번째 에러 : cannot find module 'msw/node'

해당 테스트 코드를 테스트 하는 과정에서 msw/node를 찾지 못한다는 오류가 발생할 수도 있는데 이럴 경우 jest.config.ts 팔일에 아래의 코드를 추가해주면 됩니다.

// jest.config.ts
module.exports = {
  testEnvironmentOptions: {
    customExportConditions: [''],
  },
}

 

이 설정은 JSDOM이 msw/node 모듈을 가져올 때 기본 내보내기 조건을 사용하도록 강제합니다. 이를 통해 올바른 모듈을 가져와서 테스트 환경에서 사용할 수 있게 됩니다. 즉, 이 설정을 적용하면 msw/node 모듈을 제대로 가져올 수 있도록 도와줍니다.

 

 

2번째 에러 : ReferenceError: TextEncoder is not defined

msw/node를 찾지 못하는 문제는 해결했지만 계속해서 TestEncoder를 찾지 못하는 문제가 발생합니다. 찾아본 결과 공식 문서에서 해결 방법을 알려주고 있는 것을 알아냈습니다.

 

1.x → 2.x

Migration guidelines for version 2.0.

mswjs.io

루트 디렉토리에 jest.polyfills.ts 파일을 만들어 TestEncoder와 TestDecoder를 정의해주면 됩니다.

// jest.polyfills.ts

import { TextDecoder, TextEncoder } from 'node:util';

Object.defineProperties(globalThis, {
  TextDecoder: { value: TextDecoder },
  TextEncoder: { value: TextEncoder },
});

이후 jest.config.ts에서 파일을 사용한다고 명시해 줍시다.

module.exports = {
  setupFiles: ['./jest.polyfills.js'],
}

 

 

3번째 에러 : ReferenceError: Response is not defined

이렇게 정의 해주면 이제 Response를 찾을 수 없다고 나오네요.. 공식 문서에  있는 모든 설정을 적용해 주는게 좋을 것 같습니다.

 

const { Blob, File } = require('node:buffer')
const { fetch, Headers, FormData, Request, Response } = require('undici')
 
Object.defineProperties(globalThis, {
  fetch: { value: fetch, writable: true },
  Blob: { value: Blob },
  File: { value: File },
  Headers: { value: Headers },
  FormData: { value: FormData },
  Request: { value: Request },
  Response: { value: Response },
})

위와 같이 정의해줘야 하기 때문에 undici를 설치해 줍니다.

https://github.com/nodejs/undici

 

아래와 같이 import 상태로 변환하였더니

// jest.ployfills.ts

import { TextDecoder, TextEncoder } from 'node:util'
import { Blob, File } from 'node:buffer'
import { fetch, Headers, FormData, Request, Response } from 'undici'

Object.defineProperties(globalThis, {
  TextDecoder: { value: TextDecoder },
  TextEncoder: { value: TextEncoder },
  fetch: { value: fetch, writable: true },
  Blob: { value: Blob },
  File: { value: File },
  Headers: { value: Headers },
  FormData: { value: FormData },
  Request: { value: Request },
  Response: { value: Response },
})

아예 TestEncoder도 찾지 못하더라고요.. 그래서 공식 문서 그대로 하기 위해서 js 파일로 바꾸고 다음과 같이 코드를 작성했습니다.

// jest.ployfills.js

const { TextDecoder, TextEncoder } = require('node:util')

Object.defineProperties(globalThis, {
  TextDecoder: { value: TextDecoder },
  TextEncoder: { value: TextEncoder },
})

const { Blob, File } = require('node:buffer')
const { fetch, Headers, FormData, Request, Response } = require('undici')

Object.defineProperties(globalThis, {
  fetch: { value: fetch, writable: true },
  Blob: { value: Blob },
  File: { value: File },
  Headers: { value: Headers },
  FormData: { value: FormData },
  Request: { value: Request },
  Response: { value: Response },
})

 

 

4번째 에러 : ReadableStream is not defined

이 에러는 node:util에서 ReadableStream을 가져와 정의해주면 해결됩니다.

// jest.polyfills.js

const { TextDecoder, TextEncoder } = require('node:util')
const { ReadableStream, TransformStream } = require('node:stream/web')

Object.defineProperties(globalThis, {
  TextDecoder: { value: TextDecoder },
  TextEncoder: { value: TextEncoder },
  ReadableStream: { value: ReadableStream },
  TransformStream: { value: TransformStream },
})

const { Blob, File } = require('node:buffer')
const { fetch, Headers, FormData, Request, Response } = require('undici')

Object.defineProperties(globalThis, {
  fetch: { value: fetch, writable: true },
  Blob: { value: Blob },
  File: { value: File },
  Headers: { value: Headers },
  FormData: { value: FormData },
  Request: { value: Request },
  Response: { value: Response },
})

 

 

5번째 에러 :  Error getting location: TypeError: Cannot read properties of undefined

생각해 보니까 OpenWeatherMap API랑 Kakao 지도 API에 대한 요청만 모킹해주고, geolocation에 대한 모킹은 안 해줬더라고요..하하 이에 대한 모킹도 해보겠습니다.

 

beforeAll에 브라우저 API에 대한 모킹을 해놓으면 됩니다.

// WeatherContext.test.ts

beforeAll(() => {
  server.listen()
  // getCurrentPosition 모킹
  Object.defineProperty(global.navigator, 'geolocation', {
    value: {
      getCurrentPosition: jest.fn().mockImplementation(callback =>
        callback({
          coords: {
            latitude: 37.514575,
            longitude: 127.0495556,
          },
        }),
      ),
    },
  })
})

 

이렇게 하면 테스트를 진행 시 getCurrentPosition 함수를 사용할 때 설정한 coords 데이터를 보내줍니다.

 

긴 글 읽어주셔서 감사합니다.

+ Recent posts