Please enable JavaScript to view the comments powered by Disqus.

Mock Service Worker로 만드는 모의 서버

Mock Service Worker

MSW(=Mock Service Worker)는 API 모킹(mocking) 라이브러리로, 서버를 향한 네트워크 요청을 가로채서(intercept) 모의 응답(mocked response)을 내려주는 역할을 한다. 이 라이브러리의 장점은 사용자가 정의한 모킹 핸들러를 테스트를 위한 노드(node.js) 환경, 개발과 디버깅을 위한 브라우저 환경에서 모두 사용할 수 있다는 것이다. 이미 구현된 소스 코드를 수정할 일은 없으며, 모킹이 필요한 환경에서만 MSW 인스턴스를 실행해주기만 하면 된다.

MSW가 작동하는 방식

브라우저에 서비스 워커(Service Woker)를 등록하여 fetch 이벤트를 사용해 외부로 나가는 네트워크 리퀘스트를 감지한다. 그리고 그 요청을 실제 서버가 아닌 MSW 클라이언트 사이드 라이브러리로 보낸 후, 등록된 핸들러를 통해 모의 응답을 브라우저로 보낸다. 네트워크 요청 흐름은 아래와 같다.

Mock Service Worker 리퀘스트 흐름도 Mock Service Worker 리퀘스트 흐름도 (이미지 출처: https://mswjs.io/docs/)

서비스 워커는 브라우저에서만 사용 가능하기 때문에 노드 환경에서는 node-request-interceptor로 네이티브 http, https, XMLHttpRequest 모듈을 확장(extending)해서 리퀘스트를 처리한다.

일반적인 모킹 방식

두 가지 방법을 사용한다.

  • 실제 서버를 대체하는 모킹 서버의 제공
  • 네트워크 요청을 가로채기 위해 네이티브 http, https, XMLHttpRequest 모듈을 스텁(stub, 바꿔치기)

모킹 서버는 실제 서버와 다른 방식으로 구현되고 동작하기 때문에 실제 서버를 완전히 대체할 수 없다. 목 서버 구현, 실행 및 관리 외에도 목 서버에서만 다르게 동작하는 버그 때문에 기존 코드를 수정해야 하는 일도 생길 수 있다.

그리고 스텁은 모킹 서버를 구동하는 것보다는 효율적이긴 하다. 하지만 네이티브 모듈을 수정하게 되면 모킹을 하지 않은 환경과 어쨌거나 차이를 만들어내기 때문에 종단간(end-to-end) 테스트에는 좋지 않다.

MSW는 모킹 서버를 사용하지 않고 네이티브 라이브러리를 스텁하지 않는다(Service Worker를 사용할 수 없는 노드 환경에서는 nock 라이브러리처럼 http같은 네트워크 모듈을 monkey patching하는 방식으로 구현한다). 그리고 다른 모킹 라이브러리와 달리 어플리케이션 레벨이 아닌 네트워크 레벨에서 리퀘스트를 가로채서 응답을 보내기 때문에 별도의 설정 없이도 axios, react-query등의 모든 종류의 네트워크 요청 라이브러리와 네이티브 fetch API를 사용할 수 있다는 장점이 있다.

사용 방법

로그인을 위해 서버의 /user 라우트에 HTTP POST 요청을 보낸다고 가정하자. MSW는 네트워크 요청을 처리하기 위해 rest[METHOD] 함수로 핸들러를 정의한다. 여기서는 POST 요청이므로 rest.post 함수를 사용한다.

// handlers.js
import { rest } from 'msw';

export const loginHandler = rest.post(
  'https://api.server.com/user',
  (req, res, ctx) => {
    return res(ctx.json({
			jwt: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c'
		));
  },
);

핸들러 함수의 첫번째 파라미터인 리퀘스트 엔드포인트에는 express 서버처럼 파라미터를 사용할 수 있다(ex. /user/:id). 상세정보 API가 라우트에 고유 아이디를 포함하고 있을 때 유용하다. 핸들러의 두번째 파라미터인 콜백 함수(=response resolver)에서 라우트 파라미터에 접근하는 것도 물론 가능하다.

정의한 핸들러로 워커 인스턴스를 생성한다.

import { setupWorker } from 'msw' // node 환경에서는 setupServer 모듈을 대신 사용
import * as handlers from './handlers'

export const worker = setupServer(...handlers)

이제 서버 인스턴스를 실행하면 된다.

worker.start()

실제 환경별 사용 방법은 아래 링크를 통해 확인할 수 있다.

https://mswjs.io/docs/getting-started/integrate

활용 사례

브라우저 서비스 워커에 등록하기

https://mswjs.io/docs/getting-started/integrate/browser

브라우저에서 사용하기 위해서는 MSW를 서비스 워커에 등록하는 과정이 필요하다. 고맙게도 그 과정은 MSW 라이브러리에서 직접 제공한다. 커맨드 라인에서 아래의 명령어를 실행하면 서비스 워커 등록을 위한 파일이 public 폴더에 추가된다.

npx msw init public/ --save

public은 웹 서버가 호스팅하는 폴더로 index.html 파일이 있는 곳을 가리킨다. 보통 build, public, dist 등의 이름을 사용하는데 create-react-app, next.js를 사용하고 있다면 프로젝트 소스 최상위에 있는 public 폴더가 그것에 해당한다.

이제 앱의 소스에 워커를 실행하는 코드를 추가한다.

// src/index.js
import React from 'react'
import ReactDOM from 'react-dom'
import App from './App'

// 개발 환경에서만 실행되도록 환경 변수를 확인하는 과정이 필요하다.
if (process.env.NODE_ENV === 'development') {
  const { worker } = require('./mocks/worker')
  worker.start()
}

ReactDOM.render(<App />, document.getElementById('root'))

이제 개발 서버에서 실행된 앱이 서버에 리퀘스트를 보낼 때 실제 서버가 아닌 MSW에서 응답을 보낼 수 있게 되었다.

Jest를 사용하는 테스트 환경에서 사용하기

https://mswjs.io/docs/getting-started/integrate/node

모든 테스트 케이스에서 msw가 동작하도록 전역에 설정하는 방법과 테스트 케이스마다 직접 설정하는 방법이 있다.

전역 설정

Jest 설정에서 setupFilesAfterEnv에 할당한 모듈의 코드는 각각의 테스트 파일을 실행하기에 앞서서 먼저 실행된다.

// jest.config.js
module.exports = {
  setupFilesAfterEnv: ['./jest.setup.js'],
}

이제 jest.setup.js 파일에 모의 서버를 설정하는 코드를 작성하면 된다. 테스트는 node 환경에서 실행할 것이므로 setupWorker 대신 setupServer 함수를 사용해야 한다.

// jest.setup.js
import { setupServer } from 'msw/node'
import * as handlers from './handlers'

const server = setupServer(...handlers)

// 테스트 시작 전에 서버를 실행한다.
beforeAll(() => server.listen())

// 테스트가 끝날 때마다 리퀘스트 핸들러를 초기화하여 서로 영향을 미치지 않도록 한다.
afterEach(() => server.resetHandlers())

// 테스트가 완료되면 서버를 종료한다.
afterAll(() => server.close())

테스트 케이스에 직접 설정

테스트 파일에서 서버 인스턴스를 만드는 것 외에 전역 설정과 큰 차이는 없다. 테스트에 필요한 핸들러만 추가하면 된다는 장점이 있다.

import { setupServer } from 'msw/node'
import { listRequestHandler, detailRequestHandler } from './handlers'

describe('sample test suite', () => {
  const server = setupServer(
		listRequestHandler,
		detailRequestHandler,
  );

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

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

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

	test('check response of mock server', () => {
     // write test
	})
})

쿼리 파라미터 가져오기

https://mswjs.io/docs/recipes/query-parameters

테스트마다 리퀘스트 파라미터에 따라 다른 리스펀스를 보내줘야 하는 경우가 있다. 핸들러에서 req 객체를 통해 파라미터에 접근 가능하다.

export const productDetailHandler = rest.get('/products', (req, res, ctx) => {
  const id = req.url.searchParams.get('id');

  return res(ctx.json({ productId: id }));
});

Response patching

https://mswjs.io/docs/recipes/response-patching

모의 응답(mocked response)를 실제 리퀘스트의 응답에 기반한 데이터로 구성할 수도 있다. 핸들러에서 실제 서버에 리퀘스트를 보낸 후 받은 데이터에 디버깅 등에 필요한 정보를 임의로 덧붙이는 방식이다.

아래는 Github API를 사용한 리스펀스 패칭 예제다.

import { setupWorker, rest } from 'msw'

const worker = setupWorker(
  rest.get('https://api.github.com/users/:username', async (req, res, ctx) => {
    // msw 핸들러에 가로챈 리퀘스트 정보로 살제 서버에 요청을 보낸다.
    // msw에서 정의한 핸들러에 매칭되는 것을 방지하기 위해 window.fetch가 아닌 ctx.fetch를 대신 사용한다.
    const originalResponse = await ctx.fetch(req)
    const originalResponseData = await originalResponse.json()

    return res(
      ctx.json({
        location: originalResponseData.location,
        firstName: 'Not the real first name', 
      }),
    )
  }),
)

worker.start()