vite 프로젝트에서 msw, vitest를 이용해 테스트 코드 작성해보기 (1)

기운찬곰·2023년 3월 17일
9
post-thumbnail

Overview

간단하게 문제를 푸는 사이트를 프로토타입으로 만들어볼 예정입니다.

  • 근데 문제 양식이 다양하다고 가정합니다.
  • 예를 들어, 기본 4지선다형도 있고, 그림으로 된 4지선다형도 있고, 5지선다형이 될 수도 있고, 선긋기, 드래그 앤 드랍 등 다양한 문제 형식이 있다고 가정해보려고 합니다. 이랬을 때 프로젝트 구성을 어떻게 해야 할지도 생각해봐야 될 것 입니다.

해당 프로젝트에 있어서 백엔드 없이 간단히 msw로 mock api를 구성해서 api를 호출하면 내가 원하는 데이터 형식 반환해줄 수 있게 만들 예정이고, 이를 테스트 코드로 작성해서 TDD를 적용해볼 생각입니다. 특히 해당 서비스는 TDD에 적합하다고 생각 했습니다. 문제를 제대로 불러와서 보여주는가. 사용자가 선택지를 선택하면 적절한 표시를 해주는가. 사용자가 정답을 제출하면 혹은 오답을 제출하면 그에 맞는 반응을 하는가. 서버에서 오류를 전송한다면 에러 메시지를 제대로 보여주는가 등.

마침 사이드 프로젝트에도 TDD를 적용해볼 생각인데 짜투리 시간에 만들어보면 정말 재밌을 거 같다고 생각했습니다.


프로젝트 구성

여기서는 일단 Next.js를 사용하진 않을 생각입니다. Next.js가 생각보다 다루기가 어렵고 13버전이 msw랑 오류도 많이나고 해서, 이번에는 빠르게 만들어볼 예정이기 때문에 CRA로 구성하려고 했습니다. 근데 CRA로 프로젝트 세팅하고 실행하고 보니까 처음 띄우는데 속도가 엄청 느린거 같아보였습니다. 그래서 요즘에 Vite가 유명한거 같아서 Vite로 프로젝트를 다시 세팅해봤습니다.

프로젝트 시작 : https://vitejs.dev/guide/

yarn create vite my-vue-app --template react-ts

✍️ 이렇게 구성하고 실행하니까 진짜 빠르더군요. CRA가 webpack을 써서 느리다고 하고, vite가 esbuild를 사용해서 빠르다고 하는데 정말 실감이 납니다.

그리고 css는 어차피 저 혼자 할 것이기 때문에 빠르게 사용할 수 있는 tailwindcss로 구성했습니다. 근데 이 구성 방법이 어떤 프레임워크, 라이브러리를 사용하냐에 따라 많이 달라지는 거 같습니다. vite로 시작했으면 vite + tailwindcss 구성방법을 찾아서 세팅해줘야 하더군요.

참고 : https://tailwindcss.com/docs/guides/vite

아, 그리고 한가지 더!! 보통 파일 import 절대 경로 설정할 때 tsconfig 에서 baseUrl, paths만 설정하면 됐는데, vite는 그게 아니더군요. vite.config.js에서도 설정을 추가로 해줘야 합니다.

참고 : https://l4279625.tistory.com/entry/vite-절대경로-설정하는-법


msw 세팅

msw 공식문서 : https://mswjs.io/docs/getting-started/install

msw 공식문서에 나온 사용법과 크게 다르지 않습니다. 일단 저는 browser 환경에 대해서만 세팅을 했고 server는 하지 않았습니다. broswer에는 service worker로 작동하는데 해보니까 브라우저에서 잘 작동하였습니다.

근데 vite는 공식문서에서 나온것처럼 require로 못불러오니 참고바랍니다. vite는 아예 require을 지원하지 않는다고 합니다. (깃허브 이슈 참고)

if (process.env.NODE_ENV === 'development') {
  const { worker } = require('./mocks/browser')
  worker.start()
}

그래서 저는 require을 빼고 mocks 폴더에 index.ts를 하나 따로 만들었습니다.

