Ayden's journal

Jest

Jest는 Facebook에서 개발한 JavaScript 테스트 프레임워크로써 간단하고 직관적인 API와 강력한 기능을 제공한다. Nest.js는 테스트 도구로서 Jest를 기본 제공하고 있고, React나 Next.js에서는 testing-library를 사용해 Jest 테스트를 수행할 수 있다.

Jest의 가장 큰 장점 중 하나는 설정이 거의 필요 없다는 점이다. 다른 테스트 프레임워크들은 종종 복잡한 설정이나 외부 라이브러리의 설치가 요구되지만, Jest는 기본적으로 거의 모든 기능을 내장하고 있어 별다른 준비 없이 바로 사용할 수 있다. 또한, 자동화된 테스트 실행과 빠른 피드백을 제공하여 개발 과정에서 코드 변경 시마다 수동으로 테스트를 실행하는 불편함을 줄여준다.

 

 

기본 문법

Jest는 test 단위로 실행되며, test를 의미 단위로 묶기 위한 describe를 제공한다. test 내부에서는 expect를 사용하여 두 값을 비교하는데, 비교하는 방식으로 toBe나 toStrictEqual 등의 메소드를 지원한다. 따라서 간단하게 요약하자면 Jest 테스트란 expect가 제공하는 다양한 메소드를 사용해 어떤 두 값이 일치하는지 아닌지를 비교하는 행위라고 할 수 있겠다.

describe("calc util", () => {
  test("두 숫자를 받아서 더한 값을 리턴한다", () => {
    expect(sum(1, 2)).toBe(3); // 일치하는지
    expect(sum(1, 2)).not.toBe(5); // 불일치하는지
    expect(sum(1, 2)).toBeLessThan(5) // 더 작은지
    expect(sum(1, 2)).toBeGreaterThan(2) // 더 큰지
    expect(sum(1, 2)).toStrictEqual(expect.any(Number)) // 숫자형인지만 확인
    expect(sum(0.1, 0.2)).toBe(expect.closeTo(0.3)) // 부동 소수점 문제 처리
  })
})

// 객체 비교
expect().toBeInstanceOf() // 생성자만 비교
expect().toStrictEqual() // 생성자 수준 비교
expect().toMatchObject() // 단순 객체 비교

 

describe와 test도 다양한 메소드를 가지고 있다. 다른 테스트에 영향을 받지 않도록 하는 only와 앞으로 작성해야 할 테스트를 선언하는 todo, 그리고 실행하지 않을 테스트를 지정하는 skip 등이 있다. 특히 each를 사용하면 아래와 같이 중복될 수 있는 여러 테스트를 한 번에 작성할 수 있다.

test.each([
  [3, 4, 5],
  [5, 12, 13]
])(`$i 제곱과 $i 제곱을 더하면 #i 제곱이 된다`, (a, b, c) => {
  const pow = jest.fn((a, b) => Math.pow(a, 2) + Math.pow(b, 2));
  expect(pow(a, b)).toBe(Math.pow(c, 2));
})

 

반복적으로 적용해야 할 로직이 있다면 테스트 라이프사이클을 이용할 수 있다. 이러한 라이프사이클 함수들은 describe 내외로 작성할 수 있는데, describe 바깥에 작성한 라이프사이클이 describe 내부에 작성한 라이프사이클보다 먼저 실행된다.

// 모든 테스트 전에 실행
beforeAll(() => {})

// 각 테스트 전에 실행
beforeEach(() => {})

// 각 테스트 후에 실행
afterEach(() => {})

// 모든 테스트 후에 실행
afterAll(() => {})

 

 

함수 모킹

함수 모킹(Mock Functions)은 테스트에서 특정 함수나 메서드의 실제 구현을 대신하여 가짜 함수를 사용하여 그 동작을 제어하거나 검증하는 기법이다. 이때 사용되는 가짜 함수는 원래 함수가 수행하는 로직을 그대로 사용하는 대신 특정 행동을 모방하거나, 호출된 횟수, 전달된 인자 등을 추적할 수 있도록 설계된다. 덕분에 외부 의존성으로부터 독립적인 테스트를 작성할 수 있다.

