프로젝트의 테스트 코드를 작성하면서, 모듈을 모의할 때 mock()을 사용하고 있었다. 최근 <프런트엔드 개발을 위한 테스트 입문>이라는 책을 보면서 테스트 코드 작성법에 대해 공부했는데, 여기서도 모의하는 것에 대해 mock, spyOn, fn만 나와있어서 이 셋을 주로 사용한다고 생각했다. 그런데 코드 리뷰 중, 팀원의 코드에서 doMock()이라는 메서드를 보게 되었다. 팀원은 doMock을 사용해서 나와는 다르게 모듈을 모의하고 있었다... 그래서 mock과 doMock의 차이가 무엇인지 알아보았다.
가장 큰 차이점은 호이스팅의 여부이다.
mock은 호이스팅되고, doMock은 호이스팅되지 않는다.
jest는 파일 내의 mock() 호출을 찾아 다른 import문보다 먼저 실행한다. 따라서 factory 외부에서 정의된 변수를 factory 내부에서 사용할 수 없다. factory 외부에서 정의된 변수를 factory 내부에서 사용하려면 doMock()을 사용하면 된다.
+) Vitest의 경우, vi.hoisted()에 변수를 정의하면 vi.mock()에서 해당 변수를 참조할 수 있다. (참고)
jest.doMock(moduleName, factory, options)
Jest 공식 문서에 의하면 doMock은 아래와 같이 하나의 모듈을 한 파일에서 다르게 모의하고 싶을 때 사용하면 유용하다고 한다. 정적 import는 호이스팅 되기 때문에 모듈을 동적으로 import 해야 한다.
beforeEach(() => {
jest.resetModules();
});
test('moduleName 1', () => {
jest.doMock('../moduleName', () => {
return jest.fn(() => 1);
});
const moduleName = require('../moduleName');
expect(moduleName()).toBe(1);
});
test('moduleName 2', () => {
jest.doMock('../moduleName', () => {
return jest.fn(() => 2);
});
const moduleName = require('../moduleName');
expect(moduleName()).toBe(2);
});
ES6의 import와 함께 사용하려면 다음과 같은 추가 과정이 필요하다.
- __esModule: true 옵션 설정
- dynamic import
- Babel에서 dynamic import를 지원하도록 환경 세팅 (자세한 내용은 공식 문서 참고)
beforeEach(() => {
jest.resetModules();
});
test('moduleName 1', () => {
jest.doMock('../moduleName', () => {
return {
__esModule: true,
default: 'default1',
foo: 'foo1',
};
});
return import('../moduleName').then(moduleName => {
expect(moduleName.default).toBe('default1');
expect(moduleName.foo).toBe('foo1');
});
});
test('moduleName 2', () => {
jest.doMock('../moduleName', () => {
return {
__esModule: true,
default: 'default2',
foo: 'foo2',
};
});
return import('../moduleName').then(moduleName => {
expect(moduleName.default).toBe('default2');
expect(moduleName.foo).toBe('foo2');
});
});
jest.mock(moduleName, factory, options)
예시
jest.mock('../banana');
const banana = require('../banana'); // banana will be explicitly mocked.
banana();
default export & import와 함께 사용
import moduleName, {foo} from '../moduleName';
jest.mock('../moduleName', () => {
return {
__esModule: true,
default: jest.fn(() => 42),
foo: jest.fn(() => 43),
};
});
moduleName(); // Will return 42
foo(); // Will return 43
그런데 공식 문서에서 jest.doMock을 '하나의 모듈을 한 파일에서 다르게 모의하고 싶을 때 사용하면 유용하다.'라고 했지만 jest.mock을 사용했을 때도 테스트 케이스별로 다른 값을 반환(모의)할 수 있다. jest.mock과 jest.mocked(TS 사용 시), jest.returnValueOnce(또는 jest.resolveValueOnce)의 조합을 사용하면 되는데, doMock을 사용하는 것과 어떤 차이가 있는지 궁금했다. 차이점을 찾아보고 정리해 보았다.
jest.mock + mockResolvedValueOnce | jest.doMock | |
모의 처리 시점 | 테스트 파일 로드 시 (실행 전) | 실행 시점 |
모듈 static import 가능 여부 | 가능 | 불가능 (dynamic import only) |
실행 속도 | 더 빠름 | 약간 느림 (테스트 실행 중 동적 처리) |
코드 간결 | 간결 | 다소 복잡 |
유연성 | 제한적 | 동적 테스트에 적합 |
jest.mock + mockResolvedValueOnce
- 한 번 모의한 모듈을 기반으로, 개별 테스트에서 다른 반환값을 설정.
- 가벼운 유닛 테스트에 적합.
- jest.mock 선언이 한 번만 이루어지므로 코드가 더 간결.
- 동일한 함수나 모듈에 대해 여러 테스트에서 동작을 다르게 설정 가능.
적합한 경우
- 모듈의 전역적인 기본 설정이 동일할 때
- 모의 처리된 모듈은 기본 동작이 같되, 반환값만 테스트별로 달라지는 경우.
- 테스트 간격이 명확하고 독립적일 때
- 모의 구현이 테스트 중간에 영향을 미치지 않도록 명확히 관리할 수 있는 경우.
jest.doMock
- 특정 테스트 케이스에서 모듈 전체의 동작을 변경해야 하는 경우 적합.
- 모듈 자체의 로직이 테스트 상황마다 완전히 달라야 할 때 유용.
- 테스트 간의 모의 처리 상태를 명확히 격리.
- 특정 테스트에서 모의 처리의 부작용이 다른 테스트에 영향을 주지 않음.
- 동적 import를 통해 특정 상황에서만 모의 처리 가능.
적합한 경우
- 모듈의 동작이 케이스마다 근본적으로 달라야 할 때
- 모듈 내부의 함수뿐만 아니라, 다른 의존성이나 동작 자체가 바뀌는 경우.
- 다양한 테스트 환경에 따라 모의 처리가 달라져야 할 때
- 환경 변수, 설정 파일, 또는 외부 의존성을 테스트 조건에 맞게 변경해야 하는 경우.
- 모듈 상태 간섭을 방지해야 할 때
- 테스트 간 모의 처리 상태를 철저히 격리하고 싶을 때.
이런 차이를 고려해서 기존에 작성했던 테스트 코드를 다시 살펴보고 수정이 필요한 부분은 수정할 예정이다.
jest.Mocked vs jest.mocked
mock와 doMock의 차이를 알아보는 김에 비슷하게 생겨 헷갈리는 mock 모듈을 같이 정리해 보았다.
jest.Mocked, jest.mocked는 둘 다 타입스크립트에서 사용된다.
jest.Mocked<Source>
jest.Mocked는 jest 모의 함수의 타입 정의로 래핑된 Source 타입을 반환한다. 클래스, 함수, 객체의 타입을 jest.Mocked<Source>의 타입 인자로 전달할 수 있다.
import {expect, jest, test} from '@jest/globals';
import type {fetch} from 'node-fetch';
jest.mock('node-fetch');
let mockedFetch: jest.Mocked<typeof fetch>;
afterEach(() => {
mockedFetch.mockClear();
});
test('makes correct call', () => {
mockedFetch = getMockedFetch();
// ...
});
test('returns correct data', () => {
mockedFetch = getMockedFetch();
// ...
});
jest.mocked(source, options?)
jest.mocked는 Source 객체와 그 깊이 중첩된 멤버들의 타입을 jest 모의 함수 타입 정의로 감싸주는 역할을 한다. 모의된 모듈 또는 함수의 타입을 명시적으로 지정하여 타입스크립트의 타입 안전성을 높인다.
// song.ts
export const song = {
one: {
more: {
time: (t: number) => {
return t;
},
},
},
};
// song.test.ts
import {expect, jest, test} from '@jest/globals';
import {song} from './song';
jest.mock('./song');
jest.spyOn(console, 'log');
const mockedSong = jest.mocked(song);
// or through `jest.Mocked<Source>`
// const mockedSong = song as jest.Mocked<typeof song>;
test('deep method is typed correctly', () => {
mockedSong.one.more.time.mockReturnValue(12);
expect(mockedSong.one.more.time(10)).toBe(12);
expect(mockedSong.one.more.time.mock.calls).toHaveLength(1);
});
test('direct usage', () => {
jest.mocked(console.log).mockImplementation(() => {
return;
});
console.log('one more time');
expect(jest.mocked(console.log).mock.calls).toHaveLength(1);
});
+) 우리 팀은 Vite를 사용 중이라 테스트 툴로 Vitest를 사용하고 있는데, Vitest에서 import로 가져온 모듈에만 mocking modules를 지원한다.
Beware that Vitest doesn't support mocking modules imported using require().
vi.mock works only for modules that were imported with the import keyword. It doesn't work with require.
테스트 과정에서 모의하는 부분은 간단한 것 같으면서도 어려운 것 같다. jest, vitest 공식 문서를 더 많이 읽어봐야겠다.
도움이 된 포스팅
잘못된 부분이 있다면 댓글로 알려주시면 감사하겠습니다!
'Develop > JavaScript' 카테고리의 다른 글
[JavaScript] Vanilla JavaScript에 PWA 적용하기 with Webpack, Workbox (0) | 2024.05.18 |
---|---|
[JavaScript] Firestore 검색 구현하기 with Aloglia (0) | 2024.04.19 |
[JavaScript] 카카오맵 API를 이용한 장소 검색 및 위치 추가 구현하기 (0) | 2024.03.19 |
Vanilla JavaScript 프로젝트 초기 설정하기 (ES6) (0) | 2024.02.06 |
[Jest] 자바스크립트 테스트 코드 작성해 보기 (0) | 2023.09.07 |