Mocking에 대하여

박찬욱·2023년 10월 12일
0

TIL

목록 보기
19/21

🤔Mocking이 뭐야?

Mocking은 테스트를 독립시키기 위해 의존성을 개발자가 컨트롤하고 검사할 수 있는 오브젝트로 변환하는 테크닉입니다.

말로만 들으면 쉽게 다가오지 않는다.
여기서 중요하게 봐야할 부분은 테스트의 독립의존성이다.
테스트는 모듈별로 독립적으로 이뤄져야하며 모듈끼리 테스트에 간섭해서는 안된다.
오늘 Mocking에 대해 공부하면서 해결해야할 코드를 잠깐 보고가자.

class ProductClient {
  fetchItems() {
    return fetch('http://example.com/login/id+password').then((response) =>
      response.json()
    );
  }
}

module.exports = ProductClient;
const ProductClient = require('./product_client');
class ProductService {
  constructor() {
    this.productClient = new ProductClient();
  }

  fetchAvailableItems() {
    return this.productClient.fetchItems().then((items) => items.filter((item) => item.available));
  }
}

module.exports = ProductService;

현재 코드를 보면 ProductService는 생성자나 setter를 통해 의존성을 주입받는 것이 아니라 ProductClient에 의존하고 있다. 이렇게 되면 발생할 수 있는 문제점이 있다.

만일 ProductService를 테스트하고 있는데 ProductClient의 fetch에서 문제가 발생한다면?

나는 ProductService의 기능 즉, 데이터를 받아와서 아이템을 잘 필터링하는지만 테스트하고 싶은데...😢 이거 왠 이상한 곳에서 테스트를 실패하게되니 난감일 것이다. 그렇기 때문에 모듈을 분리해서 테스트해야한다.

const doAdd = (a, b, callback) => {
  callback(a + b);
};

test('calls callback with arguments added', () => {
  const mockCallback = jest.fn();
  doAdd(1, 2, mockCallback);
  expect(mockCallback).toHaveBeenCalledWith(3);
});

보통은 이렇게 callback함수를 mock함수로 만들어서 외부로부터 주입하는 방식을 사용한다. 이 방법이 훨씬 견고한 테스트방법이지만 위의 사례와 같이 종종 그렇지 못한 경우가 발생한다.

그래서 Mocking이란 독립적인 테스트를 위한 모듈간의 의존성을 분리하기 위한 대체함수를 만드는 것이라고 생각하면 된다. 여기서 대체라는 단어를 기억하자.


Mocking하는 방법

의존성이 강하게 결합되어있는 경우에 어떻게 Mocking을 통해서 대체할 수 있는지 확인해보자.

우선 예제코드이다.

// math.js
export const add = (a, b) => a + b;
export const subtract = (a, b) => b - a;
export const multiply = (a, b) => a * b;
export const divide = (a, b) => b / a;
// app.js
import * as math from './math.js';

export const doAdd = (a, b) => math.add(a, b);
export const doSubtract = (a, b) => math.subtract(a, b);
export const doMultiply = (a, b) => math.multiply(a, b);
export const doDivide = (a, b) => math.divide(a, b);

다음과 같이 app.js는 math.js에 의존하고 있다.
간단한 의존 확인법은 모듈을 import해서 직접 사용하고 있는지 확인하면 된다.

1. jest.fn으로 Mocking하기

우선 널리 사용되는 방법은 뒤에 나온다.
일단 이 방법은 일일히 함수 하나하나 Mocking해야한다는 번거로움이 있다.

import * as app from './app';
import * as math from './math';

// 1. jest.fn으로 Mocking하기
describe('jest.fn', () => {
  math.add = jest.fn();
  math.subtract = jest.fn();

  test('calls math.add', () => {
    app.doAdd(1, 2);
    expect(math.add).toHaveBeenCalledWith(1, 2);
  });

  test('calls math.subtract', () => {
    app.doSubtract(1, 2);
    expect(math.subtract).toHaveBeenCalledWith(1, 2);
  });
});

😀일반적으로 jest.mock이나 jest.spyOn은 자동적으로 모듈의 모든 함수를 Mocking해주기 때문에 더 널리 사용된다.

2. jest.mock으로 Mocking하기

주로 자주 사용되는 방법은 자동적으로 모듈이 exports하는 모든 것들을 Mocking해주는 jest.mock을 사용하는 것이다.

