[우아한테크코스 6기 프리코스] Jest를 활용하여 테스트코드 작성하기

재오·2023년 11월 1일
1
post-thumbnail

우아한테크코스 6기 프리코스 2주차 문제를 풀어보면서 정리한 글입니다.
아직 부족한 부분이 많기에 내용에 오류가 있다면 알려주시면 감사하겠습니다.


🤷🏻 Test 코드 작성이 필요해...?

코딩 테스트 문제를 풀 때에도 테스트 케이스는 항상 정해져 있었기 때문에 테스트 코드를 작성하는 것은 나의 역할이 아니라고 생각했다. 만약 직접 Test 코드를 작성하게 된다면 능동적인 학습이 가능하고, 다양한 예외 케이스를 미리 생각해볼 수 있을 것 같았다. 테스트 코드 작성이 어떤 이점이 있는지 한번 찾아봤다.

보통 이러한 작업을 Test-Driven Development, TDD 라고 한다. TDD는 테스트가 중심이 되는 개발 방법이고 디버깅을 할 때 시간을 매우 단축시켜준다. 또한 테스트 코드 내에서 직접 설계 의도와, 설명을 적어줄 수 있어서 문서를 대체하는 기능을 한다.

그 중 Facebook에서 개발한 JavaScript 테스팅 프레임워크인 Jest에 대해 알아보자.

📂 ApplicationTest.js 분석하기

Jest 공식 문서에 들어가면 자세하게 설명이 되어있긴 하지만 관련 함수나 기능이 무수하게 많기 때문에 무엇부터 시작해야 할지 막막하다. 다행스럽게도 우아한테크코스에서 ApplicationTest.js라는 파일을 제공해주었기 때문에 이 코드를 분석한다면 전반적인 Jest 의 흐름을 파악하기 쉬울 것이다.
같이 한번 살펴보자.

⚙️ mockQuestions

const mockQuestions = (inputs) => {
  MissionUtils.Console.readLineAsync = jest.fn();
  MissionUtils.Console.readLineAsync.mockImplementation(() => {
    const input = inputs.shift();
    return Promise.resolve(input);
  });
};

1주차 미션에서 분석했듯이 Console.readLineAsync는 우아한테크코스에서 제공해준 API이다. Promise() 를 이용해 비동기적으로 입력값을 받는 함수를 의미한다. 이 함수를 jest.fn() 로 만든다는 의미이다.

그렇다면 jest.fn()은 무엇일까... 공식문서를 찾아보면 해당 함수를 가짜 함수로 만들어준다는 것을 의미한다.

왜 해당 함수를 가짜 함수로 만들어줄까? 실제 함수를 사용해서 테스트 케이스를 돌리게 된다면 부분적으로 단위 테스트를 할 때 위험 부담이 너무 커진다. 만약 데이터를 삭제하는 함수라면 실제 함수로 테스트를 돌렸을 때 모든 데이터가 삭제될 수도 있기 때문이다. 그래서 가짜 함수를 사용한다.

OK... 가짜 함수로 제작해주었다. 그 뒤에는 mockImplementation() 낯선 함수가 나타났다. 여기서 mock가짜를 의미한다. 가짜 함수는 기본적으로 아무 것도 들어가 있지 않다. 따라서 결과를 리턴하지도 않는다. 하지만 mockImplemetation()는 함수를 즉석으로 구현할 수 있다. 동작하는 가짜 함수를 만드는 것이라고 보면 된다.

➡️ 정리하자면 mockQuestions() 함수는 인자로 들어온 배열을 한칸 씩 왼쪽으로 밀어내며 그 값을 비동기 함수의 리턴 값으로 정하는 것이다.

⚙️ mockRandoms

const mockRandoms = (numbers) => {
  MissionUtils.Random.pickNumberInRange = jest.fn();
  numbers.reduce((acc, number) => {
    return acc.mockReturnValueOnce(number);
  }, MissionUtils.Random.pickNumberInRange);
};

앞에서 설명한 부분은 간략하게만 짚고 넘어가자. 우선 랜덤으로 값을 지정하는 함수를 가짜 함수로 만든다. mockReturnValueOnce 함수는 가짜 함수의 리턴 값을 사용자의 지정 값으로 대체해주는 역할을 해준다.

⚙️ getLogSpy

const getLogSpy = () => {
  const logSpy = jest.spyOn(MissionUtils.Console, "print");
  logSpy.mockClear();
  return logSpy;
};

스파이 는 몰래 정보를 캐내는 역할을 한다. 테스트를 작성할 때도 어떤 객체에 속한 함수의 구현을 가짜로 대체하지 않고, 해당 함수의 호출 여부와 어떻게 호출되었는지만을 알아내야 할 때가 있다.

jest.spyOn(a, b) 라면 MissionUtils.Console.print() 에 스파이를 붙인다. 그리고 mockClear() 은 스파이의 기록을 초기화하는 명령입니다. 다른 테스트 케이스 사이에서 스파이의 상태를 클리어한다. 함수의 호출 횟수와 어떤 인자가 넘어갔는지에 대한 정보에 대해 초기화하는 작업이라고 생각하면 된다.

