Logo

Vitest의 함수 모킹과 스파잉

올인원(All-in-one) 테스팅 프레임워크 Vitest를 사용하면 다른 라이브러리 설치 없이 바로 mocking과 spying 기능을 쓸 수 있는데요. 그런데 여기서 mocking과 spying을 한국어로 뭐라고 번역해야 모르겠네요. 😅 주변에서 보면 “모킹”과 “스파잉”으로 그냥 영어를 차용해서 쓰고 있는 것 같습니다.

Mocking 이란?

먼저 mocking이 생소하신 분들을 위해서 mocking 대한 기본 개념부터 잡고 들어가는 게 좋을 것 같습니다. mocking은 단위 테스트를 작성할 때, 해당 코드가 의존하는 부분을 가짜(mock)로 대체하는 기법을 말하는데요. 일반적으로 테스트하려는 코드가 의존하는 부분을 직접 생성하기가 너무 부담스러운 경우 mocking이 많이 사용됩니다.

예를 들어, 데이터베이스에서 데이터를 삭제하는 코드에 대한 단위 테스트를 작성할 때, 실제 데이터베이스를 사용한다면 여러 가지 문제점이 발생할 수 있습니다.

  • 데이테베이스 접속과 같이 Network이나 I/O 작업이 포함된 테스트는 실행 속도가 현저히 떨어질 수 밖에 없습니다.
  • 프로젝트의 규모가 켜져서 한 번에 실행해야 할 테스트 케이스가 많이지면 이러한 작은 속도 저하들이 모여 큰 이슈가 될 수 있으며, CI/CD 파이프라인의 일부로 테스트가 자동화되어 자주 실행되야 한다면 더 큰 문제가 될 수 있습니다.
  • 테스트 자체를 위한 코드보다 데이터베이스와 연결을 맺고 트랜잭션을 생성하고 쿼리를 전송하는 코드가 더 길어질 수 있습니다. 즉, 배보다 배꼽이 더 커질 수 있습니다.
  • 만약 테스트 실행 순간 일시적으로 데이터베이스가 오프라인 작업 중이었다면 해당 테스트는 실패하게 됩니다. 따라서 테스트가 인프라 환경에 영향을 받게됩니다. (non-deterministic)
  • 테스트가 종료 직 후, 데이터베이스에서 변경 데이터를 직접 원복하거나 트렌잭션을 rollback 해줘야 하는데 상당히 번거로운 작업이 될 수 있습니다.

무엇보다 이런 방식으로 테스트를 작성하게 되면 특정 기능만 분리해서 테스트하겠다는 단위 테스트(Unit Test)의 본연의 의도에 맞지 않게 되겠죠?

mocking은 이러한 상황에서 실제 객체인 척하는 가짜 객체를 생성하는 매커니즘을 제공합니다. 또한, 테스트가 실행되는 동안 가짜 객체에 어떤 일들이 발생했는지를 기억하기 때문에 가짜 객체가 내부적으로 어떻게 사용되는지 검증할 수 있죠. 결론적으로, mocking을 이용하면 실제 객체를 사용하는 것보다 훨씬 가볍고 빠르게 실행되면서도, 항상 동일한 결과를 내는 테스트를 작성할 수 있습니다.

Mocking 하기

Vitest는 가짜 함수(mock function)를 생성할 수 있도록 vi.fn() 함수를 제공합니다.

import { vi } from "vitest";

const mockFn = vi.fn();

그리고 이 가짜 함수는 일반 자바스크립트 함수와 동일한 방식으로 인자를 넘겨 호출할 수 있습니다.

mockFn();
mockFn(1);
mockFn("a");
mockFn([1, 2], { a: "b" });

가짜 함수는 기본적으로 undefined를 반환합니다.

console.log(mockFn()); // undefiend

가짜 함수가 특정 값을 반환하도록 설정하고 싶다면, mockReturnValue() 함수를 사용하면 됩니다.

mockFn.mockReturnValue("I am a mock!");
console.log(mockFn()); // I am a mock!

mockResolvedValue() 함수를 사용하면 가짜 비동기(async) 함수도 만들 수 있습니다.

mockFn.mockResolvedValue("I will be a mock!");
mockFn().then((result) => {
  console.log(result); // I will be a mock!
});

자바스크립트의 비동기 함수와 Promise에 대한 자세한 내용은 관련 포스팅를 참고 바랍니다.

뿐만 아니라 mockImplementation() 함수를 이용하면 아예 해당 함수를 통째로 즉석해서 재구현해버릴 수도 있습니다.

mockFn.mockImplementation((name) => `I am ${name}!`);
console.log(mockFn("Dale")); // I am Dale!

가짜 함수는 자신이 몇 회 어떻게 인자를 가지고 호출되었는지를 모두 기억합니다. 이 것이 테스트를 작성할 때 가짜 함수가 진짜로 유용한 이유입니다.

mockFn("a");
mockFn(["b", "c"]);

expect(mockFn).toHaveBeenCalledTimes(2);
expect(mockFn).toHaveBeenCalledWith("a");
expect(mockFn).toHaveBeenCalledWith(["b", "c"]);

