본문 바로가기

프로그래밍 독학/Jest

Jest Mocks에 대한 이해

반응형

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 를 필사한 것입니다.

 

Jest의 Mocking은 테스트를 독립시키기 위해 의존성을 개발자가 컨트롤하고 검사할 수 있는 오브젝트로 변환하는 테크닉입니다. 의존성은 무엇이든 될 수 있지만, 일반적으로 import하는 모듈입니다.

 

자바스크립트에는 testdouble과 sinon처럼 훌륭한 mocking 라이브러리가 있고, Jest는 기본적으로 제공하는 기능입니다. 최근에 저는 Jest의 이슈 트래커를 돕기 위해 Collaborator로서 Jest팀에 참여했습니다. 거기서 많은 이슈들이 Jest에서 어떻게 mocking하는지에 관한 질문이라는 것을 깨닫고, 이것들을 한번에 설명하는 가이드를 작성하기로 했습니다.

 

우리가 Jest에서 Mocking을 이야기할 때, 일반적으로 의존성을 Mock function으로 대체하는 것에 대해 말합니다. 이 글에서 Mock 함수에 대해 리뷰해보고, 의존성을 대체하는 여러가지 방법으로 deep dive 해보겠습니다.

 

The Mock Function

Mocking의 목적은 우리가 컨트롤할 수 없는 무엇인가를 대체하는 것이기 때문에, 우리가 대체하는 것이 필요로 하는 모든 기능을 갖고 있는게 중요합니다. 

Mock 함수는 다음 기능을 제공합니다. 

  • 함수호출 Capture
  • Return Value 설정
  • 구현 변경하기

Mock 함수 인스턴스를 만드는 가장 간단한 방법은 jest.fn()을 쓰는 것입니다.

이것과 Jest Expect를 쓰면, 함수호출을 Capture해서 쉽게 테스트할 수 있습니다.

test("returns undefined by default", () => {
    const mock = jest.fn();
    
    let result = mock("foo");
    
    expect(result).toBeUndefined();
    expect(mock).toHaveBeenCalled();
    expect(mock).toHaveBeenCalledTimes(1);
    expect(mock).toHaeBeenCalledWith("foo");
});

 

그리고 Return value, 구현, Promise Resolution을 바꿀 수도 있습니다.

test("mock implementation", () => {
    const mock = jest.fn(() => "bar");
    
    expect(mock("foo")).toBe("bar");
    expect(mock).toHaveBeenCalledWith("foo");
});

test("also mock implementation", () => {
    const mock = jest.fn().mockImplementation(() => "bar");
    
    expect(mock("foo")).toBe("bar");
    expect(mock).toHaveBeenCalledWith("foo");
});

test("mock implementation one time", () => {
    const mock = jesn.fn().mockImplementationOnce(() =>"bar");
    
    expect(mock("foo")).toBe("bar");
    expect(mock).toHaveBeenCalledWith("foo");
    
    expect(mock("baz")).toBe(undefined);
    expect(mock).toHaveBeenCalledWith("baz");
});

test("mock return value", () => {
    const mock = jest.fn();
    mock.mockReturnValue("bar");
    
    expect(mock("foo")).toBe("bar");
    expect(mock).toHaveBeenCalledWith("foo");
});

test("mock promise resolution", () => {
   const mock = jest.fn();
   mock.mockResolvedValue("bar");
   
   expect(mock("foo")).resolves.toBe("bar");
   expect(mock).toHaveBeenCalledWith("foo");
});

 

의존성 주입

Mock 함수를 사용하는 일반적인 방법 중 하나는 테스트하려는 함수로 arguments를 직접 전달하는 방식입니다. 이것을 테스트를 실행시키고, Mock함수가 어떤 arguments와 어떻게 실행됐는지 assert 구문으로 확인해볼 수 있습니다.

 

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);
});

이 전략은 견고한 테스트를 만들지만 테스트코드가 의존성주입을 허용하도록 요구합니다. 종종 그럴 수 없는 경우에, 우리는 실제로 존재하는 모듈과 함수를 Mocking 해야 합니다.

 

 

모듈과 함수를 Mocking하기

Jest에서 모듈과 함수를 Mocking하는 3가지 방법이 있습니다.

  • jest.fn: Mock a function
  • jest.mock: Mock a module
  • jest.spyOn: Spy or mock a function

이것들은 각각의 방식으로 Mock 함수를 만드는데, 어떻게 동작하는지 설명을 하기 위해 다음과 같은 폴더구조로 만들어보겠습니다.

├ example/
| └── app.js
| └── app.test.js
| └── math.js

 

이 설정에서는 math.js 함수를 실제로 호출하지 않고 app.js를 테스트하면서, 함수가 예상대로 호출되는지 확인하기 위해Spy를 하는 것이 일반적입니다. 예시들은 진부하지만 math.js의 함수들이 복잡한 계산을 하거나 개발자가 피하고싶은 IO를 만드는 요청이라고 상상해주세요.

 