가장 중요한 점은 가짜 함수로 대체한 것이 아니기 때문에 본 함수를 호출해야 한다.

⚙️ mockQuestions & mockRandoms를 이용하여 ApplicationTest 분석

test("전진-정지", async () => {
    // given
    const MOVING_FORWARD = 4;
    const STOP = 3;
    const inputs = ["pobi,woni", "1"];
    const outputs = ["pobi : -"];
    const randoms = [MOVING_FORWARD, STOP];
    const logSpy = getLogSpy();

    mockQuestions(inputs);
    mockRandoms([...randoms]);

    // when
    const app = new App();
    await app.play();

    // then
    outputs.forEach((output) => {
      expect(logSpy).toHaveBeenCalledWith(expect.stringContaining(output));
    });
  });
 

// given 파트를 살펴보면 mock 함수를 만들기 전에 기본적인 값을 정해준다. 해당 테스트는 전체 결과가 나오는 것이기 때문에 logSpy를 사용한다. 이후에는 app.play()를 사용하여 테스트를 할 타이밍을 코드로 입력해주었다. 해당 코드를 실행한 이후에 결과 부분을 // then 에서 구현해주었다. pint를 실행했을 때 기대했던 함수가 실행이 되었고 그 출력 값 안에 output이 들어있다면 성공한 케이스를 의미한다.

test.each([[["pobi,javaji"]], [["pobi,eastjun"]]])(
    "이름에 대한 예외 처리",
    async (inputs) => {
      // given
      mockQuestions(inputs);

      // when
      const app = new App();

      // then
      await expect(app.play()).rejects.toThrow("[ERROR]");
    }
  );

📂 descibe와 Test Matcher

describe : 테스트 그룹을 묶어주는 역할을 하고 그 안의 콜백함수 내에 쓰일 가짜 변수, 객체들을 선언하여 일회용으로 사용할 수 있다.

to... : 이 부분은 Test Matcher 라고 한다. 간단하게 설명하자면 “이거 맞아?”라고 물어보는 메서드라고 생각하면 된다.

  • toEqual() : 객체가 일치한지 검증한다. 왜만한 일치를 비교할 때 사용한다.
  • toBe() : 단순히 값 비교
  • toBeTruthy() / toBeFalsy() : 자바스크립트는 숫자 1이 true로 간주되고, 숫자 0이 false로 간주되는 것과 같이, 모든 타입의 값들을 true, false로 간주하는 규칙이 있다. toBeTruthy() 는 검증 대상이 규칙에 따라 true로 간주되면 테스트 통과
  • toBeCalled() : 함수가 호출되었는지 여부
  • toHaveLength() : 배열의 경우 배열의 길이를 체크
  • toContain() : 배열의 경우 특정 원소가 존재 여부를 테스트하는 경우
  • toMatch() : 문자열의 경우에는 단순히 toBe() 를 사용해서 문자열이 정확히 일치하는 지를 체크하지만, 정규식 기반의 테스트가 필요할 때 toMatch() 함수를 사용한다.
test("string", () => {
  expect(getUser(1).email).toBe("user1@test.com"); // 단순 문자열 비교
  expect(getUser(2).email).toMatch(/.*test.com$/); // 정규식 비교
});
  • toThrow() : 예외 발생 여부를 테스트해야할 때 사용한다. toThrow() 함수는 인자도 받는데, 문자열을 넘기면 예외 메세지를 비교하고, 정규식을 넘기면 정규식 체크를 해준다. 주의해야할 점은 expect() 함수에 넘기는 검증 대상을 함수로 한 번 감싸줘야 한다.

📂 직접 테스트 케이스를 만들어보자

test("이름에 공백이 포함되어 있는 경우를 확인", () => {
    const nameInput = [" pobi", "ju ne", "   w"];

    nameInput.forEach((name) => {
      expect(() => {
        InputValidator.validateCarName(name);
    }).toThrow();
  });
});

앞에서도 설명했듯 expect()는 함수로 한번 감싸줘야 한다. 이 점에 유의해서 함수를 작성했다. 앞에 작성된 코드보다 조금 더 직관적으로 이해하기 쉽게 만들었다.

describe("Car 클래스 테스트", () => {
  test("Car 클래스의 인스턴스 생성 확인", () => {
    const inputNames = ["pobi", "june", "wang"];
    const cars = inputNames.map((name) => new Car(name));

    cars.forEach((car, index) => {
      expect(car.getName()).toEqual(inputNames[index]);
    });
  });
}

위 코드는 직접 작성한 클래스의 메서드의 기능을 테스트하는 작업이다. 고정적으로 선언한 배열을 map() 함수로 Car 클래스의 인스턴스를 생성하였다. 그리고 Car 클래스가 갖고있는 getName() 메서드를 잘 생성할 수 있는지를 forEach() 함수로 순회하였다. 이미 정해진 결과값을 비교하는 것이므로 toEqual() Matcher을 이용하였다.

📑 참고자료

Jest의 jest.fn(), jest.spyOn()를 이용한 함수 모킹
Jest 공식 문서

profile
블로그 이전했습니다

0개의 댓글