Logo

Jest의 jest.mock()을 이용한 모듈 모킹

지난 포스팅에서 jest.fn()jest.spyOn() 함수를 어떻게 사용하는지 배웠습니다. 이번 포스팅에서는 Jest에서 제공하는 또 다른 모킹 함수인 jest.mock()를 활용해서 좀 더 다양한 상황에서 모킹을 해보도록 하겠습니다.

자바스크립트 모듈

먼저 자바스크립트에서 모듈이 무엇인지에 대해서 간단하게 개념만 짚고 넘어가겠습니다. 모듈이란 어떤 코드를 다른 자바스크립트 파일에서 불러오기 편하도록 하나의 파일에 모아둔 것을 뜻하는데요. 프로젝트의 규모가 커지면 모든 자바스크립트 코드를 하나의 파일에 두기 어렵기 때문에, 자연스럽게 코드가 여러 자바스크립트 파일로 나눠지게 됩니다.

이러한 모듈 파일들은 해당 프로젝트의 내부 디렉터리에 라이브러리로 존재할 수도 있고, npm을 통해 설치 후에 사용할 수 있는 외부 패키지가 될 수도 있는데요. 어떤 형태가 되었든 이러한 모듈 파일들은 결국은 CommonJS의 require나 ES6의 import 키워드를 통해서 다른 자바스크립트 파일에서 불러내어 사용되게 됩니다.

const moment = require("moment");
const myLib = require("./myLib");
import moment from "moment";
import myLib from "./myLib";

자바스크립트 모듈에 대한 좀 더 자세한 설명은 아래 관련 포스팅를 참고 바라겠습니다.

테스트 대상 모듈

먼저 실습을 위해 간단하게 테스트의 대상이 될 간단한 자바스크립트 모듈 두 개를 작성해보겠습니다.

이메일과 문자를 보낼 때 사용하는 messageService라는 자바스크립트 모듈이 있다고 가정해보겠습니다. 이렇게 외부 매체를 통해 메시지를 보내는 작업은 애플리케이션에서 수시로 일어날 수 있지만, 단위 테스트 측면에서는 모킹 기법 없이는 처리가 매우 끼다로운 대표적인 케이스 중 하나입니다. 왜냐하면, 일반적으로 이메일과 문자는 외부 서비스를 이용하는 경우가 많아서 테스트 실행 시 마다 불필요한 과금 발생할 수 있고, 해당 외부 서비스에 장애가 발생하면 관련 테스트가 모두 깨지는 불상사가 발생할 수 있기 때문입니다.

따라서 어차피 앞으로 테스트를 작성하면서 이 모듈은 모킹을 할 것이기 때문에, messageService 모듈의 내 함수의 실제 구현은 주석 처리 하겠습니다.

messageService.js
export function sendEmail(email, message) {
  /* 이메일 보내는 코드 */
}

export function sendSMS(phone, message) {
  /* 문자를 보내는 코드 */
}

그리고 위 모듈을 사용해서 회원 가입 또는 탈퇴 시 이메일과 문자를 보내는 userService라는 자바스크립트 모듈이 있다고 가정해보겠습니다. DB에 회원 레코드를 추가하거나 삭제하는 부분은 본 포스팅에서 관심을 두는 부분이 아니기 때문에 주석 처리 하였습니다. 중요한 것은 userService 모듈에서 messageService 모듈의 sendEmailsendSMS을 임포트하여 사용하고 있다는 점입니다.

userService.js
import { sendEmail, sendSMS } from "./messageService";

export function register(user) {
  /* DB에 회원 추가 */
  const message = "회원 가입을 환영합니다!";
  sendEmail(user.email, message);
  sendSMS(user.phone, message);
}

export function deregister(user) {
  /* DB에 회원 삭제 */
  const message = "탈퇴 처리 되었습니다.";
  sendEmail(user.email, message);
  sendSMS(user.phone, message);
}

다음 섹션부터 userService 모듈를 테스트 하기 위해서 messageService 모듈을 모킹해보도록 하겠습니다.

jest.fn()을 이용한 모듈 모킹

