Jest ES6 클래스 모킹

Jest ES6 클래스 모킹 (ES6 Class Mocks)

테스트 대상 파일에 import 된 ES6 클래스를 가짜로 바꾸고 싶을 때가 있어요. ES6 클래스는 결국 '문법 설탕이 더해진 생성자 함수'라서, mock도 함수나 ES6 클래스(다시 말하면 함수)여야 해요. Jest에서 mock 함수로 ES6 클래스를 어떻게 모킹하는지 살펴볼게요.

출처: Jest 공식 문서 - ES6 Class Mocks

예제: 자동 모킹 (Automatic Mock)

소리 파일을 재생하는 SoundPlayer 클래스와 그것을 사용하는 SoundPlayerConsumer를 예로 들어요.

// sound-player.js
export default class SoundPlayer {
  constructor() {
    this.foo = 'bar';
  }

  playSoundFile(fileName) {
    console.log('Playing sound file ' + fileName);
  }
}
// sound-player-consumer.js
import SoundPlayer from './sound-player';

export default class SoundPlayerConsumer {
  constructor() {
    this.soundPlayer = new SoundPlayer();
  }

  playSomethingCool() {
    const coolSoundFileName = 'song.mp3';
    this.soundPlayer.playSoundFile(coolSoundFileName);
  }
}

jest.mock('./sound-player')을 호출하면 유용한 자동 mock을 반환해요. 클래스 생성자 호출과 모든 메서드에 스파이(spy)를 걸 수 있어요. ES6 클래스를 mock 생성자로 바꾸고, 모든 메서드를 항상 undefined를 반환하는 mock 함수로 교체해요. 메서드 호출은 theAutomaticMock.mock.instances[index].methodName.mock.calls에 저장돼요.

주의할 점은 화살표 함수로 정의한 클래스 멤버는 mock에 포함되지 않아요. 화살표 함수는 프로토타입에 없고, 단지 함수 참조를 담은 프로퍼티일 뿐이기 때문이에요.

구현을 바꿀 필요가 없다면 자동 mock이 가장 간단한 설정이에요.

import SoundPlayer from './sound-player';
import SoundPlayerConsumer from './sound-player-consumer';

jest.mock('./sound-player'); // SoundPlayer는 이제 mock 생성자

beforeEach(() => {
  // 생성자와 모든 메서드의 인스턴스·호출을 비워요.
  SoundPlayer.mockClear();
});

it('We can check if the consumer called the class constructor', () => {
  const soundPlayerConsumer = new SoundPlayerConsumer();
  expect(SoundPlayer).toHaveBeenCalledTimes(1);
});

it('We can check if the consumer called a method on the class instance', () => {
  expect(SoundPlayer).not.toHaveBeenCalled();
  const soundPlayerConsumer = new SoundPlayerConsumer();

  expect(SoundPlayer).toHaveBeenCalledTimes(1);

  const coolSoundFileName = 'song.mp3';
  soundPlayerConsumer.playSomethingCool();

  // 자동 mock으로 mock.instances를 사용할 수 있어요.
  const mockSoundPlayerInstance = SoundPlayer.mock.instances[0];
  const mockPlaySoundFile = mockSoundPlayerInstance.playSoundFile;

  expect(mockPlaySoundFile.mock.calls[0][0]).toBe(coolSoundFileName);
  expect(mockPlaySoundFile).toHaveBeenCalledWith(coolSoundFileName);
  expect(mockPlaySoundFile).toHaveBeenCalledTimes(1);
});

수동 mock과 함께 쓰기

__mocks__ 폴더에 mock 구현을 저장하는 수동 mock을 만들 수도 있어요. 구현을 지정할 수 있고 여러 테스트 파일에서 재사용돼요.

// __mocks__/sound-player.js
// 테스트 파일로 import 할 named export:
export const mockPlaySoundFile = jest.fn();

const mock = jest.fn().mockImplementation(() => {
  return { playSoundFile: mockPlaySoundFile };
});

export default mock;
// sound-player-consumer.test.js
import SoundPlayer, { mockPlaySoundFile } from './sound-player';
import SoundPlayerConsumer from './sound-player-consumer';

jest.mock('./sound-player');

beforeEach(() => {
  SoundPlayer.mockClear();
  mockPlaySoundFile.mockClear();
});