Jest에는 함수를 모킹하기 위한 jest.fn()과 객체 내의 메소드를 모킹하는 jest.spyOn()가 존재한다. 이 두 메소드는 비슷하면서도 조금 다른 데가 있는데, jest.fn()는 아규먼트로 함수를 받아 모킹된 함수(jest.Mock)를 리턴하지만, jest.spyOn()은 원본 메소드에 대해 추적할 수 있는 스파이 객체(jest.SpyInstance)를 리턴한다.

test("", () => {
  const spySum = jest.fn(sum);
  expect(spySum(1, 2)).toBe(3);
  expect(spySum).toHaveBeenCalledTimes(1);
})

test("", () => {
  const jestSpyObject = jest.spyOn(obj, "method");
  expect(obj.method(1, 2)).toBe(3)
  expect(jestSpyObject).toHaveBeenCalledTimes(1)
  expect(obj.method).toHaveBeenCalledTimes(1) // 이것도 가능
})

 

모킹된 함수 안에도 스파이 객체가 있고, 이 스파이 객체를 사용하면 함수의 호출 횟수(toHaveBeenCalledTimes)나 아규먼트가 올바르게 제공되었는지 여부(toHaveBeenCalledWith) 등을 확인할 수 있다.

아래의 스파이 객체는 jestSpyObject.mock을 콘솔에 찍어본 내용이다. 스파이 객체를 살펴보면 함수가 호출될 때마다 어떤 인자(calls)를 받았는지, 모킹된 함수가 가리키는 this는 무엇인지(contexts), 함수가 호출된 순서(invocationCallOrder)와 그 결과(results) 등을 알 수 있다. 이 호출된 순서와toBeGreaterThen 등을 사용하여 함수들이 올바른 순서로 호출되었는지를 확인할 수도 있다.

{
  calls: [ [ 1, 2 ] ],
  contexts: [ { method: [Function], props: 'a' } ],
  instances: [ { method: [Function], props: 'a' } ],
  invocationCallOrder: [ 1 ],
  results: [ { type: 'return', value: 3 } ],
  lastCall: [ 1, 2 ]
}

 

이렇게 스파이를 심는 행위 자체는 함수의 구현 자체에는 크게 영향을 주지 않는다. 따라서 함수가 데이터베이스 등 외부 환경에 의존적이라면 테스트를 실행할 때마다 외부 환경에 영향을 줄 수가 있다. 이를 방지하기 위해 함수의 구현 자체를 덮어써버리는 mockImplementation이나 함수 구현은 무시하고 정해진 값만 반환하도록 만드는 mockReturnValue를 사용하기도 한다.

다만, 이러한 메소드를 사용해 전역 변수를 수정하는 경우 다른 테스트에도 영향을 미칠 수 있다. 따라서 테스트 라이프사이클 등을 사용해 적절하게 스파이를 리셋시켜주는 것이 좋다.

// 스파이 리셋
.mockClear() // times, with 초기화
jest.clearAllMocks()

.mockReset() // mockClear + mockImplementation/mockReturnValue
jest.resetAllMocks()

.mockRestore() // 스파이 자체를 지워버림
jest.restoreAllMocks()

 

 

모듈 모킹

jest.mock 메소드를 사용하면 특정 경로에서 가져오는 모듈을 모킹할 수 있다. 이를 통해 해당 모듈의 실제 구현을 대체하여 테스트 중 원하는 동작을 시뮬레이션하거나, 호출 추적 및 값을 제어할 수 있다. 아무 구현도 제공하지 않으면, 모듈의 모든 함수 및 메소드는 기본적으로 jest.fn()으로 덮어씌워진다.

메소드의 두 번째 인자로 팩토리 함수를 제공하면, 이를 통해 모듈의 일부 함수만을 모킹하도록 지정할 수 있다. 모듈 모킹은 필연적으로 전역 변수의 값을 덮어쓰기 때문에, 테스트 라이프사이클을 활용해 테스트가 끝날 때마다 모듈을 초기화해주는 것이 바람직하다.