즉, jest.mock("파일경로")를 하면 해당 경로에 있는 모듈의 모든 함수를 mocking하는 것이다.

일일히 jest.fn을 하는 것보다 훨씬 편하다!

import * as app from './app';
import * as math from './math';

describe('jest.mock', () => {
  jest.mock('./math.js');

  test('calls math.add', () => {
    app.doAdd(1, 2);
    expect(math.add).toHaveBeenCalledWith(1, 2);
  });

  test('calls math.subtract', () => {
    app.doSubtract(1, 2);
    expect(math.subtract).toHaveBeenCalledWith(1, 2);
  });
});

😢하지만 이 방법의 단점은 모듈의 원래 구현에 접근하기 어렵다는 것이다.

Mock함수는 대체함수이다. 즉, 기존의 원래 구현된 함수를 대체하는 함수이기 때문에 이렇게 모듈의 모든 함수를 대체함수로 대체해버리게되면 원래 구현함수의 접근하기 당연히 어렵다.

이를 해결해줄 수 있는 것이 바로 spy이다.

3. spyOn으로 Mocking하기

spy가 무엇일까?

어떤 객체에 속한 함수의 구현을 가짜로 대체하지 않고, 해당 함수의 호출 여부와 어떻게 호출되었는지만을 알아내야 할 때 사용한다.

쉽게 생각해서 원래 구현된 함수에 스파이를 붙인다고 생각하면 된다. 코드로 한번 스파이를 붙여보자.
(이 코드는 Mocking예시로 사용한 코드와 별개의 코드이다.)

const calculator = {
  add: (a, b) => a + b,
};

const spyFn = jest.spyOn(calculator, "add");

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

expect(spyFn).toBeCalledTimes(1);
expect(spyFn).toBeCalledWith(2, 3);
expect(result).toBe(5);

calculator 객체의 add 함수에 스파이를 붙였다. 때문에 calculator.add가 호출되면 스파이는 그 정보를 안다. 그게 바로 영화 속 스파이의 역할 아닌가.
그래서 내가 염탐하고 있는 함수가 몇번 호출되었는지, 어떤 인자랑 호출되었는지에 대한 정보를 가지고 있게 된다.

하지만 핵심은 스파이는 가짜함수로 대체하는 것이 아니다!

그렇기 때문에 result는 기존의 구현함수가 제대로 작동하여 5를 리턴할 수 있는것이다.

  • 😀mock함수는 기존의 함수를 유지하지 않고 대체되는구나!
  • 😀아! 기존의 함수를 유지하면서 메서드가 제대로 실행되기를 원할 때는 스파이를 붙이면 되는구나!
import * as app from './app';
import * as math from './math';

describe('spy', () => {
  test('calls math.add', () => {
    const addMock = jest.spyOn(math, 'add');

    expect(app.doAdd(1, 2)).toEqual(3);
    expect(addMock).toHaveBeenCalledWith(1, 2);
  });
});

뒤에 설명하겠지만 spy도 mock함수이다. 엄밀하게 말하면 원본 기능을 수행할 수 있는 mock함수이다.

spy를 활용하면 함수를 다시 Mocking했다고 원래 구현으로 복원할 수 있다.

import * as app from './app';
import * as math from './math';

test('calls math.add', () => {
  const addMock = jest.spyOn(math, 'add');

  // override
  addMock.mockImplementation(() => 'mock');
  expect(app.doAdd(1, 2)).toEqual('mock');

  // restore
  addMock.mockRestore();
  expect(app.doAdd(1, 2)).toEqual(3);
});

mockImplementation은 mock함수의 구체적인 구현을 돕는 메서드이다.

🤔어디에 사용될까? 함수를 대체하지 않으면서, 사이드 이펙트가 발생하는지 테스트하는 경우에 유용하다.


Spy도 Mock함수이다

앞서 말한 것처럼 spy도 mock함수이다. 기존 함수의 기능을 수행할 수 있는, 기억하고 있는 가짜함수이다.