async function initMocks() {
  if (typeof window === "undefined") {
    const { server } = await import('./server')
    server.listen()
  } else {
    const { worker } = await import("./browser");
    worker.start();
  }
}

export default initMocks;

그리고 이걸 초기 시작 파일(vite에서는 main.tsx)에서 NODE_ENV에 따라 개발모드에서 msw api가 사용되도록 해줬습니다. 근데 최상단에 await을 해도 될까? 했는데 잘 되었습니다. 역시 vite~ 최신 문법 지원을 잘 하는거 같습니다.

if (process.env.NODE_ENV === "development") {
  await initMocks();
}

브라우저에서 msw 가 잘 작동하면 [MSW] Mocking enabled 라고 나옵니다. 그리고 msw에서 handlers에서 내가 만든 api를 호출하면 MSW에서 받아서 처리하는 것을 볼 수 있습니다.

실제 호출 결과. 여기서 보면 200 OK (from service worker) 라고 되어있습니다. 즉, 서비스 워커에서 인터셉터해서 처리한 것입니다.

그리고 Application Service Workers에서도 설치된 service worker을 볼 수 있습니다.

참고 : https://fe-developers.kakaoent.com/2022/220825-msw-integration-testing/

카카오 엔터테이먼트 FE 기술블로그는 msw 구성을 다음과 같이 구성했다고 합니다.

src
├── mocks
│   ├── api
│   │   ├── data
│   │   │   └── searchResultData.ts
│   │   └── searchResult.ts
│   ├── handlers.ts
│   └── server.ts

근데 data 까지 굳이 분리할 필요가 있을까 해서 저는 api 폴더만 분리해서 만들기로 했습니다.

axios 구성

axios 공통 구성은 딱히 뭐 별거 없는거 같습니다. 제일 중요한 baseURL 설정해주고(나중에 환경변수에 따라 바뀌게 해줄 예정), 응답에 대해 에러 인터셉터 처리해주고 끝인거 같습니다.

import axios from "axios";

const client = axios.create({
  baseURL: "http://127.0.0.1:5173",
  headers: {
    "Content-Type": "application/json",
  },
});

// 응답 인터셉터 처리
client.interceptors.response.use(
  (response) => {
    if (response && response.data) {
      return response.data;
    }
    return response;
  },
  (error) => {
    if (error.response) {
      if (error.response.status === 400) {
        return {
          code: "400",
          message: "400",
        };
      }
      if (error.response.status === 401) {
        return {
          code: "401",
          message: "401",
        };
      }
      if (error.response.status === 403) {
        return {
          code: "403",
          message: "403",
        };
      }
      if (error.response.status === 404) {
        return {
          code: "404",
          message: "404",
        };
      }
    }
    return Promise.reject(error);
  }
);

export default client;

routing 구성

참고 : https://reactrouter.com/en/main

react-router-dom v6 문서를 찾아보면서 구성해봤습니다. 근데 예전이랑 좀 다른거 같습니다. createBrowserRouter에서 path랑 element 설정해주고, 그걸 RouterProvider에다가 넣어주면 끝입니다.

이거 역시 vite에서 시작점 파일인 main.tsx에서 해줬습니다.

import React from "react";
import ReactDOM from "react-dom/client";
import {
  createBrowserRouter,
  RouterProvider,
} from "react-router-dom";
import "./index.css";

const router = createBrowserRouter([
  {
    path: "/",
    element: <App />,
  },
]);

ReactDOM.createRoot(document.getElementById("root")).render(
  <React.StrictMode>
    <RouterProvider router={router} />
  </React.StrictMode>
);

pages 및 layout 구성

프로젝트 구성 트리를 블로그에서 좀 쉽게 보여주는 방법 없나 해서 찾아보니 vscode 익스텐션에 file-tree-generator라는게 있더군요. 보기 좋네요.