// 모듈 모킹
jest.mock('caro-kann')

// 모듈 일부만 모킹
import module from 'caro-kann';
jest.mock('caro-kann', () => {
  return {
    ...jest.requireActual('caro-kann'),
    method1: jest.fn(module.method1),
  }
})

// 타입스크립트는 obj를 모킹되지 않은 타입으로 추론한다
jest.mock('./module'); import { obj } from './module';

test('module', () => {
  (obj.method as jest.Mock).mockImplementation((callback, num) => callback(num));
  expect(obj.method((num: number) => num, 3)).toBe(3);
})

test("module2", () => {
  // afterEach으로 인해 obj.method가 () => void 로 다시 돌아가버린 상태
  expect(obj.method((num: number) => num, 3)).toBe(3) // Expected: 3
                                                      // Received: undefined
})

// 초기화
afterEach(() => {
  jest.clearAllMocks(); // 모의 함수 호출 기록 초기화
  jest.resetModules();  // 모듈 캐시 초기화
});

 

 

비동기 테스트

비동기 함수를 테스트하기 위해서는 리턴되는 값으로부터 promise를 제거해야 한다. 이때 jest에서는 resolves를 통해 promise를 벗겨낸다. 하지만 resolves가 아니래도 promise chain이나 async/await을 사용하여 promise를 벗겨내도 괜찮다. Promise.reject의 경우 rejects를 사용하거나, then 대신 catch를 사용하는 식으로 테스트할 수 있다.

// resolves
test('promiseResolve1', () => {
  return expect(promiseResolve()).resolves.toBe('resolved')
})

// promise chain
test('promiseResolve2', () => {
  return promiseResolve().then((result) => {
    expect(result).toBe('resolved')
  })
})

// async-await
test('promiseResolve3', async () => {
  const result = await promiseResolve();
  expect(result).toBe('resolved');
})

 

비동기 함수를 모킹할 때는 mockReturnValue 대신 mockResolvedValue와 mockRejectedValue를 사용한다.

test('promiseResolve', () => {
  jest.spyOn(obj, "promiseResolve").mockResolvedValue('resolved')
  return expect(obj.promiseResolve).resolves.toBe('resolved')
})

test('promiseReject', () => {
  jest.spyOn(obj, "promiseReject").mockRejectedValue('rejected')
  return expect(obj.promiseReject).rejects.toBe('rejected')
})

 

 

에러 테스트

// 에러 테스트
test('error', () => {
  expect(() => error()).toThrow(Error)
})

test('error', () => {
  try {
    error()
  } catch (e) {
    expect(e).toBeInstanceOf(Error)
  }
})

 

 

날짜/시간 테스트

시간을 동적으로 가져오는 코드는 실제 시스템 시간에 의존하기 때문에 테스트 결과가 일관되지 않을 수 있다. 때문에 jest는 useFakeTimers 메소드를 사용해 가상의 타이머를 만들고 테스트에 사용되는 시간을 고정시킨다. 이를 통해 예측 가능성과 일관성을 확보할 수 있다.

그 외에도 다양한 메소드를 사용해 타이머를 조작할 수 있다. 덕분에 setTimeout과 같은 로직이 있다 해도 몇 초씩 기다릴 필요 없이 즉시 타이머를 실행하거나 조작하여 테스트를 빠르게 진행할 수 있다.

test("", () => {
  jest.useFakeTimers().setSystemTime(new Date(2024, 11, 1)) // 가짜 타이머 생성
  expect(after3days()).toStrictEqueal(new Date(2024, 11, 4))
  jest.useRealTimers() // 가짜 타이머 없애기
})

jest.getRealSystemTime() // 가짜 타이머 기간 동안 진짜 시간 얻기
jest.runAllTicks() // micro-task queue 고갈(promise)
jest.runAllTimers() // macro-task queue 고갈(setTimeout, setInterval, setImmediate)
jest.clearAllTimers() // 모든 타이머를 제거
jest.advanceTimersByTime(msToRun) // 일정 시간 스킵

 

블로그의 정보

Ayden's journal

Beard Weard Ayden

활동하기