간단하게 문제를 푸는 사이트를 프로토타입으로 만들어볼 예정입니다.
해당 프로젝트에 있어서 백엔드 없이 간단히 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 공식문서 : 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 공통 구성은 딱히 뭐 별거 없는거 같습니다. 제일 중요한 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;
참고 : 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>
);
프로젝트 구성 트리를 블로그에서 좀 쉽게 보여주는 방법 없나 해서 찾아보니 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
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을 만들면 어떨까요?
근데 상태관리를 위해 상태는 위에서 관리하고 아래로 넘겨주는 방식으로 해야할 거 같습니다.
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;
tailwind-merge: https://www.npmjs.com/package/tailwind-merge
clsx : https://www.npmjs.com/package/clsx
이를 조합해서 다음과 같은 유틸리티 함수를 만들어서 사용할 수 있습니다.
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"
)}
>
근데 테스트 코드는 어디서 부터 어떻게 시작해야 할까요? 그 전에 어떤걸 테스트 하면 좋을까요? 저는 아래 상황에 대해 테스트 코드를 작성해보고 싶습니다.
이제 테스트 라이브러리를 어떤걸 쓸지 찾아볼까요?
근데 저는 테스트 라이브러리 중에 제일 유명한 양대산맥인 jest랑 react-testing-library가 있는건 알지만 이 두개가 뭔 차이가 있는지 잘 모르겠습니다. 그래서 한번 찾아봤습니다.
참고 : https://blog.logrocket.com/testing-react-apps-jest-react-testing-library/
그래서 jest랑 react-testing-library는 상호 보완적 관계이며 같이 사용할 수 밖에 없는 거였습니다. 근데 무조건 이 조합만 쓸 수 있다는 건 아닙니다. jest 대신 mocha를 쓸 수도 있고, 밑에서 나오는 vitest를 쓸 수도 있습니다.
참고 : https://blog.mathpresso.com/모던-프론트엔드-테스트-전략-1편-841e87a613b2
참고 : https://ui.toast.com/fe-guide/ko_TEST#브라우저-vs-nodejs
근데 1번에 경우 최근에는 크로스 브라우징 테스트의 필요성이 많이 감소했습니다. 최신 브라우저들은 표준 명세의 구현에 있어 과거에 비해 브라우저 간의 차이가 거의 없어졌습니다. 따라서 브라우저 환경이 정말 필요한 것이 아니라면 Node.js 환경으로 테스트하는게 맞는거 같습니다.
아. 근데 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를 만들어서 구현할 수 있으니까 업무 효율이 꽤 많이 좋아질게 분명합니다.