프로젝트/하루한냥

[트러블 슈팅] 모듈 시스템과 Jest 테스트

알파카털파카 2023. 7. 14. 22:40
[트러블 슈팅]
모듈 시스템과 Jest 테스트

 

 

로그인, 회원가입 기능을 구현하면서 테스트 오류가 발생했다. 처음에는 단순히 post의 응답 타입을 지정하지 않아서, 혹은 타입이 잘못되어서 발생한 오류인 줄 알았으나 사실은 라이브러리를 잘못 가져다 사용한 문제였고, 생각보다 해결에 오래걸렸다. 허스키에서 1차로 테스트를 진행했지만 이슈를 놓쳤고 깃허브 CI에서 뒤늦게 알게 되었다. 초기 환경을 세팅할 때는 번거로웠지만 이중으로 테스트를 하니까 안정감이 느껴졌다. 이번 글에서는 테스트 오류와 이를 해결하는 흐름을 적어본다.

 

 


 

 

테스트 오류 발생

발단은 이렇다. 허스키 pre-commit에서 npm run test 명령어로 테스트를 하고 있는데, 오류가 잡히지 않았다. 아마 부분 부분을 나눠 커밋하다보니 한 커밋에 오류가 잡히지 않은 듯 하다. 이 문제는 깃허브 액션의 CI 과정에서 통과되지 못했다. 테스트에서 오류가 발생한 부분은 로그인 기능을 하는 함수에서 post의 타입을 정해주지 않았기 때문이었다.

 

깃허브 액션

 

 

 

1. 타입 지정

Axios 설정할 때 post의 Response와 Request 타입을 지정해주었다. 

 

// src/api/http.ts