test('calls math.add', () => {
  // ✅기존 구현을 저장한다.
  const originalAdd = math.add;

  //✅기존 구현을 Mocking한다 -> mock함수로 대체한다.
  // 여기서는 mock함수에 기존 구현을 전달했으니 원래대로 동작한다.
  // 기존구현을 기억하면서 몇 번 호출되었는지 알수있다...? spy와 매우 유사하다
  math.add = jest.fn(originalAdd);

  expect(app.doAdd(1, 2)).toEqual(3);
  expect(math.add).toHaveBeenCalledWith(1, 2);

  // override
  math.add.mockImplementation(() => 'mock');
  expect(app.doAdd(1, 2)).toEqual('mock');
  expect(math.add).toHaveBeenCalledWith(1, 2);

  // restore - mockRestore 똑같다.
  math.add = originalAdd;
  expect(app.doAdd(1, 2)).toEqual(3);
});

여기서 override된 부분을 살펴보자.

mockImplementation을 통해 스파이함수를 변경했다.
그러니까 원래 구현된 함수도 변경된 것을 알 수 있다.

그 이유는 const originalAdd = math.add;이 부분에서 기존 함수의 레퍼런스를 전달했기 때문이다. pass by reference

참고 사이트에 spyOn이 어떻게 구현되었는지 자료가 있다. 시간되면 꼭 확인하자.


사실은 잘못된 방법...?🤔

이제 Mocking을 이용해서 맨 처음 언급한 코드를 테스트해보자.

class ProductClient {
  fetchItems() {
    return fetch('http://example.com/login/id+password').then((response) =>
      response.json()
    );
  }
}

module.exports = ProductClient;
const ProductClient = require('./product_client');
class ProductService {
  constructor() {
    this.productClient = new ProductClient();
  }

  fetchAvailableItems() {
    return this.productClient.fetchItems().then((items) => items.filter((item) => item.available));
  }
}

module.exports = ProductService;
const ProductService = require('../product_service_no_di.js');
const ProductClient = require('../product_client.js');

jest.mock('../product_client');

describe('ProductService', () => {
  const fetchItems = jest.fn(async () => [
    { item: 'Milk', available: true },
    { item: 'Banana', available: false },
  ]);
  ProductClient.mockImplementation(() => {
    return {
      fetchItems,
    };
  });
  let productService;

  beforeEach(() => {
    productService = new ProductService();
    fetchItems.mockClear();
    ProductClient.mockClear();
  });

  it('should filter out only available items', async () => {
    const items = await productService.fetchAvailableItems();
    expect(items.length).toBe(1);
    expect(items).toEqual([{ item: 'Milk', available: true }]);
  });
});

💬나는 ProductService에서 available이 true인 데이터를 잘 필터링하는지를 테스트하고 싶다. 하지만 ProductClient에 의존하고 있기 때문에 ProductClient가 하는 기능을 Mocking을 통해 대체하려고 한다.

✅대체하기 위해서는 ProductClient의 기능이 무엇인지부터 파악해야한다.
서버로부터 전달받은 response객체에서 .json()메서드를 사용하여 JSON으로 전달된 데이터를 가지고 있는 Promise를 리턴하고 있다.

때문에 ProductClient즉, 대체하려는 mock함수가 Promise를 리턴하게끔 해주기 위해 fetchItems를 async 함수로 만들어 Promise를 리턴하도록 만들었다.
그런다음 테스트에서 await을 사용해 데이터를 받아오고 테스트를 진행하고있다.

(mockClear는 테스트가 실행되기 이전에 만들어진 mock함수를 제거하는 기능이다. jest.config파일에 clearMock이 true이면 안적어도 된다.)

❌하지만 위의 코드는 stub으로 리팩토링 할 수 있다. 현재 Mock을 너무 남발하고 있다...

stub으로 리팩토링하는 것은 다음 포스팅에서 다뤄보겠다. 지금은 mock에도 익숙하지 않기 때문에 mocking하는 법을 조금 더 공부해야겠다.

정리하자면

  • Mocking은 모듈간의 의존성을 분리해서 독립적인 테스트 코드를 만들기 위한 대체 객체이다.

  • 원래 구현된 함수를 유지하면서 호출여부 등을 알고 싶을때는 SpyOn을 활용한다.

  • 많이 사용해보자. 익숙해질때까지👍

📚참고사이트

https://minoo.medium.com/%EB%B2%88%EC%97%AD-jest-mocks%EC%97%90-%EB%8C%80%ED%95%9C-%EC%9D%B4%ED%95%B4-34f75b0f7dbe

profile
대체불가능한 사람이다

0개의 댓글

관련 채용 정보