목 함수

목 함수 (Mock Functions)

기능이 실제로 완성되기 전에 함수 사이의 연결을 테스트하고 싶은 때가 있어요. **목 함수(mock function)**는 실제 구현을 지워 버리고, 그 함수가 어떻게 호출됐는지(어떤 인자와 함께)를 기록하며, new로 생성자 함수가 인스턴스화될 때의 인스턴스까지 잡아 주고, 테스트 중에 반환값을 마음대로 설정할 수 있게 해 줘요. 이 덕분에 의존 컴포넌트 없이도 함수 간의 연결이 제대로 이어지는지만 추적할 수 있죠.

목 함수를 만드는 방법은 두 가지예요. 테스트 코드 안에서 직접 목 함수를 만들거나, manual mock을 작성해 모듈 의존성을 대체하는 거죠. 이 글에서는 주로 전자를 다룰게요.

출처: Jest 공식 문서 — Mock Functions

목 함수 사용하기

배열의 각 항목에 대해 콜백을 호출하는 forEach 함수를 테스트한다고 가정해 볼게요.

export function forEach(items, callback) {
  for (const item of items) {
    callback(item);
  }
}

이 함수를 검증할 때는 목 함수를 만들어서, 콜백이 기대한 대로 호출됐는지 목의 상태를 살펴보면 돼요.

import {forEach} from './forEach';

const mockCallback = jest.fn(x => 42 + x);

test('forEach mock function', () => {
  forEach([0, 1], mockCallback);

  // The mock function was called twice
  expect(mockCallback.mock.calls).toHaveLength(2);

  // The first argument of the first call to the function was 0
  expect(mockCallback.mock.calls[0][0]).toBe(0);

  // The first argument of the second call to the function was 1
  expect(mockCallback.mock.calls[1][0]).toBe(1);

  // The return value of the first call to the function was 42
  expect(mockCallback.mock.results[0].value).toBe(42);
});

.mock 프로퍼티

모든 목 함수에는 특별한 .mock 프로퍼티가 있어요. 이곳에 함수가 어떻게 호출됐는지, 무엇을 반환했는지에 대한 데이터가 저장되죠. .mock은 호출마다의 this 값도 추적하므로, 이 값까지 확인할 수 있어요.

const myMock1 = jest.fn();
const a = new myMock1();
console.log(myMock1.mock.instances);
// > [ <a> ]

const myMock2 = jest.fn();
const b = {};
const bound = myMock2.bind(b);
bound();
console.log(myMock2.mock.contexts);
// > [ <b> ]

이 멤버들은 테스트에서 함수가 어떻게 호출·인스턴스화·반환됐는지를 단언하는 데 아주 유용해요.

// The function was called exactly once
expect(someMockFunction.mock.calls).toHaveLength(1);

// The first arg of the first call to the function was 'first arg'
expect(someMockFunction.mock.calls[0][0]).toBe('first arg');

// The second arg of the first call to the function was 'second arg'
expect(someMockFunction.mock.calls[0][1]).toBe('second arg');

// The return value of the first call to the function was 'return value'
expect(someMockFunction.mock.results[0].value).toBe('return value');

// The function was called with a certain `this` context: the `element` object.
expect(someMockFunction.mock.contexts[0]).toBe(element);

// This function was instantiated exactly twice
expect(someMockFunction.mock.instances.length).toBe(2);

// The object returned by the first instantiation of this function
// had a `name` property whose value was set to 'test'
expect(someMockFunction.mock.instances[0].name).toBe('test');

// The first argument of the last call to the function was 'test'
expect(someMockFunction.mock.lastCall[0]).toBe('test');

목 반환값 (Mock Return Values)

목 함수는 테스트 중에 코드에 테스트 값을 주입하는 데도 쓰여요.

const myMock = jest.fn();
console.log(myMock());
// > undefined

myMock.mockReturnValueOnce(10).mockReturnValueOnce('x').mockReturnValue(true);

console.log(myMock(), myMock(), myMock(), myMock());
// > 10, 'x', true, true

목 함수는 특히 함수형 continuation-passing 스타일로 작성된 코드에서 아주 효과적이에요. 실제 컴포넌트의 동작을 재현하는 복잡한 스텁을 만들 필요 없이, 값이 쓰이기 직전에 테스트로 직접 값을 주입할 수 있으니까요.

const filterTestFn = jest.fn();

// Make the mock return `true` for the first call,
// and `false` for the second call
filterTestFn.mockReturnValueOnce(true).mockReturnValueOnce(false);

const result = [11, 12].filter(num => filterTestFn(num));

console.log(result);
// > [11]
console.log(filterTestFn.mock.calls[0][0]); // 11
console.log(filterTestFn.mock.calls[1][0]); // 12

실무에서는 보통 의존 컴포넌트에서 목 함수를 꺼내와서 그것을 설정하는 경우가 많지만, 기법 자체는 동일해요. 이때 직접 테스트하는 함수가 아닌 곳에는 로직을 넣지 않도록 주의하세요.

모듈 목킹 (Mocking Modules)

API에서 사용자를 불러오는 클래스가 있고, 그 클래스가 axios로 API를 호출한 뒤 사용자들이 담긴 data 속성을 반환한다고 해 볼게요.

import axios from 'axios';

class Users {
  static all() {
    return axios.get('/users.json').then(resp => resp.data);
  }
}

export default Users;

실제 API를 때리지 않고(느리고 깨지기 쉬운 테스트를 만들지 않고) 이 메서드를 검증하려면, jest.mock(...)으로 axios 모듈을 자동 목킹하면 돼요. 모듈을 목킹한 뒤에는 .getmockResolvedValue를 제공해, 테스트가 단언할 데이터를 반환하게 할 수 있어요. 즉 axios.get('/users.json')이 가짜 응답을 반환하라고 지정하는 셈이죠.

