유닛 테스트 시 MSW를 사용해 API Mocking하기

배경

특정 지역의 날씨 정보를 3rd party API로부터 n분에 한 번씩 가져와 데이터를 가공한 뒤 데이터베이스에 저장하는 작업을 하며 유닛 테스트를 작성할 기회가 있었다.

Fetch 부분의 테스트를 작성하는 과정에서 MSW(Mock Service Worker)를 활용한 경험을 정리하려 한다.

무엇을 테스트할까?

테스트하고자 한 내용은 다음과 같았다.

  1. 요청에 상응하는 응답을 제공하는가? 인자를 올바르게 전달했을 때, 해당 지역의 날씨 정보를 정확히 가져오는지 확인
  2. 잘못된 요청의 처리 : 존재하지 않는 지역의 데이터를 요청하거나, 입력 포맷이 잘못된 경우, 예상한 대로 에러를 처리하는지 검증
  3. 잘못된 응답의 처리 : API Source가 필수 데이터를 누락했을 때, 해당 데이터를 적절히 필터링하는지, 에러 로그를 남기는지 검증

이처럼 외부 데이터의 Fetch 단계에 대해 테스트를 작성하는 과정에서 API 자체의 정확성이나 안정성은 테스트 대상이 아니었다.

왜 API Mocking을 사용할까?

API Mocking은 실제 API를 사용하는 대신, 가상의 API를 설정하여 요청과 해당 파라미터를 확인하고, 그에 따른 응답을 반환하는 방식이다. Mocking 도구를 사용하면 특정 HTTP 요청에 대해 미리 정의된 응답을 반환하도록 구성할 수 있다.

API mocking을 통해 실제 API 서버에 요청을 보내지 않고도 테스트를 진행할 수 있다. 나의 경우,

  • API 정확성이 테스트의 초점이 아니기 때문에 실제 API의 호출은 불필요했고
  • 월간 API 호출 횟수 제한이 있어 불필요한 호출을 피하려는 이유도 있었다

그리고 나의 상황과는 조금 다르지만

  • 백엔드 API가 아직 개발 중일 때 프론트엔드 쪽에서 Mocking을 통해 요청과 응답 흐름을 미리 설정해 테스트를 진행할 수 있기도 하고
  • 실제 서버에서 발생할 수 있는 다양한 에러 상황을 Mocking으로 쉽게 시뮬레이션할 수 있다는 이점도 있다고 한다.

MSW(Mock Service Worker)

Mock 서버를 구성할 때 MSW(Mock Service Worker)라는 모킹 라이브러리를 사용했다. 사용하다보니 일단 서비스 워커가 무엇인지 궁금해졌다.

MDN 에 따르면 서비스 워커는 웹 애플리케이션의 백그라운드에서 동작하는 스크립트로, 자바스크립트 파일 형태를 갖고 있다고 한다.사 용자가 웹 페이지를 떠나도 백그라운드에서 실행되며 오프라인 지원, 캐싱, 푸시 알림, 네트워크 요청 가로채기 및 처리 등의 역할을 한다.

MSW는 서비스 워커가 네트워크 요청을 가로채 처리할 수 있다는 점을 이용하여 가짜 API 서버 역할을 한다. 개발 환경에서 클라이언트가 API 요청을 보낼 때 실제 서버 대신 가상의 서버가 가로챈 요청에 기반한 맞춤 응답을 반환하도록 설정한다. 이를 통해 네트워크 상태, 백엔드 개발 상태와 상관없이 프론트엔드 개발과 테스트가 가능하다.

MSW는 브라우저 환경에서도 사용할 수 있고, node.js 환경에서도 사용할 수 있다. 또한 REST API, GraphQL API, WebSocket API를 모킹할 수 있다.

시작하기

// 설치
npm install --save-dev msw


// 사용하고 싶은 곳에서 import
import { setupServer } from 'msw/node';
import { HttpResponse, http } from 'msw';

Node.js에서 요청 가로채기 : setupServer

요청을 가로채기 위해 setupServer 함수를 사용한다. 서버라는 이름이 들어가 있지만, 실제로 별도의 서버를 생성하지는 않는다.

  1. 기본 설정 예시 : 데이터를 요청할 endpoint와, 해당 endpoint가 응답할 response 값 설정
const server = setupServer(
  http.get('/user', () => {
    return HttpResponse.json({
      id: '123',
      firstName: 'John',
    })
  })
)
  1. request url에 parameter가 존재하는 경우 예시

이 부분은 searchParams 프로퍼티를 사용했다. (이는 MSW에 국한되는 부분은 아니고, JavaScript의 URL 객체에서 사용되는 속성 중 하나다.)

파라미터에 전달되는 값에 따라서 다른 response들을 던져줄 수 있도록, 다양한 response값들을 미리 설정해 주었다.

// URL이 다음과 같이 주어지는 경우
const url = `https://api_url?` + new URLSearchParams({ key: apiKey, q: area, aqi: 'yes' });

