[번역] 당신의 Jest 테스트는 잘못되어 있을 수도 있습니다

Jisu Yuk
13 min readJun 8, 2023

--

원문: https://jamiemagee.co.uk/blog/your-jest-tests-might-be-wrong/

당신의 Jest 테스트 그룹이 실패하고 있나요? 그렇다면 당신은 테스트 프레임워크의 잠재력을 모두 활용하지 못하고 있을 수 있습니다. 특히 테스트 간에 상태 정보 공유를 막지 못하고 있을 가능성이 큽니다. Jest 설정 clearMocks, resetMocks, restoreMocks 그리고 resetModules는 기본적으로 false로 설정되어 있습니다. 이러한 기본값을 변경하지 않으면 테스트가 취약해지고 순서에 따라서 결과가 달라지거나 완전히 잘못될 수도 있습니다. 이 글에서는 각 설정이 어떻게 동작하는지 그리고 테스트를 어떻게 수정할 수 있는지에 대해 자세히 설명하겠습니다.

clearMocks

우선 clearMocks 입니다.

모든 테스트 전에 모의 함수 호출, 인스턴스, 컨텍스트 및 결과를 자동으로 지웁니다. 각 테스트 전에 jest.clearAllMocks()를 호출하는 것과 동일합니다. 이 설정을 하더라도 실행되었던 모의 함수 구현은 제거되지 않습니다.

모든 Jest 모의 함수에는 관련된 컨텍스트가 있습니다. 그래서 mockReturnValuemockReturnValueOnce 같은 함수가 각각 사용될 수 있는 것이죠. 그러나 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);
});
});

clearMocksresetMocks를 활성화하고 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는 에러를 발생시키지 않아야 하지만, randomNumberinitialize가 먼저 호출되지 않으면 에러를 발생시켜야 합니다. 좋습니다! 하지만 작동하지 않습니다. 다음과 같은 에러가 출력됩니다.

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 // 상황에 따라 다릅니다.
}

이 부분을 기본 구성으로 설정하는 제안이 있습니다. 하지만 그때까지는 사용자가 직접 설정해야 합니다.

--

--