it('We can check if the consumer called a method on the class instance', () => {
  const soundPlayerConsumer = new SoundPlayerConsumer();
  const coolSoundFileName = 'song.mp3';
  soundPlayerConsumer.playSomethingCool();
  expect(mockPlaySoundFile).toHaveBeenCalledWith(coolSoundFileName);
});

모듈 팩토리 (Module Factory)

jest.mock(path, moduleFactory)의 두 번째 인자로 모듈 팩토리를 줄 수 있어요. 모듈 팩토리는 mock을 반환하는 함수예요. 생성자 함수를 모킹하려면 모듈 팩토리가 생성자 함수를 반환해야 하는데, 즉 함수를 반환하는 함수(고차 함수, HOF)여야 해요.

import SoundPlayer from './sound-player';

const mockPlaySoundFile = jest.fn();

jest.mock('./sound-player', () => {
  return jest.fn().mockImplementation(() => {
    return { playSoundFile: mockPlaySoundFile };
  });
});

주의할 점: jest.mock() 호출은 파일 맨 위로 호이스팅되기 때문에, 범위 밖의 변수에 접근하지 못해요. 기본적으로 변수를 먼저 정의하고 팩토리에서 쓰는 것은 불가능해요. Jest는 mock이라는 단어로 시작하는 변수에 대해서만 이 검사를 비활성화해요. 그럼에도 초기화 시점을 스스로 보장해야 하고, Temporal Dead Zone에 주의해야 해요.

예를 들어 fake로 선언한 변수는 팩토리에서 접근하면 범위 밖 오류가 나요.

// Note: this will fail
import SoundPlayer from './sound-player';

const fakePlaySoundFile = jest.fn();

jest.mock('./sound-player', () => {
  return jest.fn().mockImplementation(() => {
    return { playSoundFile: fakePlaySoundFile }; // 범위 밖!
  });
});

기존 mock에서 mockImplementation()을 호출하면 위 mock들을 통째로 교체할 수 있어요. 테스트마다 mock을 바꿔야 할 때도 유용해요.

jest.mock('./sound-player');

describe('When SoundPlayer throws an error', () => {
  beforeAll(() => {
    SoundPlayer.mockImplementation(() => {
      return { playSoundFile: () => { throw new Error('Test error'); } };
    });
  });

  it('Should throw an error when calling playSomethingCool', () => {
    const soundPlayerConsumer = new SoundPlayerConsumer();
    expect(() => soundPlayerConsumer.playSomethingCool()).toThrow();
  });
});

커스텀 mock 직접 만들기

__mocks__ 폴더 안에 모킹할 클래스와 같은 파일명으로 ES6 클래스를 정의하면 그 클래스가 mock으로 쓰여요. 구현을 주입할 수는 있지만 호출을 스파이하기는 어려워요.

// __mocks__/sound-player.js
export default class SoundPlayer {
  constructor() {
    console.log('Mock SoundPlayer: constructor was called');
  }

  playSoundFile() {
    console.log('Mock SoundPlayer: playSoundFile was called');
  }
}

모듈 팩토리가 함수를 반환하면 new로 호출할 수 있어요. 단 mock은 화살표 함수가 될 수 없어요 — new로 호출할 수 없기 때문이에요.

jest.mock('./sound-player', () => {
  return function () {
    return { playSoundFile: () => {} };
  };
});

만약 클래스가 default export가 아니라면, 클래스 export 이름과 같은 키를 가진 객체를 반환해야 해요.

import { SoundPlayer } from './sound-player';

jest.mock('./sound-player', () => {
  return {
    SoundPlayer: jest.fn().mockImplementation(() => {
      return { playSoundFile: () => {} };
    }),
  };
});

스파이 걸기

클래스의 특정 메서드를 mock/spy하고 싶으면 jest.spyOn을 써요.

import SoundPlayer from './sound-player';
import SoundPlayerConsumer from './sound-player-consumer';

const playSoundFileMock = jest
  .spyOn(SoundPlayer.prototype, 'playSoundFile')
  .mockImplementation(() => {
    console.log('mocked function');
  });

it('player consumer plays music', () => {
  const player = new SoundPlayerConsumer();
  player.playSomethingCool();
  expect(playSoundFileMock).toHaveBeenCalled();
});

mock 컨스트럭터 호출 기록을 비우려면 beforeEach()에서 mockClear()를 호출해요.

beforeEach(() => {
  SoundPlayer.mockClear();
  mockPlaySoundFile.mockClear();
});

더 알아보기