// 상황에 맞게 던져줄 response들을 생성
const response = {
  seoul: { wind_direction : 'N', temp_c : -12, ...},
  london:{ wind_direction : 'SE', temp_c : 8, ...},
  tokyo: { wind_direction : 'NW', temp_c : 0, ...}, 
};

//url.searchParams.get('q')와 같이 파라미터에서 원하는 부분을 가져와 응답을 설정
const server = setupServer(
    http.get('https://api_url', ({ request }) => {
      const url = new URL(request.url);
      const param = url.searchParams.get('q');
      
      if (param === 'Seoul') return HttpResponse.json(response.seoul);
      if (param === 'London') return HttpResponse.json(response.london);
      if (param === 'Tokyo') return HttpResponse.json(response.tokyo);
      throw Error('No data exists for the area');
    })
  );


// 테스팅 예시
 it("should fetch right area's data", async () => {
    const output = await fetcher('Seoul')
    expect(output.wind_direction).toEqual('N');
    expect(output.temp_c).toEqual(-12);
  });

 it("should throw error when no data exists for the area", async () => {
    await expect(fetcher('Busan')).rejects.toThrowError("No data exists for the area");
  }); 
  1. 서버가 에러를 반환하는 경우 모킹하는 예시
const server = setupServer(
    http.get('https://api_url', ({ request }) => {
      const url = new URL(request.url);
      return HttpResponse.json({ status: 403, error: { code: 2007 } });
    })
  );

// 테스팅 예시
 it("should return null and log error when server returns error'", async () => {
    const options = { context };
    const loggerErrorSpy = vi.spyOn(options.context.logger, 'error');

    const fetcherFn = fetcher(options);
    const result = await fetcherFn();

    expect(result).toBeNull();
    expect(loggerErrorSpy).toHaveBeenCalled();
  });

테스팅 전, 중, 후 서버 연결 관리

// 현재 프로세스에서 요청을 가로채는 기능 활성화
beforeAll(() => server.listen()); 

// 현재 Node.js 프로세스에서 요청 가로채기를 중지
afterAll(() => server.close());

/* request handler를 초기 목록(setupServer() 함수에 전달된 기본 요청 핸들러)으로 재설정.  Runtime에 추가된 핸들러는 제거한다 
*/

afterEach(() => {
  server.resetHandlers();
});
  

보통 서버 하나로도 대부분의 테스트 시나리오를 커버할 수 있지만, 꼭 여러 개의 서버가 필요한 경우가 있을 수 있다. 만약 하나의 테스트 파일에서 또 다른 서버를 설정하고 실행하고 싶다면, 다음과 같이 기존에 돌아가던 서버를 먼저 닫아주어야 한다.

it('should return null when there is no received data', async () => {
    server.close(); // 기존 서버 종료

    const anotherServer = setupServer(/* 새로운 핸들러 */);
    anotherServer.listen();

    const result = await doSomething();

    expect(result).toBeNull();

    anotherServer.close(); // 새로운 서버 종료
    anotherServer.resetHandlers();
});

테스트마다 동적으로 핸들러 등록하기 : server.use()

테스트마다 요청 처리 로직이 달라질 수 있다면 server.use()를 사용하는 것이 더 권장된다. server.use()로 등록된 핸들러는 초기 핸들러보다 우선하여 동작한다. 그래서 초기 핸들러를 통해 공통적인 동작을 정의하고, 테스트 단위로만 필요한 핸들러를 런타임에 동적으로 추가하거나 수정할 수 있다.

// 초기 핸들러
const server = setupServer(
  http.get('/api/data', () => {
    return HttpResponse.json({ defaultResult: 'Default handler' });
  })
);

beforeAll(() => server.listen());
afterAll(() => server.close());
afterEach(() => server.resetHandlers());

it('should handle default response', async () => {
  const result = await fetcher('/api/data');
  expect(result).toEqual({ defaultResult: 'Default handler' });
});

it('should handle custom response in this test', async () => {
  // 테스트 중 새로운 핸들러 추가
  server.use(
    http.get('/api/data', () => {
      return HttpResponse.json({ customResult: 'Custom handler for this test' });
    })
  );

  const result = await fetcher('/api/data');
  expect(result).toEqual({ customResult: 'Custom handler for this test' });
});

테스트가 끝난 후에는 초기 핸들러로 상태를 복원하기 위해 항상 server.resetHandlers() 를 호출한다. 그래야 이후 테스트에 영향을 미치지 않는다.


MSW 공식문서에는 다양한 Best Practice 예시들이 있다. 이전에 업무를 하며 테스트 코드를 짤 때는 이 부분까지 면밀히 살펴보지 못했었는데, 다양한 상황에서 핸들러를 구성할 수 있는 방법들이 잘 나와 있으니 이후 더 복잡한 테스트가 필요할 때에도 MSW를 활용해볼 수 있을 것 같다.