원문: https://jamiemagee.co.uk/blog/your-jest-tests-might-be-wrong/
당신의 Jest 테스트 그룹이 실패하고 있나요? 그렇다면 당신은 테스트 프레임워크의 잠재력을 모두 활용하지 못하고 있을 수 있습니다. 특히 테스트 간에 상태 정보 공유를 막지 못하고 있을 가능성이 큽니다. Jest 설정 clearMocks
, resetMocks
, restoreMocks
그리고 resetModules
는 기본적으로 false
로 설정되어 있습니다. 이러한 기본값을 변경하지 않으면 테스트가 취약해지고 순서에 따라서 결과가 달라지거나 완전히 잘못될 수도 있습니다. 이 글에서는 각 설정이 어떻게 동작하는지 그리고 테스트를 어떻게 수정할 수 있는지에 대해 자세히 설명하겠습니다.
clearMocks
우선 clearMocks
입니다.
모든 테스트 전에 모의 함수 호출, 인스턴스, 컨텍스트 및 결과를 자동으로 지웁니다. 각 테스트 전에
jest.clearAllMocks()
를 호출하는 것과 동일합니다. 이 설정을 하더라도 실행되었던 모의 함수 구현은 제거되지 않습니다.
모든 Jest 모의 함수에는 관련된 컨텍스트가 있습니다. 그래서 mockReturnValue
와 mockReturnValueOnce
같은 함수가 각각 사용될 수 있는 것이죠. 그러나 clearMocks
가 기본적으로 false이면 해당 컨텍스트는 테스트 간에 전달될 수 있습니다.
아래의 예제 함수를 참고하세요.
export function randomNumber() {
return Math.random();
}
그리고 아래의 코드는 해당 함수의 테스트입니다.
jest.mock('.');
const { randomNumber } = require('.');
describe('tests', () => {
randomNumber.mockReturnValue(42);
it('should return 42', () => {
const random = randomNumber();
expect(random).toBe(42);
expect(randomNumber).toBeCalledTimes(1);
});
});
테스트가 통과되고 예상대로 작동합니다. 하지만 테스트 스위트에 다른 테스트를 추가하면 어떻게 될까요?
jest.mock('.');
const { randomNumber } = require('.');
describe('tests', () => {
randomNumber.mockReturnValue(42);
it('should return 42', () => {
const random = randomNumber();
expect(random).toBe(42);
expect(randomNumber).toBeCalledTimes(1);
});
it('should return same number', () => {
const random1 = randomNumber();
const random2 = randomNumber();
expect(random1).toBe(42);
expect(random2).toBe(42);
expect(randomNumber).toBeCalledTimes(2);
});
});
두번째 테스트는 아래의 에러를 노출하고 실패합니다.
Error: expect(jest.fn()).toBeCalledTimes(expected)
Expected number of calls: 2
Received number of calls: 3
테스트 순서를 변경하면 더 큰 문제가 발생합니다.
jest.mock('.');
const { randomNumber } = require('.');
describe('tests', () => {
randomNumber.mockReturnValue(42);
it('should return same number', () => {
const random1 = randomNumber();
const random2 = randomNumber();
expect(random1).toBe(42);
expect(random2).toBe(42);
expect(randomNumber).toBeCalledTimes(2);
});
it('should return 42', () => {
const random = randomNumber();
expect(random).toBe(42);
expect(randomNumber).toBeCalledTimes(1);
});
});
이전과 동일한 오류가 발생하지만, should return same number
대신 should return 42
가 발생됩니다.
Jest 설정에서 clearMocks
를 활성화하면 테스트 사이에 모든 모의 컨텍스트가 리셋됩니다. jest.clearAllMocks()
를 beforeEach()
함수에 추가하면 동일한 결과를 얻을 수 있습니다. 하지만 이는 모든 테스트를 안전하게 만들기 위해 각 테스트 파일에 이를 추가해야 하는 번거로움을 야기합니다. 대신 clearMocks
를 사용하여 기본적으로 모든 테스트를 안전하게 만드는 것이 좋은 아이디어입니다.
resetMocks
다음은 resetMocks
입니다.
모든 테스트 전에 모의 상태를 자동으로 재설정합니다. 각 테스트 전에
jest.resetAllMocks()
를 호출하는 것과 동일합니다. 이렇게 하면 모의 구현이 제거되지만 처음의 구현 상태로 복원되지는 않습니다.
resetMocks
는 모의 구현을 지움으로써 clearMocks
를 한 단계 더 발전시킵니다. 하지만 clearMocks
와 함께 사용해야 합니다.
첫 번째 예제로 다시 돌아갑시다. 모의 설정 부분을 첫 번째 테스트 케이스 randomNumber.mockReturnValue(42);
로 이동시키겠습니다.
describe('tests', () => {
it('should return 42', () => {
randomNumber.mockReturnValue(42);
const random = randomNumber();
expect(random).toBe(42);
expect(randomNumber).toBeCalledTimes(1);
});
it('should return 42 twice', () => {
const random1 = randomNumber();
const random2 = randomNumber();
expect(random1).toBe(42);
expect(random2).toBe(42);
expect(randomNumber).toBeCalledTimes(2);
});
});
논리적으로는 실패할 것으로 예상할 수 있지만 통과합니다! Jest 모의 객체는 해당 파일 내에서 전역적으로 작동합니다. 어떤 describe
, it
, test
스코프를 사용하든 상관없습니다. 그리고 테스트 순서를 다시 변경하면 실패합니다. 따라서 상태가 누출되고 순서에 의존적인 테스트를 쉽게 작성하게 될 수 있습니다.
Jest 컨텍스트에서 resetMocks
를 활성화하면 테스트 사이에 모든 모의 구현이 초기화됩니다. 이전과 마찬가지로 모든 테스트 파일에서 beforeEach()
에 jest.resetAllMocks()
를 추가할 수도 있습니다. 하지만 특정 테스트보다는 모든 테스트를 기본적으로 안정적으로 만드는 것이 훨씬 더 좋습니다.
restoreMocks
다음은 restoreMocks
입니다.
모든 테스트 전에 모의 상태와 구현을 자동으로 복원합니다. 각 테스트 전에
jest.restoreAllMocks()
를 호출하는 것과 동일합니다. 이렇게 하면 모든 모의 구현을 제거하며 초기 구현을 복원합니다.
restoreMocks
는 테스트 격리 및 안전성을 한 차원 더 높여줍니다.
예제를 조금 다시 작성해 보겠습니다. 함수를 직접 모킹하는 대신 Math.random()
을 모킹하겠습니다.
const { randomNumber } = require('.');
const spy = jest.spyOn(Math, 'random');
describe('tests', () => {
it('should return 42', () => {
spy.mockReturnValue(42);
const random = randomNumber();
expect(random).toBe(42);
expect(spy).toBeCalledTimes(1);
});
it('should return 42 twice', () => {
spy.mockReturnValue(42);
const random1 = randomNumber();
const random2 = randomNumber();
expect(random1).toBe(42);
expect(random2).toBe(42);
expect(spy).toBeCalledTimes(2);
});
});
clearMocks
및 resetMocks
를 활성화하고 restoreMocks
를 비활성화하면 내 테스트가 통과합니다. 하지만 restoreMocks
를 활성화하면 다음과 같은 오류 메시지와 함께 두 테스트가 모두 실패합니다.
Error: expect(received).toBe(expected) // Object.is equality
Expected: 42
Received: 0.503533695686772
restoreMocks
는 각 테스트 전에 Math.random()
의 원래 구현을 복원했기 때문에 이제 모의 반환 값인 42
대신 실제 임의의 숫자를 얻습니다. 이로 인해 제가 기대하는 모의 반환 값뿐만 아니라 모의 자체에 대해 명시적으로 설명해야 합니다.
테스트를 수정하기 위해 각 개별 테스트에서 Jest 모의 테스트를 설정할 수 있습니다.
describe('tests', () => {
it('should return 42', () => {
const spy = jest.spyOn(Math, 'random').mockReturnValue(42);
const random = randomNumber();
expect(random).toBe(42);
expect(spy).toBeCalledTimes(1);
});
it('should return 42 twice', () => {
const spy = jest.spyOn(Math, 'random').mockReturnValue(42);
const random1 = randomNumber();
const random2 = randomNumber();
expect(random1).toBe(42);
expect(random2).toBe(42);
expect(spy).toBeCalledTimes(2);
});
});
resetModules
마지막으로 resetModules
입니다.
기본적으로 각 테스트 파일은 독립적인 모듈 레지스트리를 갖습니다.
resetModules
를 활성화하면 한 단계 더 나아가 각 개별 테스트를 실행하기 전에 모듈 레지스트리를 재설정합니다. 이 기능은 로컬 모듈 상태가 테스트 간에 충돌하지 않도록 모든 테스트에 대해 모듈을 분리하는 데 유용합니다. 이 작업은jest.resetModules()
를 사용하여 프로그래밍 방식으로 수행할 수 있습니다.
다시 말하지만, 이것은 clearMocks
, resetMocks
그리고 restoreMocks
를 기반으로 구축됩니다. 대부분의 테스트에서 이 정도의 격리 수준은 필요하지 않다고 생각하지만, 저는 완벽주의자입니다.
위의 예제를 randomNumber
를 호출하기 전에 수행해야 하는 몇 가지 초기화를 포함하도록 확장해 보겠습니다. 난수를 생성하기에 충분한 엔트로피가 있는지 확인해야 할 수도 있겠죠? 제 모듈은 다음과 같이 보일 수 있습니다.
let isInitialized = false;
export function initialize() {
isInitialized = true;
}
export function randomNumber() {
if (!isInitialized) {
throw new Error();
}
return Math.random();
}
또한 이것이 예상대로 작동하는지 확인하기 위해 몇 가지 테스트를 작성하고 싶습니다.
const random = require('.');
describe('tests', () => {
it('does not throw when initialized', () => {
expect(() => random.initialize()).not.toThrow();
});
it('throws when not initialized', () => {
expect(() => random.randomNumber()).toThrow();
});
});
initialize
는 에러를 발생시키지 않아야 하지만, randomNumber
는 initialize
가 먼저 호출되지 않으면 에러를 발생시켜야 합니다. 좋습니다! 하지만 작동하지 않습니다. 다음과 같은 에러가 출력됩니다.
Error: expect(received).toThrow()
Received function did not throw
그 이유는 resetModules
를 활성화하지 않으면 파일에 있는 모든 테스트 간에 모듈이 공유되기 때문입니다. 따라서 첫 번째 테스트에서 random.initialize()
를 호출했을 때 두 번째 테스트에서도 isInitialized
가 여전히 true입니다. 하지만 다시 한 번 파일에서 테스트 순서를 바꾸면 두 테스트가 모두 통과합니다. 그래서 제 테스트는 다시 순서에 의존적입니다!
resetModules
를 활성화하면 파일의 각 테스트에 대해 각 테스트에 대한 모듈의 새 버전이 제공됩니다. 하지만 전역적으로 활성화하는 대신 beforeEach()
에서 jest.resetAllModules()
를 추가하는 것이 더 나은 경우가 있습니다. 모든 테스트에 이런 종류의 격리가 필요한 것은 아닙니다. 그리고 require
대신 import
를 사용하는 경우, ‘import’ and ‘export’ may only appear at the top level
오류를 피하려는 경우 구문이 어색해질 수 있습니다.
TL;DR 모든 것을 초기화하세요
기본적으로 Jest 테스트는 파일 수준에서만 격리됩니다. 테스트를 안전하게 격리하고 싶다면 아래의 기능을 Jest 설정에 추가하세요.
{
clearMocks: true,
resetMocks: true,
restoreMocks: true,
resetModules: true // 상황에 따라 다릅니다.
}
이 부분을 기본 구성으로 설정하는 제안이 있습니다. 하지만 그때까지는 사용자가 직접 설정해야 합니다.