이제 userService가 회원 가입 또는 탈퇴 처리 시, 이메일과 문자를 보내기 위해서 messageService 모듈의 함수를 호출하는지 검증하는 테스트 코드를 작성하려고 합니다. 여기서 우리는 messageService 모듈의 sendEmail 함수와 sendSMS 함수를 목(mock) 함수로 대체를 해야합니다. 왜냐하면 실제로 이메일이나 문자를 보낼 의도가 없고, 단순히 userService가 제대로 호출을 하는지 여부만 알면 되기 때문입니다.

이 상황에서 테스트를 작성할 때 많이 하는 실수가 임포트한 함수를 저장하고 있는 변수에 목(mock) 함수를 바로 할당하려고 하는 것입니다.

import { sendEmail, sendSMS } from "./messageService";

sendEmail = jest.fn(); // "sendEmail" is read-only.
sendSMS = jest.fn(); // "sendSMS" is read-only.

이 방법은 자바스크립트 문법을 위반하기 때문에 컴파일 에러가 발생하는 것을 볼 수 있으실 겁니다. 왜냐하면 import 키워드로 불러오기 된 함수들은 기본적으로 const 변수이기 때문에 한 번 초기화되면 다른 값으로 변경이 불가능하기 때문입니다.

조금은 억지스럽지만 차선책으로 messageService 모듈의 모든 함수를 하나의 객체로 불러오면, 객체의 속성으로 목(mock) 함수를 할당할 수 있습니다.

userService1.test.js
import { register, deregister } from "./userService";
import * as messageService from "./messageService";

messageService.sendEmail = jest.fn();
messageService.sendSMS = jest.fn();

const sendEmail = messageService.sendEmail;
const sendSMS = messageService.sendSMS;

beforeEach(() => {
  sendEmail.mockClear();
  sendSMS.mockClear();
});

const user = {
  email: "test@email.com",
  phone: "012-345-6789",
};

test("register sends messages", () => {
  register(user);

  expect(sendEmail).toHaveBeenCalledTimes(1);
  expect(sendEmail).toHaveBeenCalledWith(user.email, "회원 가입을 환영합니다!");

  expect(sendSMS).toHaveBeenCalledTimes(1);
  expect(sendSMS).toHaveBeenCalledWith(user.phone, "회원 가입을 환영합니다!");
});

test("deregister sends messages", () => {
  deregister(user);

  expect(sendEmail).toHaveBeenCalledTimes(1);
  expect(sendEmail).toHaveBeenCalledWith(user.email, "탈퇴 처리 되었습니다.");

  expect(sendSMS).toHaveBeenCalledTimes(1);
  expect(sendSMS).toHaveBeenCalledWith(user.phone, "탈퇴 처리 되었습니다.");
});

이렇게 jest.fn()(또는 jest.spyOn())함수를 이용해서 모듈을 모킹 하려고 하면 불필요하게 처리가 까다로워지는 경우가 많습니다. 예를 들어, messageService 모듈에서 제공하는 함수가 엄청 많다면 어떨까요? 일일이 jest.fn()으로 모든 함수를 모킹하는 작업이 매우 번겨로울 것입니다.

지난 포스팅에서 다뤘듯이 jest.fn()jest.spyOn() 함수를 사용해서 함수 하나 하나를 모킹하는 것은 그다지 어렵지 않습니다. 하지만 여러 모듈을 임포트해서 사용하고 있는 코드에 대한 테스트를 작성하다보면 단순히 함수 하나를 모킹하기 보다는 하나의 모듈 전체를 모킹하는 편이 더 유용한 경우가 많습니다.

jest.mock()을 이용한 모듈 모킹

Jest는 이렇게 까다로울 수 있는 모듈 모킹을 좀 더 편하게 할 수 있도록 jest.mock()이라는 강력한 함수를 제공합니다. 이 함수는 자동으로 모듈을 모킹을 해주기 때문에 위와 같이 직접 일일이 모킹을 해줄 필요가 없습니다.

예를 들어, 이전 섹션에서 작성했던 테스트 코드를 jest.mock()을 이용해서 작성하면 다음과 같습니다. jest.mock() 함수는 첫 번째 인자로 넘어온 모듈 내의 모든 함수를 자동으로 목(mock) 함수로 바꿔줍니다.

userService2.test.js
import { register, deregister } from "./userService";
import { sendEmail, sendSMS } from "./messageService";

jest.mock("./messageService");

beforeEach(() => {
  sendEmail.mockClear();
  sendSMS.mockClear();
});

