[Jest] Mocking

Noah·2022년 1월 15일
0

Jest

목록 보기
1/1
post-thumbnail

현재 리팩토링중인 Node/Express 기반의 프로젝트에서 테스트 라이브러리로 Jest/Supertest를 사용 중이다. 문득 Jest에서 제공하는 Mock 기능이 궁금해 글을 쓰기로 했다. 여러 블로그와 공식문서를 참고했고 가장 이해하기 쉬웠던 글을 링크로 남기도록 하겠다.

https://www.daleseo.com/jest-fn-spy-on/
https://medium.com/@rickhanlonii/understanding-jest-mocks-f0046c68e53c

Mocking

Mocking이란 단위 테스트를 작성할 때, 해당 코드가 의존하는 부분을 가짜(mock)로 대체하는 기법이다. 일반적으로 테스트하려는 코드가 의존하는 부분을 직접 생성하기가 너무 부담스러운 경우에 많이 사용된다.

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

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

무엇보다 이런 방식으로 테스트를 작성하게 되면 특정 기능만 분리해서 테스트하겠다는 단위 테스트(Unit Test)의 근본적인 사상에 부합하지 않는다. 즉 단위 테스트란 의존하는 부분에 독립적인 테스트이며 의존하는 부분에 영향을 받지 않도록 해야 한다는 것이다.

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

아래부터는 공식 문서를 참고했다.
https://jestjs.io/docs/mock-functions


Using a mock function

배열의 각 항목에 대한 콜백을 호출하는 forEach 함수의 구현을 테스트한다고 가정해 보자.

function forEach(items, callback) {
  for (let index = 0; index < items.length; index++) {
    callback(items[index]);
  }
}

이 함수를 테스트하기 위해 mock 함수를 사용하고 mock 객체의 상태를 검사하여 예상대로 콜백이 호출되는지 확인할 수 있다.

const mockCallback = jest.fn(x => 42 + x);
forEach([0, 1], mockCallback);

// The mock function is called twice
expect(mockCallback.mock.calls.length).toBe(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 property

모든 mock 함수에는 특별한 mock 속성이 존재한다. 이 속성에는 함수가 호출된 방법과 반환된 함수에 대한 데이터가 보관된다. mock 속성은 또한 각 호출 시마다 this 값을 추적하므로 이를 검사할 수도 있다.

const myMock = jest.fn();

const a = new myMock();
const b = {};
const bound = myMock.bind(b);
bound();

console.log(myMock.mock.instances);
// > [ <a>, <b> ]

이러한 mock 속성의 각 속성은 테스트에서 이러한 함수가 어떻게 호출되고, 인스턴스화되고, 반환되는지 확인하는 데 매우 유용하다.

// The function was called exactly once
expect(someMockFunction.mock.calls.length).toBe(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');

// 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).toEqual('test');

Mock Return Values

mock 함수는 테스트 중에 코드에 테스트 값을 주입하는 데 사용할 수도 있다.

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

mock 함수는 기능적 연속 전달 스타일을 사용하는 코드에서도 매우 효과적이다. 이 스타일로 작성된 코드는 사용 직전에 테스트에 값을 직접 주입하기 위해 마주하는 실제 구성 요소의 동작을 재현하는 복잡한 stubs의 필요성을 피하는 데 도움이 된다.

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를 호출한 다음 모든 사용자를 포함하는 데이터 속성을 반환한다.

import axios from 'axios';

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

export default Users;

이제 실제로 API를 사용하지 않고 이 메서드를 테스트하기 위해 jest.mock() 함수를 사용하여 axios 모듈을 자동으로 mocking 할 수 있다.

모듈을 mocking 하면 테스트에서 주장할 데이터를 반환하는 .get에 대해 mockResolvedValue를 제공할 수 있다. 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

부분적으로 mocking을 수행할 수도 있다. 즉 모듈의 특정 하위 집합을 mock 할 수 있고 나머지 모듈은 실제 구현을 유지할 수 있다는 것이다.

// foo-bar-baz.js
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

또한 단지 반환 값을 mocking 하는 것을 넘어 mock 함수의 구현을 완전히 대체하는 기능 또한 있다. 이것은 jest.fn 또는 mock 함수의 mockImplementationOnce 메서드를 사용하여 수행할 수 있다.

jest.mock('../foo'); // this happens automatically with automocking
const foo = require('../foo');

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

여러 함수 호출이 각각 다른 결과를 생성하도록 mock 함수의 복잡한 동작 만들어야 하는 경우 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

mock 함수가 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'

Mock Names

테스트 실패 시 오류 출력에서 ​​"jest.fn()" 대신 표시될 mock 함수의 이름을 선택적으로 제공할 수도 있다.

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

Custom Matchers

https://jestjs.io/docs/expect 참고하자!

profile
개발 공부는 🌳 구조다…

0개의 댓글