import axios from 'axios';
import Users from './users';

jest.mock('axios');

test('should fetch users', () => {
  const users = [{name: 'Bob'}];
  const resp = {data: users};
  axios.get.mockResolvedValue(resp);

  // or you could use the following depending on your use case:
  // axios.get.mockImplementation(() => Promise.resolve(resp))

  return Users.all().then(data => expect(data).toEqual(users));
});

부분 목킹 (Mocking Partials)

모듈의 일부만 목킹하고 나머지는 실제 구현을 유지할 수도 있어요.

export const foo = 'foo';
export const bar = () => 'bar';
export default () => 'baz';
//test.js
import defaultExport, {bar, foo} from '../foo-bar-baz';

jest.mock('../foo-bar-baz', () => {
  const originalModule = jest.requireActual('../foo-bar-baz');

  //Mock the default export and named export 'foo'
  return {
    __esModule: true,
    ...originalModule,
    default: jest.fn(() => 'mocked baz'),
    foo: 'mocked foo',
  };
});

test('should do a partial mock', () => {
  const defaultExportResult = defaultExport();
  expect(defaultExportResult).toBe('mocked baz');
  expect(defaultExport).toHaveBeenCalled();

  expect(foo).toBe('mocked foo');
  expect(bar()).toBe('bar');
});

목 구현 (Mock Implementations)

반환값을 지정하는 걸 넘어서, 목 함수의 구현 자체를 통째로 바꿔야 할 때도 있어요. 그럴 땐 jest.fn 또는 목 함수의 mockImplementationOnce 메서드를 사용하면 됩니다.

const myMockFn = jest.fn(cb => cb(null, true));

myMockFn((err, val) => console.log(val));
// > true

다른 모듈에서 만들어진 목 함수의 기본 구현을 정의해야 할 때는 mockImplementation이 유용해요.

module.exports = function () {
  // some implementation;
};
jest.mock('../foo'); // this happens automatically with automocking
const foo = require('../foo');

// foo is a mock function
foo.mockImplementation(() => 42);
foo();
// > 42

여러 번 호출될 때 서로 다른 결과를 내는 복잡한 동작을 재현해야 한다면 mockImplementationOnce를 사용해요.

const myMockFn = jest
  .fn()
  .mockImplementationOnce(cb => cb(null, true))
  .mockImplementationOnce(cb => cb(null, false));

myMockFn((err, val) => console.log(val));
// > true

myMockFn((err, val) => console.log(val));
// > false

mockImplementationOnce로 정의한 구현을 모두 소진하면, jest.fn으로 설정한 기본 구현(있다면)이 실행돼요.

const myMockFn = jest
  .fn(() => 'default')
  .mockImplementationOnce(() => 'first call')
  .mockImplementationOnce(() => 'second call');

console.log(myMockFn(), myMockFn(), myMockFn(), myMockFn());
// > 'first call', 'second call', 'default', 'default'

체이닝되는 메서드라 항상 this를 반환해야 하는 경우에는, 모든 목에 있는 .mockReturnThis() 함수로 간단히 처리할 수 있어요.

const myObj = {
  myMethod: jest.fn().mockReturnThis(),
};

// is the same as

const otherObj = {
  myMethod: jest.fn(function () {
    return this;
  }),
};

목 이름 (Mock Names)

목 함수에 이름을 붙여 두면, 테스트 에러 출력에서 'jest.fn()' 대신 그 이름이 표시돼요. 에러를 보고한 목 함수를 빠르게 식별하고 싶다면 .mockName()을 사용하세요.

const myMockFn = jest
  .fn()
  .mockReturnValue('default')
  .mockImplementation(scalar => 42 + scalar)
  .mockName('add42');

커스텀 매처 (Custom Matchers)

마지막으로, 목 함수가 어떻게 호출됐는지를 더 쉽게 단언하도록 몇 가지 커스텀 매처가 준비돼 있어요.

// The mock function was called at least once
expect(mockFunc).toHaveBeenCalled();

// The mock function was called at least once with the specified args
expect(mockFunc).toHaveBeenCalledWith(arg1, arg2);

// The last call to the mock function was called with the specified args
expect(mockFunc).toHaveBeenLastCalledWith(arg1, arg2);

// All calls and the name of the mock is written as a snapshot
expect(mockFunc).toMatchSnapshot();

이 매처들은 .mock 프로퍼티를 들여다보는 흔한 형태를 간편하게 만든 거예요. 더 세밀한 검사가 필요하다면 언제든 직접 작성할 수 있죠.

// The mock function was called at least once
expect(mockFunc.mock.calls.length).toBeGreaterThan(0);

// The mock function was called at least once with the specified args
expect(mockFunc.mock.calls).toContainEqual([arg1, arg2]);

// The last call to the mock function was called with the specified args
expect(mockFunc.mock.calls[mockFunc.mock.calls.length - 1]).toEqual([
  arg1,
  arg2,
]);

// The first arg of the last call to the mock function was `42`
// (note that there is no sugar helper for this specific of an assertion)
expect(mockFunc.mock.calls[mockFunc.mock.calls.length - 1][0]).toBe(42);

// A snapshot will check that a mock was invoked the same number of times,
// in the same order, with the same arguments. It will also assert on the name.
expect(mockFunc.mock.calls).toEqual([[arg1, arg2]]);
expect(mockFunc.getMockName()).toBe('a mock name');

더 알아보기

매처의 전체 목록은 expect 레퍼런스 문서에서 확인해요. 모듈 전체를 대체하는 방법이 궁금하다면 Manual Mocks를, 목 함수 API의 모든 세부 사항은 Mock Function API를 참고하세요.