const user = {
  email: "test@email.com",
  phone: "012-345-6789",
};

test("register sends messages", () => {
  register(user);

  expect(sendEmail).toHaveBeenCalledTimes(1);
  expect(sendEmail).toHaveBeenCalledWith(user.email, "회원 가입을 환영합니다!");

  expect(sendSMS).toHaveBeenCalledTimes(1);
  expect(sendSMS).toHaveBeenCalledWith(user.phone, "회원 가입을 환영합니다!");
});

test("deregister sends messages", () => {
  deregister(user);

  expect(sendEmail).toHaveBeenCalledTimes(1);
  expect(sendEmail).toHaveBeenCalledWith(user.email, "탈퇴 처리 되었습니다.");

  expect(sendSMS).toHaveBeenCalledTimes(1);
  expect(sendSMS).toHaveBeenCalledWith(user.phone, "탈퇴 처리 되었습니다.");
});

여기서 jest.mock('./messageService') 호출이 userService 모듈을 임포트 하기 전에 일어나야된느 것 아니냐고 생각하시는 분들이 있을 것입니다. 먼저 messageService 모듈을 모킹을 해놔야지 userService 모듈이 불러오는 시점에 목 함수들이 사용될 것이기 때문입니다. 사실 Jest는 jest.mock() 함수를 호출을 테스트 파일의 맨 위로 자동으로 hoist 시켜 주기 때문에 jest.mock()의 호출 위치은 크게 걱정하지 않으셔셔도 됩니다.

jest.mock()을 이용한 외부 모듈 모킹

아래 코드는 지난 포스팅에 작성했던 내부적으로 axios라는 NPM 패키지를 사용하고 있는 userService에 대한 테스트 코드입니다. 첫번째 테스트 케이스는 axios 객체의 get 함수의 호출 이력을 추적하기 위해서 jest.spyOn() 함수를 사용하고 있고, 두번째 테스트 케이스는 실제 API 호출하지 않고, 임의의 데이터를 리졸브(resolve)하도록 jest.fn() 함수를 이용해서 axios.get 함수를 모킹하고 있습니다.

const axios = require("axios");
const userService = require("./userService");

test("findOne fetches data from the API endpoint", async () => {
  const spyGet = jest.spyOn(axios, "get");
  await userService.findOne(1);
  expect(spyGet).toHaveBeenCalledTimes(1);
  expect(spyGet).toHaveBeenCalledWith(
    `https://jsonplaceholder.typicode.com/users/1`
  );
});

test("findOne returns what axios get returns", async () => {
  axios.get = jest.fn().mockResolvedValue({
    data: {
      id: 1,
      name: "Dale Seo",
    },
  });

  const user = await userService.findOne(1);
  expect(user).toHaveProperty("id", 1);
  expect(user).toHaveProperty("name", "Dale Seo");
});

jest.mock() 함수를 사용해서 위 두 개의 테스트 케이스를 하나의 테스트 케이스로 재작성 해보겠습니다. axios 모듈 전체를 모킹해버리면 get을 포함한 axios 모든 함수가 목 함수로 자동으로 대체되기 때문에, 이미 목 함수가 되어버린 axios.get 함수가 임의의 데이터를 리졸브(resolve)하도록 처리만 해주면, 호출 이력까지 추가 설정없이 기본적으로 제공됩니다.

const axios = require("axios");
const userService = require("./userService");

jest.mock("axios");

test("findOne fetches data from the API endpoint and returns what axios get returns", async () => {
  axios.get.mockResolvedValue({
    data: {
      id: 1,
      name: "Dale Seo",
    },
  });

  const user = await userService.findOne(1);

  expect(user).toHaveProperty("id", 1);
  expect(user).toHaveProperty("name", "Dale Seo");
  expect(axios.get).toHaveBeenCalledTimes(1);
  expect(axios.get).toHaveBeenCalledWith(
    `https://jsonplaceholder.typicode.com/users/1`
  );
});

전체 코드

Edit jest-mock-modules

마치면서

이상으로 jest.mock() 함수를 이용해서 내부 모듈과 외부 모듈을 모킹하는 방법에 대해서 알아보았습니다.

Jest에 연관된 포스팅은 Jest 태그를 통해서 쉽게 만나보세요!