📦src
 ┣ 📂assets
 ┃ ┗ 📜react.svg
 ┣ 📂components
 ┃ ┣ 📂common
 ┃ ┃ ┣ 📂Loading
 ┃ ┃ ┗ 📂QuestionLayout
 ┃ ┃ ┃ ┣ 📜index.ts
 ┃ ┃ ┃ ┗ 📜QuestionLayout.tsx
 ┃ ┗ 📂templates
 ┃ ┃ ┣ 📂BasicQuestion
 ┃ ┃ ┃ ┣ 📜BasicQuestion.tsx
 ┃ ┃ ┃ ┗ 📜index.ts
 ┃ ┃ ┗ 📂CommonQuestion
 ┃ ┃ ┃ ┣ 📜CommonQuestion.tsx
 ┃ ┃ ┃ ┗ 📜index.ts
 ┃ ┃ ┗ 📂QuestionTemplate
 ┃ ┃ ┃ ┣ 📜QuestionTemplate.tsx
 ┃ ┃ ┃ ┗ 📜index.ts
 ┣ 📂lib
 ┃ ┣ 📜client.ts
 ┃ ┗ 📜queryClient.ts
 ┣ 📂mocks
 ┃ ┣ 📂api
 ┃ ┃ ┗ 📂question
 ┃ ┃ ┃ ┣ 📂basic
 ┃ ┃ ┃ ┃ ┗ 📜basicQuestionHandler.ts
 ┃ ┃ ┃ ┗ 📂picture
 ┃ ┣ 📜browser.ts
 ┃ ┣ 📜handlers.ts
 ┃ ┗ 📜index.ts
 ┣ 📂pages
 ┃ ┗ 📂question
 ┃ ┃ ┣ 📂basic
 ┃ ┃ ┃ ┣ 📜index.ts
 ┃ ┃ ┃ ┗ 📜QuestionBasicPage.tsx
 ┃ ┃ ┗ 📂picture
 ┃ ┃ ┃ ┣ 📜index.ts
 ┃ ┃ ┃ ┗ 📜QuestionPicturePage.tsx
 ┣ 📜App.css
 ┣ 📜App.tsx
 ┣ 📜index.css
 ┣ 📜main.tsx
 ┗ 📜vite-env.d.ts
  • 일단 메인 페이지 → App.tsx. 여기서는 각 문항별 문제들 리스트를 보여줄 예정. 이쁘게 카드 형식으로 Swiper 사용해서 보여주면 좋을 거 같은데 그건 나중에 하도록 합니다.
  • pages : 각 문항 종류별로 폴더를 구성할 것입니다. 기본형, 그림형 등…
  • components/common : 기본 컴포넌트 폴더 (atomic 하도록)
    • QuestionLayout : 문항 기본 레이아웃 정의. 헤더, 푸터는 공통으로 가져가려고 합니다.
  • components/templates : atoms 보다 한단계 위 컴포넌트 폴더
    • CommonQuestion : 실제 문항에 대한 API를 요청에 데이터를 가져옴.
    • BasicQustion : 기본형 문제 유형. CommonQuestion에서 받은 데이터에 대해 실질적으로 보여주는 부분. 아무래도 이렇게 문제 유형별로 만들 생각이긴 함… (그림형에 대해서는 PictureQuestion 등)

react-query와 suspense 구성

react-query에서 suspense를 쓰려면 옵션중에 suspense: true 해줘야 합니다. 그리고 Suspense는 Suspense 내부에서 api 통신을 해야 합니다. 그래야 fallback으로 Loading 을 적용할 수 있고, ErrorBoundry 사용도 가능해집니다.

근데 문항 페이지 형태 자체가 헤더랑 푸터는 Suspense를 적용하지 않을 예정이고, 콘텐츠 영역에 대해서만 데이터를 불러오는 Loading, 그리고 에러 시에 적절한 에러 메시지를 보여줄 수 있도록 할 예정이기 때문에 QuestionLayout 안에다가 Suspense를 넣어줬습니다.

function QuestionLayout({ children }: { children: React.ReactNode }) {
  return (
    <main>
      <QuestionLayout.Header />
      <section className="h-128 p-8">
        <Suspense fallback={<div>문제 로딩 중...</div>}>{children}</Suspense>
      </section>
      <QuestionLayout.Footer />
    </main>
  );
}

