- Mock Service Worker is an API mocking library that allows you to write client-agnostic mocks and reuse them across any frameworks, tools, and environments.
Mock Service Worker (
MSW
)라이브러리는 백엔드 서버를 Mocking 할 때 사용하기 좋은 JS 라이브러리 입니다. MSW
의 소개글은 다음과 같습니다. 클라이언트에 구애받지 않게 mock 을 만들수 있도록 해주고, 어떤 툴과 환경, 프레임워크에서도 재사용 가능하게 해준다고 합니다. 독립적으로 사용할 수 있는 JS 백엔드 Mock 서버라고 생각하면 될 것 같습니다. MSW 같은 백엔드 mock 서버가 있다면, 프론트엔드 개발이 가진 백엔드 개발의 의존성(package.json
의 그 의존성이 아닌 개발 프로세스 상의 의존성)을 줄일 수 있습니다. 백엔드 개발이 아직 완료되지 않았더라도 프론트엔드 개발은 마치 서버가 완성이 된 것처럼 간단한 통신을 가능하게 해준다는 것입니다.1. 어떻게 해주는가?
클라이언트 사이드에서는 브라우저 제공하는 Service Worker 라는 기능을 사용해 합니다. Service Worker 는 Web API 로 웹 페이지를 보여주는 부분과 별개로 백그라운드에서 실행되어 브라우저 웹 페이지 성능에 도움을 줄 수 있게 도와주는 기능입니다. 해당 기능은 스레드를 따로 할당받기 때문에, 웹 브라우저의 네트워크 요청을 가로채서 웹 애플리케이션이 백엔드에 api 를 호출하는 것을 모의로 할 수 있습니다.
→ 실제로 api 이름도 서버가 아닌 worker 가 됩니다.
서버 사이드에서는 node 의 http 모듈을 통해 들어오는 요청을 가로채고 처리합니다. 마치 미들웨어 처럼 요청을 가로채고 조작하여 모의 응답을 합니다.
→ 실제로 api 이름이 서버입니다.
2. 적용
Next.js 에 적용하기 때문에 클라이언트 사이드와 서버 사이드 양측에 모의 서버를 적용할 수 있어야합니다. 하지만, 현재는 서버 사이드에서의 MSW 적용에 문제가 약간 있어서 일단은 클라이언트 쪽만 먼저 해보려고 합니다. () Next.js 는 14.2.2 버전을 사용하였습니다. MSW Docs 는 정말 잘 정리되어 있어서, Docs 만 읽고 따라해도 금방 적용할 수 있습니다. 클라이언트 사이드 즉, browser 환경의 경우에는 문제 없이 잘 됩니다.
Support Next.js 13 (App Router)
Updated May 13, 2024
- 동작방식 이해하기
- 설치
- API 작성
- Mock 서버 실행
npm install msw@latest --save-dev pnpm add -D msw@latest
msw 는 개발환경에서만 사용할 것이니 devDependency 로 추가합니다.
// src/mocks/handlers.js import { http, HttpResponse } from 'msw' export const handlers = [ // Intercept "GET https://example.com/user" requests... http.get('https://example.com/user', () => { // ...and respond to them using this JSON response. return HttpResponse.json({ id: 'c7b3d8e0-5e0b-4b0f-8b3a-3b9f4b3d3b3d', firstName: 'John', lastName: 'Maverick', }) }), ]
예시와 같이 handler 를 작성합니다. 보시면 아시겠지만, msw 에서 제공하는
http
api 등의 배열로 제공되므로, 나중에 한번에 통합해서 넣을 수도 있습니다. 이 handlers
변수를 mock 서버 실행하는 api 에 인자로 넣어 시작하면 끝입니다. GraphQL 도 지원합니다. // src/mocks/node.js import { setupServer } from 'msw/node' import { handlers } from './handlers' export const server = setupServer(...handlers)
예시와 같이 핸들러 객체를 넣고, 세팅 후 실행하면 됩니다.
- 실제 프로젝트에 적용
위 큰 그림 예시는 동작의 큰 그림을 이해하기 위한 예시일 뿐 Next.js 프로젝트에 그대로 사용할 수 없습니다. 일단 클라이언트 사이드에선 어떻게 적용해야할지, 확인해보겠습니다.
// src/mocks/api/test/index.ts import { HttpResponse, http } from "msw"; const employeeEntity = new Map<string, TestEmployee>(); export type TestEmployeeParams = { empNumber: string; }; export interface TestEmployee { empNumber: string; name: string; age: number; } export interface TestEmployeeRequest { name: string; age: number; } interface TestEmployeeResponse extends TestEmployee {} export const testController = [ http.get< TestEmployeeParams, TestEmployeeRequest, TestEmployeeResponse[], "/emplyee" >("/emplyee", () => { return HttpResponse.json(Array.from(employeeEntity.values())); }), http.post<never, TestEmployeeRequest, TestEmployeeResponse>( "/employee", async ({ request }) => { const newEmployee = await request.json(); const newEmpNumber = employeeEntity.size + 1 + ""; employeeEntity.set(newEmpNumber, { empNumber: newEmpNumber, ...newEmployee, }); return HttpResponse.json(employeeEntity.get(newEmpNumber), { status: 201, }); } ), http.delete<TestEmployeeParams, never, never, "/employee/:empNumber">( "/employee/:empNumber", ({ params }) => { const employee = employeeEntity.get(params.empNumber); if (employee) { employeeEntity.delete(employee.empNumber); return HttpResponse.json(employee); } else { return HttpResponse.json(null, { status: 404 }); } } ), ];
// src/mocks/handlers.ts import { testController } from "./api/test"; export const handlers = [...testController]; // export const handlers = [...testController, ...otherController];
// src/mocks/brower.ts import { setupWorker } from "msw/browser"; import { handlers } from "./handlers"; export const worker = setupWorker(...handlers);
// src/mocks/server.ts import { setupServer } from "msw/node"; import { handlers } from "./handlers"; export const server = setupServer(...handlers);
export async function enableMocking() { if (typeof window === "undefined") { const { server } = await import("./server"); server.listen(); } else { const { worker } = await import("./browser"); return worker.start(); } }
클라이언트 컴포넌트에서 백엔드 api 를 실행 할 때, 다음과 같이
worker
를 실행하는 훅을 만들어, 사용하는 컴포넌트에서 사용합니다."use client"; import { useEffect, useState } from "react"; import { enableMocking } from "."; export function MSWStarter({ children }: { children: React.ReactNode }) { const [enableMSW, setEnableMSW] = useState(false); useEffect(() => { const init = async () => { await enableMocking(); setEnableMSW(true); }; if (!enableMSW) { init(); } }, [enableMSW]); return enableMSW && <>{children}</>; }
export function TestInputForm() const [employees, setEmployees] = useState(); useEffect(() => { const fetch = async () => { const data = await getTestEmployee(); setEmployees(data); }; try { fetch(); } catch (e) {} }, [enabled]); return ( <MSWStarter> <ListWrapper> {employees && employees.map(({ name, age, empNumber }) => ( <TestEmployeeItem key={empNumber} name={name} age={age} empNumber={empNumber} onClickDelete={() => handleDelete(empNumber)} /> ))} </ListWrapper> </MSWStarter> ); }
하지만 위와 같이 클라이언트 컴포넌트를 상위에 사용해버리면, 하위 노드들 또한 클라이언트 컴포넌트로 변경되게 됩니다. 그러므로, 훅을 만들어 놓고, 필요한 클라이언트 컴포넌트에만 훅을 사용하는 것이 좋을 수도 있겠습니다. (
worker.start()
를 매번 호출해서 경고가 뜨는 문제가 있습니다. 이는 추후에 고칠 수 있는 방법을 찾아보겠습니다.)3. 클라이언트 사이드 적용 테스트
테스트 환경은 실제 서버 사이드 환경, 클라이언트 사이드 환경과 다릅니다. 테스트 환경은 클라이언트 컴포넌트를 테스트 하더라도 node 에서 동작합니다. 저는 vitest 를 통해 테스트를 진행하려고 하였으며, 제가 미리 만들어 두었던 클라이언트 컴포넌트를 테스트 하고자 했습니다.
// vitest.config.ts import { defineConfig } from "vitest/config"; import react from "@vitejs/plugin-react"; import path from "path"; export default defineConfig({ plugins: [react()], test: { globals: true, environment: "jsdom", setupFiles: "./vitest.setup.ts", }, resolve: { alias: { "@/*": path.resolve(__dirname, "src/*"), }, }, });
// vitest.setup.ts import "@testing-library/jest-dom"; import { afterAll, afterEach, beforeAll, expect } from "vitest"; import { server } from "./src/mocks/server"; (global as any).expect = expect; // Mock server setup beforeAll(() => server.listen()); afterEach(() => server.resetHandlers()); afterAll(() => server.close());
이후 제가 적용해 두었던 테스트를 실행합니다.
import { describe, expect, it } from "vitest"; import { render, screen } from "@testing-library/react"; import { TestInputForm } from "../../mocks/components/TestInputForm"; import userEvent from "@testing-library/user-event"; describe("MockingTest in client", () => { it("should render correctly", () => { const testInputForm = render(<TestInputForm />); expect(testInputForm).toMatchSnapshot(); }); it("should input correctly", async () => { render(<TestInputForm />); const nameInput = screen.getByPlaceholderText("name"); const ageInput = screen.getByPlaceholderText("age"); const postButton = screen.getByText("POST"); screen.debug(); await userEvent.type(nameInput, "김영수"); await userEvent.type(ageInput, "2"); await userEvent.click(postButton); const item = screen.getByText("사번: 1"); expect(item).toBeInTheDocument(); const deleteButton = screen.getByText("delete"); await userEvent.click(deleteButton); expect(item).not.toBeInTheDocument(); }); });
TestInputForm
컴포넌트 안에서 직접 api 호출을 하며, GET, POST, DELETE api 를 각각 호출하는 것을 테스트 하였습니다. 4. 정리
mock 서버는 클라이언트 사이드에서 보다는 서버 사이드에서가 더 필요할 것 같기는 하지만, 일단 환경 설정은 해두고, 테스트를 해본 것에 의의를 두었습니다.