위와 같이 Vitest는 가짜 함수를 검증하기 위한 전용 매처(matcher) 함수를 제공하고 있습니다. 이 매처 함수들은 toHaveBeenCalled*** 형태의 이름을 가지고 있습니다.

Vitest의 매처 함수에 대해서는 관련 포스팅에서 자세히 다루고 있습니다.

Spying 하기

전쟁 영화를 보면 전직에 아군있는 잠입하여 “몰래” 정보를 캐내는 스파이(spy)라는 직업이 있죠? (물론 현실 속에서 있을 것입니다.)

테스트를 작성할 때도 객체의 특정 함수에 스파이를 심어서 해당 함수가 어덯게 호출되는지를 염탐할 수 있습니다. Vitest는 vi.spyOn(object, methodName) 함수를 통해서 스파잉(spying) 기능을 제공하고 있습니다.

이 기능은 굳이 함수의 구현을 가짜로 대체할 필요까지는 없고 호출 여부와 어떻게 호출되었는지만을 알아내야 할 때 특히 유용합니다.

import { test, expect, vi } from "vitest";

test("spy on", () => {
  const calculator = {
    add: (a: number, b: number) => a + b,
  };

  const spyAdd = vi.spyOn(calculator, "add");

  const result = calculator.add(2, 3);

  expect(spyAdd).toHaveBeenCalledTimes(1);
  expect(spyAdd).toHaveBeenCalledWith(2, 3);
  expect(result).toBe(5);
});

위 예제를 보시면, vi.spyOn() 함수를 이용해서 calculator 객체의 add라는 함수에 스파이를 심고 있습니다. 우리는 이 스파이를 통해서, add 함수를 호출 후에 호출 횟수와 어떤 인자가 넘어 갔는지 검증할 수 있습니다. 하지만 가짜 함수로 대체한 것은 아니기 때문에 여전히 결과 값은 원래 구현대로 23의 합인 5가 되는 것을 알 수 있습니다.

테스트 작성하기

자 그럼 이제 위에서 배운 vi.fn()vi.spyOn() 사용해서 어떻게 테스트를 작성할 수 있는지 알아보겠습니다.

다음 예제 코드는 axios 라이브러리를 이용해서 REST API를 호출하여 사용자 데이터를 조회해주는 함수를 선언하고 있는 모듈입니다. 이번 포스팅에서는 이 findOne() 함수에 대한 테스트를 한 번 작성해보도록 하겠습니다.

userService.ts
import axios from 'axios';

const API_ENDPOINT = 'https://jsonplaceholder.typicode.com';

export function findOne(id: number) {
  return axios
    .get(`${API_ENDPOINT}/users/${id}`)
    .then((response) => response.data);
}

먼저 mocking 없이 findOne()의 결과 값에 대한 단순한 테스트를 작성합니다.

userService.test.ts
import { expect, test } from 'vitest';
import { findOne } from './userService';

test('findOne returns a user', async () => {
  const user = await findOne(1);
  expect(user).toHaveProperty('id', 1);
  expect(user).toHaveProperty('name', 'Leanne Graham');
});

만약에 findOne() 함수가 외부 API 연동을 통해서 사용자 정보를 조회해야하는지를 테스트하려면 어떻게 해야 할까요? 이 함수는 내부적으로 axios 객체의 get 함수를 사용하고 있기 때문에, 여기에 스파이를 붙이면 쉽게 알아낼 수 있습니다.

userService.test.ts
import axios from 'axios';
import { expect, test, vi } from 'vitest';
import { findOne } from './userService';

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

하지만 이 테스트는 API 서버가 다운된 상황이거나 네트워크가 제한된 환경(ex. CI 서버)에서 실행되면 오류가 발생하고 실패할 것입니다. 따라서 위 두 개의 테스트 함수는 “테스트는 deterministic 해야한다. (언제 실행되든 항상 같은 결과를 내야한다.)”라는 원칙에 위배됩니다. 왜냐하면 단위 테스트가 단독으로 고립되어 있지 않고, 외부 환경에 의존하기 때문입니다.

이 문제를 해결하려면, axios 객체의 get 함수가 항상 안정적으로 결과를 반환하도록 mocking 해야 합니다. 즉, 다음과 같이 axios.get를 어떤 고정된 결과값을 리턴하는 가짜 함수로 대체해주면 됩니다.

userService.test.ts
import axios from 'axios';
import { expect, test, vi } from 'vitest';
import { findOne } from './userService';

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

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

이렇게 테스트 입장에서 통제할 수 없는 부분을 모킹해주면 외부 환경에 의존하지 않고도 얼마든지 독립적으로 실행 가능한 테스트를 작성할 수 있습니다.

전체 코드

실습 프로젝트에서 작성한 테스트 코드는 아래에서 직접 확인하고 실행해볼 수 있습니다.

마치면서

지금까지 Vitest가 제공하는 vi.fn()vi.spyOn() 함수의 사용법과 이를 활용하여 어떻게 실제 테스트에서 모킹과 스파잉을 할 수 있는지 알아보았습니다.

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