흠. 근데 만약 QuestionLayout.Header나 Footer에도 데이터를 호출해서 보여줘야 하는 부분이 있다면? 아마 그 부분은 메인 페이지에서 문제 리스트를 호출하는 데이터를 가져다가 사용하면 될 듯 합니다. 그니까 콘텐츠를 불러오는 API랑은 별개인 부분이라 상관 없을 거 같습니다.

QuestionTemplate을 만들어서 재사용과 재구성력을 높일까?

근데 기본형 문제, 그림형 문제.. 등 문제 유형에 따라 전부 따로 따로 만들면 관리하기 어렵지 않을까 생각했습니다. 분명 공통인 요소들이 존재할 것입니다. 문제 제목, 제출하기 버튼 등... 이런 요소마다 QuestionTemplate을 만들면 어떨까요?

근데 상태관리를 위해 상태는 위에서 관리하고 아래로 넘겨주는 방식으로 해야할 거 같습니다.

import { IChoice } from "@types/Question/QuestionType";

function QuestionTemplate({ children }: { children: React.ReactNode }) {
  return <div className="question-content">{children}</div>;
}

function QuestionTitle({
  question,
  className,
}: {
  question: string;
  className?: string;
}) {
  return (
    <div className={`question-content__title ${className}`}>
      <h2 className="text-2xl font-bold">{question}</h2>
    </div>
  );
}

function QuestionChoices({
  choices,
  selectedChoice,
  handleChoice,
}: {
  choices: IChoice[];
  selectedChoice: number | null;
  handleChoice: (e: React.ChangeEvent<HTMLInputElement>) => void;
}) {
  console.log("selectedChoice", selectedChoice);

  return (
    <div className="question-content__choices flex flex-col gap-8">
      {choices.map((choice) => (
        <div
          key={choice.id}
          className="question-content__choice border-2 rounded-xl p-4"
        >
          <input
            type="radio"
            name="choice"
            id={`choice-${choice.id}`}
            value={choice.id}
            onChange={handleChoice}
          />
          <label htmlFor={`choice-${choice.id}`} className="text-xl">
            {choice.content}
          </label>
        </div>
      ))}
    </div>
  );
}

QuestionTemplate.Title = QuestionTitle;
QuestionTemplate.Choices = QuestionChoices;

export default QuestionTemplate;

css 조건문 처리 - tailwind-merge + clsx

tailwind-merge: https://www.npmjs.com/package/tailwind-merge

  • 아. 그니까 tailwind 끼리 뭔가 충돌이 날만한 상황에 대해서 처리를 해주는 모양입니다

clsx : https://www.npmjs.com/package/clsx

  • 이 녀석은 clsx(’foo’, true && ‘bar’, ‘baz’) 같이 좀 더 className에 대한 조건문을 쉽게 사용할 수 있도록 해주는 라이브러리라고 보면 됩니다.

이를 조합해서 다음과 같은 유틸리티 함수를 만들어서 사용할 수 있습니다.

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs));
}

오. 이렇게 쓰니까 css 조건문 처리하기 훨씬 수월합니다.

<div
    key={choice.id}
    className={cn(
      "question-content__choice border-2 rounded-xl p-4",
      choice.id === selectedChoice && "border-teal-500"
    )}
  >

드디어 테스트 코드 작성해보기 - 그 전에 일단 사전 조사

근데 테스트 코드는 어디서 부터 어떻게 시작해야 할까요? 그 전에 어떤걸 테스트 하면 좋을까요? 저는 아래 상황에 대해 테스트 코드를 작성해보고 싶습니다.

  • 기본적으로 화면에 정보를 잘 보여주는지
  • 선택지를 선택했을 때 css가 잘 바뀌어서 사용자에게 선택되었다는 사실을 보여주는지
  • 제출 버튼 클릭 후 정답일 때와 오답일 때 적절한 응답이 오는지
  • 문항 정보 데이터를 못 불러왔을 때 처리는 제대로 하는지
  • 기타 등등…

이제 테스트 라이브러리를 어떤걸 쓸지 찾아볼까요?

근데 저는 테스트 라이브러리 중에 제일 유명한 양대산맥인 jest랑 react-testing-library가 있는건 알지만 이 두개가 뭔 차이가 있는지 잘 모르겠습니다. 그래서 한번 찾아봤습니다.