export const http = {  
  post: function post<Response = unknown, Request = any>(url: string, data?: Request, config?: AxiosRequestConfig) {
    return client.post<APIResponse<Response>>(url, data, config)
    .then((res) => res.data);
  },
  // ...

 

http.post를 사용하면서 응답 타입을 <{ user: { user_token: string; name: string } }> 추가했다. 비슷한 로직을 사용하는 회원가입 페이지의 SignUp 함수에도 동일하게 응답의 타입을 지정했다.

 

// src/pages/SigninPage.tsx

const handleClickSignIn = async () => {
  if (!isDisabledSubmit) {
    try {
      const responseSignIn = await http.post<{ user: { user_token: string; name: string } }>('/user/signin', {
        email: user.email,
        password: user.password,
      });
      const accessToken = responseSignIn.data.user.user_token;
      const userProfile = {
        name: responseSignIn.data.user.name,
      };
      localStorage.setItem(ACCESS_TOKEN, JSON.stringify(accessToken));
      localStorage.setItem(USER, JSON.stringify(userProfile));
      navigate(PATH.CALENDAR);
    } catch (e) {
      const error = handleAxiosError(e);
      alert(error.msg);
    }
  }
};

 

 

그렇지만 타입을 지정했음에도 테스트 오류가 계속 발생했다. 

 

 

 

2. 방어 코드 작성 

response.data 값이 없을 수도 있다. 옵셔널 파라미터와 옵셔널 체이닝 '?' 의 사용을 지양하고자 방어 코드를 추가했다. 보다 명확하고 안전한 타입을 위한 선택이었다.

 

    try {
      const responseSignIn = await http.post<{ user: { user_token: string; name: string } }>('/user/signin', {
        email: user.email,
        password: user.password,
      });
      if (responseSignIn.data) {
        const accessToken = responseSignIn.data.user.user_token;
        const userProfile = {
          name: responseSignIn.data.user.name,
        };
        localStorage.setItem(ACCESS_TOKEN, JSON.stringify(accessToken));
        localStorage.setItem(USER, JSON.stringify(userProfile));
        navigate(PATH.CALENDAR);
      }
    } catch (e) {
    // ...

 

방어 코드를 작성하고 다시 테스트를 돌려보니 이번에는 꽤 긴 테스트 오류가 발생했다. 

 

 

오류 메시지를 해석해 보니 'import 구문이 ECMAScript 모듈이 아닌 환경에서 사용되고 있어 발생하는 문제'였다. import 구문은 일반적으로 브라우저에서 직접 실행되지 않는 자바스크립트 환경에서는 지원되지 않는다고 한다. 이 부분은 Node.js 환경에서 주로 사용되는 CommonJS 방식으로 되어있다는 뜻일까? 그래서 브라우저에서 직접 실행되지 않는 환경이라고 하는 걸까? 이 내용은 뒷쪽에서 더 알아보도록 하고 우선 넘어가겠다.

 

문제가 발생했다고 뜨는 해당 부분에 관한 코드를 살펴보았다.

 

// src/api/http.ts

import axios, { AxiosError, AxiosRequestConfig, AxiosResponse, InternalAxiosRequestConfig } from 'axios';
import qs from 'query-string'; // ✅
import { API_TIMEOUT, BASE_URL } from '@lib/const/config';
import { ACCESS_TOKEN } from '@lib/const/localstorage';

export const client = axios.create({
  baseURL: BASE_URL,
  timeout: API_TIMEOUT,
  timeoutErrorMessage: '서버 요청 시간 초과',
  paramsSerializer: (params) => qs.stringify(params, { arrayFormat: 'comma' }), // ✅
});

 

✅ 표시가 된 부분의 코드를 주석 처리하고 테스트를 실행했더니 테스트가 통과되었다.

 

 

테스트 창에서 뜨는 사이트에 들어가 혹시 필요한 내용이 있을지 검토해보았다. jest.config.ts에서도 이런 저런 코드를 넣었다 뺐다 여러번 시도했었다. 딱히 테스트 문제가 해결이 되지는 않았다.

 

https://jestjs.io/docs/ecmascript-modules

https://jestjs.io/docs/getting-started#using-typescript

 

 

 

3. 라이브러리 교체

필요한 코드를 주석 처리하고 테스트가 통과되는 것은 의미가 없기 때문에, 좀 더 살펴보았다. 내가 설치한 라이브러리가 항상 옳다는 믿음을 의심해볼 필요가 있다. 

 

package.json의 상태는 이랬다. 

 "dependencies": {
    "@emotion/react": "^11.11.1",
    "@emotion/styled": "^11.11.0",
    "axios": "^1.4.0",
    "query-string": "^8.1.0", // ✅
    "react": "^18.2.0",
    "react-dom": "^18.2.0",
    "react-router-dom": "^6.14.0",
    "vite-tsconfig-paths": "^4.2.0",
    "zustand": "^4.3.8"
  },

 

내가 사용하던 query-string 라이브러리이다. query-string이 ESmodules로 되어있지 않아서 오류가 발생하는 것 같았다. jest 설정을 변경하는 대신에 터미널에서 이 라이브러리를 삭제하고, 모듈을 지원하는 다른 qs 라이브러리를 설치했다. 

 

npm uninstall query-string @types/query-string
npm install qs @types/qs

 

재설치하고 난 후의 package.json과 import를 수정한 결과이다. 이렇게 교체하고 테스트를 진행하면 문제 없이 돌아간다. 이 시점에서 대강의 트러블 슈팅을 마치고 커밋 푸시를 해두었다.

 

  "dependencies": {
    "@emotion/react": "^11.11.1",
    "@emotion/styled": "^11.11.0",
    "@types/qs": "^6.9.7", // ✅
    "axios": "^1.4.0",
    "qs": "^6.11.2", // ✅
    "react": "^18.2.0",
    "react-dom": "^18.2.0",
    "react-router-dom": "^6.14.0",
    "vite-tsconfig-paths": "^4.2.0",
    "zustand": "^4.3.8"
  },
// src/api/http.ts

import axios, { AxiosError, AxiosRequestConfig, AxiosResponse, InternalAxiosRequestConfig } from 'axios';
import qs from 'qs'; // ✅
import { API_TIMEOUT, BASE_URL } from '@lib/const/config';
import { ACCESS_TOKEN } from '@lib/const/localstorage';

export const client = axios.create({
  baseURL: BASE_URL,
  timeout: API_TIMEOUT,
  timeoutErrorMessage: '서버 요청 시간 초과',
  paramsSerializer: (params) => qs.stringify(params, { arrayFormat: 'comma' }),
});

 

 

 

4. vite 뜯어보기 

query-string과 qs, 각 라이브러리의 차이점은 무엇일까? 이 내용을 추가적으로 알아보기 위해 라이브러리의 깃허브에 들어가보았다.

 

🔗 query-string 라이브러리 module 검색 결과

🔗 qs 라이브러리 module 검색 결과 

 

 

query-string 라이브러리

 

qs 라이브러리

 

예상과는 달리 qs가 module.exports를 사용하는 CommonJS로 되어있고, ESmodules가 아니라서 오류가 발생했을거라고 예상했던 query-string 라이브러리가 ESM으로 되어있었다. 그렇다면 왜 라이브러리 교체로 테스트가 통과될 수 있었던걸까?

 

이번 프로젝트는 vite로 구성했다. vite에 대해 잠시 살펴보자. vite는 ESMoudles와 esbuild를 사용한다. vite의 깃허브에 들어가보면 내부적으로 esbuild를 사용하고 있음을 볼 수 있다. esbuild의 공식문서에는 웹을 위한 매우 빠른 번들러 라고 소개되어 있다.

 

🔗 Vite를 사용해야 하는 이유

🔗 vite: 프로덕션 버전으로 빌드하기

 

Vite는 이러한 것에 초점을 맞춰, 브라우저에서 지원하는 ES Modules(ESM) 및 네이티브 언어로 작성된 JavaScript 도구 등을 활용해 문제를 해결하고자 합니다.
빌드된 프로덕션 번들은 모던 JavaScript를 지원하는 환경에서 동작한다고 가정합니다. 따라서 Vite는 기본적으로 
네이티브 ES 모듈, 네이티브 ESM의 동적 Import, 그리고 import.meta를 지원하는 브라우저를 타깃으로 하고 있습니다.

 

vite 깃허브

 

ChatGPT의 힘을 빌려, esbuild와 두 모듈 시스템은 어떤 관계인지 알아보았다. esbuild는 ESM과 CJS 모두 지원하고 있었다. 

 

esbuild은 ES Modules 및 CommonJS와 함께 작동하여 모듈을 번들링하고 변환합니다. esbuild는 입력으로 ES Modules나 CommonJS 형식의 모듈을 받아 번들링하여 출력으로 다양한 형식의 번들을 생성할 수 있습니다. 예를 들어, esbuild는 ES Modules로 작성된 코드를 브라우저에서 실행할 수 있는 번들로 변환할 수 있으며, CommonJS 형식으로 작성된 코드를 Node.js에서 실행할 수 있는 번들로 변환할 수도 있습니다.

따라서 esbuild는 다양한 모듈 시스템을 지원하며, ES Modules와 CommonJS를 효과적으로 처리할 수 있습니다. 이를 통해 개발자는 esbuild를 사용하여 JavaScript 프로젝트를 모듈화하고 번들링하는 데 필요한 작업을 간편하게 수행할 수 있습니다.

 

 

 

5. 모듈 시스템

사실 모듈 시스템을 정확히 알고 있지 못해서, 이 기회에 학습해보았다. 개발하는 애플리케이션의 크기가 커지면 필연적으로 파일을 분리해야 하는 시점이 오는데, 이때 분리된 각각의 파일을 모듈(Modules)이라고 부른다. 클래스 하나 또는 여러 개의 함수로 구성된 라이브러리 하나로 되어 있다. 최신 브라우저는 기본적으로 모듈 기능을 지원하며, 브라우저는 모듈의 로딩을 최적화할 수 있어 라이브러리를 사용하는 것보다 효율적이다.

 

빌드는 코드 파일을 컴퓨터 또는 스마트폰에서 실행할 수 있는 독립적인 소프트웨어로 만드는 과정이다. 코드가 실행 코드로 변환되는 컴파일 과정이 핵심이다. 빌드는 번들링을 포함하는 개념으로 볼 수 있다. 번들러는 의존성이 있는 모듈 코드를 하나 또는 여러 개의 파일로 만들어주는 도구이다. 번들링은 애플리케이션의 종속성을 관리하고 최적화해서 배포 가능한 형태로 만드는 작업이다. 유명한 번들러 도구로는 webpack이나 parcel가 있다.

 

모던 자바스크립트 진영에서 주로 사용되는 모듈 시스템에는 CommonJS와 ES Modules 두 가지가 있다. 이 두 모듈 시스템에는 차이가 있는데 이 내용도 이 참에 제대로 알아봤다.

 

📌 CommonJS는 주로 Node.js, 서버 측에서 사용된다. require 함수를 사용해 모듈을 가져오고 exports, module.exports를 사용해 모듈을 내보낸다. 모듈을 로드할 때 동기적으로 로드하기 때문에, 해당 모듈이 완전히 로드될 때까지 기다려야 한다. 

 

📌 ES Modules은 주로 브라우저에서 사용된다. 브라우저의 네이티브 모듈 시스템이지만 Node.js에서도 지원된다. import, export 문을 사용해 모듈을 가져오고 내보낸다. 모듈을 로드할 때 비동기적으로 로드하기 때문에, 모듈의 로딩이 완료되기 전에는 사용할 수 없다. 

 

위에서 보았듯 esbuild는 웹을 위한 번들러이다. CJS와 ESM 모듈 시스템 모두를 지원하며 번들링이 끝나면 여러 개의 파일을 합친 번들을 생성한다. vite는 빌드 과정에서 번들링을 진행하며, 이를 통해 애플리케이션의 로딩 속도를 높일 수 있다. 

 

 

 

마치며

왜 라이브러리의 교체로 제대로 동작하게 되었는지는 정확히 밝혀내지 못했다. Jest를 실행하는 환경에서 ES Modules를 사용하기 위해 jest.config.ts 설정을 추가했어야 하지 않았을까 추측할 뿐이다. 일반적으로 jest는 babel과 함께 사용되는데 이번 프로젝트는 vite로 구성했기 때문에 vite와 모듈 시스템에 대해 알아보는 기회가 되었다. vite build를 하면서 esbuild를 사용하게 되는데, 이 때 esbuild가 두 가지 ESM, CJS 모듈 시스템 모두 지원하기 때문에 라이브러리가 어떤 모듈 시스템으로 되어있는지는 크게 상관 없지 않을까 싶다. 명쾌하게 해결되지 않아 찜찜하다. 이 글도 거의 일주일에 걸쳐 조금씩 작성되었기 때문에 추가로 더 학습하거나 알게된 내용이 있으면 덧붙여야겠다.