// 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);

 

jest.fn으로 Mocking하기

가장 기본적인 전략은 함수를 Mock 함수로 재할당하는 것입니다. 재할당된 함수가 쓰이는 어디서든지 Mock 함수가 원래의 함수 대신 호출될 것입니다.

 

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

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);
});

이렇게 Mocking 하는 방식은 몇 가지 이유로 덜 쓰입니다.

- jest.mock은 자동적으로 모듈의 모든 함수를 Mocking 해줍니다.

- jest.spyOn도 마찬가지로 모든 함수를 Mocking해주면서 원래의 함수를 다시 복원할 수도 있습니다.

 

jest.mock으로 Mocking하기

좀 더 일반적인 접근법은 자동적으로 모듈이 exports하는 모든 것들을 Mocking 해주는 jest.mock을 쓰는 것입니다. 따라서 jest.mock('./math.js')를 해주면 본질적으로 math.js를 다음처럼 설정하는 것입니다.

export const add      = jest.fn();
export const subtract = jest.fn();
export const multiply = jest.fn();
export const divide   = jest.fn();

 

여기서부터 모듈이 exports하는 모든 것들에 Mock 함수 기능을 쓸 수 있습니다.

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

// Set all module functions to jest.fn
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);
});

이것은 가장 쉽고 일반적인 Mocking 방법입니다. (Jest의 automock: true 설정 방법이기도 합니다.) 

이 전략의 유일한 단점은 모듈의 원래 구현에 접근하기 어렵다는 것입니다. 이런 경우를 대비해 spyOn이 있습니다.

 

jest.spyOn으로 Spy 혹은 Mocking 하기

때로 우리는 메소드가 실행되는 것을 지켜보길 원할 뿐만 아니라, 기존의 구현은 보존하길 바랍니다. 구현을 Mocking하고 차후에 테스트 구문에서 원본을 복원할 수 있습니다.

 

이 결루에 jest.spyOn을 쓸 수 있습니다.

단순히 math 함수에 "Spy"를 호출하고 원본 구현은 그대로 둘 수 있습니다.

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

test("calls math.add", () => {
    const addMock = jest.spyOn(math, "add");
    
    // calls the original implementation
    expect(app.doAdd(1, 2)).toEqual(3);
    
    // and the spy stores the calls to add
    expect(addMock).toHaveBeenCalledWith(1, 2);
});

이것은 실제로 함수를 대체하지 않고, 특정한 사이드 이펙트가 발생하는지 테스트라는 몇몇 시나리오에 유용합니다.

 

함수를 Mocking하고 다시 원래 구현을 복원할 수도 있습니다.

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

test("calls math.add", () => {
    consdt addMock = jest.spyOn(math, "add");
    
    // override the implementation
    addMock.mockImplementation(() => "mock");
    expect(app.doAdd(1, 2)).toEqual("mock");
    
    // restore the original implementation
    addMock.mockRestore();
    expect(app.doAdd(1, 2)).toEqual(3);
});

 

Jest는 각각의 테스트 파일이 샌드박스화되어있기 때문에, afterAll 훅을 불필요하게 사용하지 않도록 하는 경우에 유용합니다.

 

jest.spyOn은 기본적으로 jest.fn()의 사용에 대한 sugar라는 것이 키 포인트입니다. 우리는 기존의 구현을 저장하고, Mocking했다가, 기존 구현을 재할당하는 방식으로 똑같은 목표를 달성할 수 있습니다.

 

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

test("calls math.add", () => {
    // store the original implementation
    const originalAdd = math.add;
    
    // mock add with the original implementation
    math.add = jest.fn(originalAdd);
    
    // spy the calls to add
    expect(app.doAdd(1, 2)).toEqual(3);
    expect(math.add).toHaveBeenCalledWith(1, 2);
    
    // override the implementation
    math.add.mockImplementation(() => "mock");
    expect(app.doAdd(1, 2)).toEqual("mock");
    expect(math.add).toHaveBeenCalledWith(1, 2);
    
    // restore the original implementation
    math.add = originalAdd;
    expect(app.doAdd(1, 2)).toEqual(3);
});

이것이 실제로 jest.spyOn이 구현된 방식입니다.

 

결론

이 글에서 우리는 Mock함수가 무엇인지와 모듈과 함수 호출을 트래킹하고, 구현과 return value를 바꾸는 방법을 배웠습니다.

반응형

'프로그래밍 독학 > Jest' 카테고리의 다른 글

jest - redux 테스트하기 번역  (0) 2023.06.22
jest 시작하기 - jest의 개요  (0) 2023.06.22
Jest 웹팩과 함께 쓰기  (0) 2020.12.17