참고 : https://blog.logrocket.com/testing-react-apps-jest-react-testing-library/

  • 알고 보니 이 두개는 성격 자체가 완전 다르더군요.
  • jest는 테스트 러너인 셈입니다. “Jest offers functions for test suites, test cases, and assertions.”
  • react-testing-library는 테스트 환경에서 가상 돔을 제공해준다. 사용자의 상호작업을 시뮬레이션하고 출력을 비교해서 올바르게 동작하는지 확인할 수 있게 한다.
  • 웹 브라우저 없이 테스트를 실행할 때는 항상 가상 DOM을 사용하여 앱을 렌더링하고 요소와 상호 작용하며 가상 DOM이 정상적으로 동작하는지 관찰해야 합니다

그래서 jest랑 react-testing-library는 상호 보완적 관계이며 같이 사용할 수 밖에 없는 거였습니다. 근데 무조건 이 조합만 쓸 수 있다는 건 아닙니다. jest 대신 mocha를 쓸 수도 있고, 밑에서 나오는 vitest를 쓸 수도 있습니다.


참고 : https://blog.mathpresso.com/모던-프론트엔드-테스트-전략-1편-841e87a613b2

  • 해당 글에서는 테스트 환경에 따라 브라우저 환경, 노드 환경이 있다고 합니다. 그 중 jest와 react-testing-library는 node.js 환경에서 테스트를 수행하는 거 같습니다. 정확히 말해서는 Jest가 Node.js 기반으로 돌아가는거 같습니다.
  • 그리고 Node.js 환경은 브라우저에서 제공하는 Web API, DOM 접근 API를 사용할 수 없다는 단점이 존재합니다. 이러한 문제를 극복하기 위해 jsodm처럼 DOM을 가상으로 구현하는 라이브러리를 활용하고 있는데, 여전히 페이지 내비게이션이나 레이아웃과 같은 것은 테스트 할 수 없다고 합니다.

참고 : https://ui.toast.com/fe-guide/ko_TEST#브라우저-vs-nodejs

  • "테스트 러너는 크게 Karma와 같이 브라우저에서 직접 코드를 실행하는 러너와, Jest와 같이 Node.js 환경에서 코드를 실행하는 러너로 나눌 수 있다."
  • "이 중 Node.js 기반의 테스트 러너들은 굳이 러너의 실행 환경과 코드의 실행 환경을 구분할 필요가 없기 때문에 대부분 테스트 프레임워크와 통합된 형태로 제공된다.”
  • 위의 설명을 보고도 아직 어떤 환경을 선택해야 할지 고민된다면 다음의 가이드를 따르기를 권장한다.
    • 크로스 브라우징 테스트가 "반드시" 필요한 경우 브라우저 환경을 사용한다.
    • 브라우저의 실제 동작(렌더링, 네트워크 IO, 내비게이션 등)에 대한 테스트가 필요한 경우 브라우저 환경을 사용한다.
    • 그 외의 경우 Node.js 환경을 사용한다.

근데 1번에 경우 최근에는 크로스 브라우징 테스트의 필요성이 많이 감소했습니다. 최신 브라우저들은 표준 명세의 구현에 있어 과거에 비해 브라우저 간의 차이가 거의 없어졌습니다. 따라서 브라우저 환경이 정말 필요한 것이 아니라면 Node.js 환경으로 테스트하는게 맞는거 같습니다.

vitest + react testing library 설치 및 사용해보기

아. 근데 vite는 vitest라는게 있었습니다. 살펴보니까 다운로드 수가 가파르게 늘어나고 있더군요. 이걸 써야하나 싶은데 아무래도 vite는 vitest 를 써야 할 것만 같아서 이걸 사용하기로 했습니다. jest랑 크게 사용법이 다를 거 같지 않기도 했고요.

근데 msw 랑 연동부터 해야 되는데... 공식문서를 찾아보니 있더군요. 역시 msw도 노드 환경에서 돌아가도록 설정이 필요합니다. msw가 좋은 점이 빠르게 브라우저 환경이랑 서버 환경 동시에 커버가 되니 정말 유용한거 같습니다.

설정이 끝났다면 테스트 코드랑 msw 연동을 해줍니다. 찾아보니 좋은 가이드 코드가 있더군요.

이건 글로벌 설정입니다. 모든 테스트 공통이기 때문에 vitest-setup.ts 라고 만들어줍니다.

import { server } from "@mocks/server";
import { afterAll, afterEach, beforeAll } from "vitest";

// Start server before all tests
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));

//  Close server after all tests
afterAll(() => server.close());

// Reset handlers after each test `important for test isolation`
afterEach(() => server.resetHandlers());

그리고 나서 vite.config.js에 해당 설정파일을 적용해주면 됩니다.

import { defineConfig } from "vitest/config"; // vitest/config로 변경

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [react()],
  test: {
    globals: true,
    environment: 'jsdom',
    setupFiles: ['./src/setup.ts'],
  },
})

근데… 이제 테스트 코드를 작성해보려고 하는데 어디서부터 어떻게 할지 좀 막막하더군요. 그래서 예시를 찾기 위해 서치를 좀 해봤습니다. 참고 : https://www.wwt.com/article/using-mock-service-worker-to-improve-jest-unit-tests

일단 컴포넌트를 render 부터 해야 할 거 같습니다. 근데 어떤 컴포너트를 render 할 건가에 대해 좀 의문입니다. BasicQuestion 만 render 하자니 API 통신을 통해 데이터를 받아오는 부분이 없습니다. props로 제가 직접 데이터를 넣어주기에는 이럴거면 msw를 사용하는 의미가 없어집니다.

아무래도 한단계 윗단계인 CommonQuestion 컴포넌트를 render 해줘야 할 거 같습니다. 근데 react-query 때문에 에러가 나더군요...

describe("기본 선다형 문항 테스트", () => {
  it("should render", () => {
    // reqeust msw data inner CommonQuestion
    render(<CommonQuestion type="basic" questionId="1" />);
  });
});

⛔️ FAIL src/components/templates/BasicQuestion/BasicQuestion.test.tsx > 기본 선다형 문항 테스트 > should render Error: No QueryClient set, use QueryClientProvider to set one

참고 : https://tanstack.com/query/v4/docs/react/guides/testing

좀 많이 복잡하네요. 아 근데 여기 나와있는건 react-query를 이용해 데이터 호출한 결과에 대한 테스트인거 같아 보입니다. 저는 화면을 테스트하고 싶은건데...

참고 : https://testing-library.com/docs/react-testing-library/setup#custom-render

render에 wrapper 기능을 이용하면 될 거 같습니다.

// lib/test-utils.ts
import { QueryClientProvider } from "@tanstack/react-query";
import { render } from "@testing-library/react";
import React from "react";
import queryClient from "./queryClient";

const AllTheProviders = ({ children }: { children: React.ReactNode }) => {
  return (
    <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
  );
};

const customRender = (ui: React.ReactElement, options = {}) =>
  render(ui, {
    // wrap provider(s) here if needed
    wrapper: AllTheProviders,
    ...options,
  });

export * from "@testing-library/react";

// override render export
export { customRender as render };

이렇게 하고 보니 에러는 없어지고 테스트가 통과 되었습니다. 근데 이제 예를 들어 제목이 잘 나왔는지 테스트 하려면 expect랑 screen 등 react-testing-library를 사용할 줄 알아야 합니다. 산 넘어 산이군요. 이건 다음 시간에 알아보도록 하겠습니다.

마치면서

이번 시간에 참 많은 것을 해본 거 같습니다. vite도 처음 사용해보고, msw로 이렇게 제대로 사용해본건 처음인거 같고요. 테스트 라이브러리를 알아보고 일단 동작되도록 구성해본 것도 만족할만한 성과인 거 같습니다.

특히, msw가 참 좋은 거 같습니다. 이제는 정말 백엔드가 구현이 완료될 때까지 기다릴 필요 없이, 데이터 형식만 공유 된다면 프론트가 직접 mock api를 만들어서 구현할 수 있으니까 업무 효율이 꽤 많이 좋아질게 분명합니다.

profile
배움을 좋아합니다. 새로운 것을 좋아합니다.

0개의 댓글