TIL - 우테코6기 프리코스 1주차 Jest 테스트 코드

신혜린·2023년 10월 23일
11

TIL

목록 보기
14/19
post-thumbnail

이번 우테코 프리코스 1주차 과제 덕에 Jest와 테스트코드에 대해 처음 접해보게 되었다. 과제에서 주어진 테스트 코드 내용을 이해해보고자 한 줄씩 뜯어보기로 함.


ApplicationTest.js

import App from "../src/App.js";
import { MissionUtils } from "@woowacourse/mission-utils";

const mockQuestions = (inputs) => {
  MissionUtils.Console.readLineAsync = jest.fn();

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

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

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

describe("숫자 야구 게임", () => {
  test("게임 종료 후 재시작", async () => {
    // given
    const randoms = [1, 3, 5, 5, 8, 9];
    const answers = ["246", "135", "1", "597", "589", "2"];
    const logSpy = getLogSpy();
    const messages = ["낫싱", "3스트라이크", "1볼 1스트라이크", "3스트라이크", "게임 종료"];

    mockRandoms(randoms);
    mockQuestions(answers);

    // when
    const app = new App();
    await expect(app.play()).resolves.not.toThrow();

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

  test("예외 테스트", async () => {
    // given
    const randoms = [1, 3, 5];
    const answers = ["1234"];

    mockRandoms(randoms);
    mockQuestions(answers);

    // when & then
    const app = new App();

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

🤔 그 전에, asyncawait이란?

asyncawait은 JavaScript에서 비동기 코드를 다루는 데 사용되는 키워드이다. 이들은 비동기 동작을 보다 직관적으로 작성하고 관리할 수 있도록 돕는다.

  • async : 함수 정의 앞에 붙어서 해당 함수가 비동기 함수임을 나타낸다.
  • await : 비동기 작업이 완료될 때까지 함수의 실행을 일시 중단 시키는 역할을 한다. await 다음에는 일반적으로 프로미스를 반환하는 비동기 함수가 오며, 해당 비동기 작업이 완료되면 결과를 반환한다.
test("게임 종료 후 재시작", async () => {
    // ...

    // when
    const app = new App();
    await expect(app.play()).resolves.not.toThrow();
});
  • async () => { ... } : 테스트 케이스를 비동기 함수로 정의하는 부분. app.play() 메서드가 비동기 작업을 수행하기 때문에 async를 사용하여 해당 비동기 작업을 다룰 수 있도록 한다.
  • await expect(app.play()) : await 키워드를 사용하여 app.play() 메서드의 실행을 기다린다. 이렇게 하면 app.play() 메서드가 완료될 때까지 테스트가 일시 중단된다.
  • .resolves.not.toThrow() : expect 함수를 사용하여 app.play() 메서드의 반환 값을 테스트하고, 이 프로미스가 성공적으로 완료되는지 확인한다. .not.toThrow()는 프로미스가 에러를 던지지 않아야 함을 검증한다.

결과적으로, asyncawait을 사용하면 비동기 작업을 동기식으로 작성하고, 해당 작업의 완료를 기다려서 테스트가 올바르게 동작하도록 만들 수 있다. 이를 통해 테스트 시나리오에서 비동기 작업의 상태를 관리하고, 비동기 코드의 예외 처리를 테스트할 수 있게 된다.


💻 한 줄씩 뜯어보기

const mockQuestions = (inputs) => { ... }

: mockQuestions 함수를 정의한다. 이 함수는 MissionUtils.Console.readLineAsync를 Jest의 모의함수(mock.fn())로 설정하여 입력 값을 제어할 수 있게 한다.

const mockRandoms = (numbers) => { ... }

: mockRandoms 함수를 정의한다. 이 함수는 MissionUtils.Random.pickNumberInRange를 Jest의 모의함수(mock.fn())로 설정하여 난수를 제어할 수 있게 한다.

const randoms = [1, 3, 5, 5, 8, 9];
// `randoms` 배열을 정의하고, 난수를 나타내는 숫자들을 포함한다.
const answers = ["246", "135", "1", "597", "589", "2"];
// `answers` 배열을 정의하고, 사용자 입력을 나타내는 문자열을 포함한다.
mockRandoms(randoms);
// `mockRandoms` 함수를 호출하여 `MissionUtils.Random.pickNumberInRange`를 모의 함수로 설정한다.
mockQuestions(answers);
// `mockQuestions` 함수를 호출하여 `MissionUtils.Console.readLineAsync`를 모의 함수로 설정한다.

💡 모의함수(mock.fn())로 설정하는 이유?

테스트를 특정 시나리오에 맞게 조작하고, 특정 입력을 제공하여 애플리케이션 동작을 테스트하는 데 도움이 되기 때문.

  • 테스트 가능성 : 실제 입력을 기다릴 필요 없이 원하는 입력 값을 즉시 제공할 수 있어서 테스트를 빠르게 실행할 수 있다.
  • 일관성 : 모의 함수를 사용하면 항상 일정한 입력을 제공할 수 있으므로,여러 번 테스트를 실행해도 동일한 조건에서 테스트를 수행할 수 있다.
  • 제어 : 원하는 테스트 케이스를 시뮬레이션 하기 위해 입력 값을 제어할 수 있다.
  • 외부 의존성 제거 : 실제 외부 의존성에 의존하지 않고 테스트할 수 있기 때문에 테스트 실행 환경을 안정화하고 테스트를 더 예측 가능하게 만든다.


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

💡 mockQuestions()

  • mockImplementation : 함수를 모의(mock)하고 동작을 재정의(reimplement)하는 부분으로, MissionUtils.Console.readLineAsync 함수는 사용자 입력을 모의로 시뮬레이션하고, 특정 입력 값들을 순선대로 반환하도록 설정된다.
  • const input = inputs.shift(); : inputs 배열에서 다음 입력 값을 가져오기 위해 shift() 메서드를 사용한다. shift() 메서드는 배열에서 첫 번째 요소를 제거하고 반환하며, 이로써 input 변수에 다음 입력 값이 할당된다.
  • return Promise.resolve(input); : input 값을 Promise.resolve()를 사용하여 프로미스로 래핑하여 반환한다. MissionUtils.Console.readLineAsync 함수가 비동기 함수인 것처럼 동작하도록 만들고, 해당 프로미스가 해결(resolve)되면 사용자 입력 값이 반환된다.


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

💡 mockRandoms()

  • reduce 함수를 사용하여 numbers 배열을 순회하면서 MissionUtils.Random.pickNumberInRange 함수를 모의(mock) 한다. reduce 함수를 사용하여 배열을 순회하고 누적된 결과를 생성하는 것이 일반적인 패턴.
  • return acc.mockReturnValueOnce(number); : 각 반복에서 MissionUtils.Random.pickNumberInRange 함수를 모의(mock)한 객체(acc)에 대해 mockReturnValueOnce 함수를 호출하여, 매번 다른 number 값을 반환하도록 설정한다. 이렇게 하면 MissionUtils.Random.pickNumberInRange 함수가 호출될 때 numbers 배열의 요소를 순서대로 반환하게 된다.


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

💡 logSpy 변수

  • jest.spyOn(MissionUtils.Console, "print"); : jest.spyOn 함수를 사용하여 MissionUtils.Console 객체의 print 메서드를 스파이로 설정한다. 이렇게 하면 해당 메서드의 호출을 모니터링하고, 호추 정보와 호출 횟수를 기록할 수 있다.
  • logSpy.mockClear(); : 스파이 객체의 mockClear 메서드를 호출하여 스파이의 동작을 초기화한다. 이것은 이전 테스트에서 스파이에 의해 기록된 모든 호출 정보를 지우고, 스파이를 초기 상태로 되돌린다.
    => 이를 통해 각 테스트 간에 영향을 주지 않도록 한다.
  • return logSpy; : 초기화된 스파이 객체를 반환한다. 이렇게 반환된 스파이 객체는 향후 테스트에서 MissionUtils.Console.print 함수의 호출을 모니터링하고 확인하는 데 사용된다.


const getLogSpy = () => { ... }
///
const logSpy = getLogSpy();

: getLogSpy 함수를 정의한다. 이 함수는 MissionUtils.Console.print를 Jest의 스파이(Spy)로 설정하여 로그 출력을 모니터링할 수 있게 한다.
: getLogSpy 함수를 호출하여 logSpy 라는 스파이 객체를 생성하고, 이 스파이는 MissionUtils.Console.print 함수를 감시하게 된다.

💡 로그 출력을 모니터링하는 이유?

  • 로그 출력 확인 및 테스트 검증 : 로그 출력을 모니터링함으로써 특정 동작 또는 함수 호출에 대한 로깅 메시지가 예상대로 수행되는지 확인할 수 있다. 이를 통해 애플리케이션 동작의 일관성과 정확성을 검증할 수 있다.
  • 디버깅 : 스파이를 사용하면 특정 시점에서 로그 메시지가 출력되는지 확인할 수 있으므로, 문제가 발생한 경우 디버깅에 도움이 된다.
    => 테스트의 일관성과 정확성을 확보하고, 로깅 동작을 확인하여 애플리케이션의 정확성과 신뢰성을 높이기 위함이다.


describe("숫자 야구 게임", () => { ... })

: Jest의 describe 함수를 사용하여 테스트 스위트(또는 테스트 스위트 블록)를 정의한다. 이 테스트 스위트는 "숫자 야구 게임"에 대한 여러 테스트 케이스를 그룹화하는 역할을 한다.

💡 describe 함수의 테스트 스위트의 주요 개념

  • 테스트 스위트(Test Suite) : 테스트 스위트는 연관된 테스트 케이스를 그룹화하는 데 사용된다. 예를 들어, 하나의 테스트 스위트는 특정 모듈, 함수, 또는 기능에 대한 모든 테스트 케이스를 포함할 수 있다. 이것은 테스트를 논리적으로 조직하고 분류하는데 도움이 된다.
  • 테스트 케이스(Test Case) : 테스트 케이스는 특정 동작 또는 조건을 검증하기 위한 테스트의 단위이다. 각 테스트 케이스는 test 함수를 사용하여 정의하며, 이 함수는 특정 동작을 테스트하는 코드를 포함한다.
  • 그룹화와 조작화 : describe 함수를 사용하여 테스트 케이스를 그룹화하고 테스트 스위트를 조직화할 수 있다. 이를 통해 특정 주제나 모듈에 대한 테스트를 관리하기 쉽게 만들 수 있다.
  • 테스트 실행 및 리포팅 : Jest는 describe 함수를 사용하여 정의된 테스트 스위트 내의 모든 테스트 케이스를 실행하고 결과를 리포팅한다. 각 테스트 케이스는 test 함수에 의해 실행되며, 결과가 성공 또는 실패로 보고된다.
    => describe 함수는 테스트 스위트를 정의하고 그 안에 여러 테스트 케이스를 그룹화하기 위한 기본 도구로, 그룹화와 조직화를 통해 테스트 코드를 보다 명확하고 유지보수하기 쉽게 만들며, 특정 모듈 또는 기능을 테스트하는 데 도움이 된다.


💡 test 함수

test("게임 종료 후 재시작", async () => { ... })

: Jest의 test 함수를 사용하여 첫 번째 테스트 케이스를 정의한다. 이 테스트는 "게임 종료 후 재시작" 시나리오를 검증하는 테스트이다.



const app = new App();

: App 클래스의 인스턴스를 생성한다.

💡 인스턴스 생성 이유?

  • 인스턴스란? : 객체 지향 프로그래밍(OOP)에서 "인스턴스"란, 클래스(또는 생성자 함수)를 기반으로 생성된 구체적인 객체를 가리킨다. 클래스는 객체를 만들기 위한 템플릿 또는 설계도 역할을 하며, 클래스로부터 생성된 실제 객체가 인스턴스이다.

🚗 예를 들어 "자동차" 클래스를 정의하면 "자동차" 클래스의 인스턴스로 여러 대의 실제 자동차 객체를 생성할 수 있다. 각 자동차 객체는 자신만의 속도, 색상, 모델 등의 상태를 가지며, 클래스의 메서드를 사용하여 가속, 감속, 정지 등의 동작을 수행할 수 있다.
인스턴스는 OOP의 핵심 개념 중 하나로, 코드를 구조화하고 객체 지향적으로 문제를 해결하는 데 사용된다.


  • 상태 관리 : 인스턴스는 클래스이 속성(멤버 변수)을 포함하며, 이를 통해 객체의 상태를 저장하고 관리할 수 있다. 예를 들어, App 클래스의 인스턴스가 게임 상태를 저장하고 유지할 수 있는 것이다.
  • 상호작용 : App 클래스의 메서드를 호출하기 위해 인스턴스를 생성하면, 해당 메서드를 통해 애플리케이션의 다양한 동작을 실행할 수 있다. 이를 통해 사용자와 상호작용하거나 애플리케이션 로직을 수행할 수 있다.
  • 테스트 : 인스턴스를 생성하여 애플리케이션의 특정 기능 또는 동작을 테스트하기 용이하다. 애플리케이션의 정확성과 신뢰성을 검증하는 데 중요하다.


await expect(app.play()).resolves.not.toThrow();

: app.play() 메서드를 호출하고, 해당 호출이 예외를 발생시키지 않는지를 테스트한다. expect 함수를 사용하여 비동기 작업을 테스트하고, resolves를 사용하여 프로미스가 성공적으로 완료되는지 확인한다.

💡 await expect (.not.toThrow())

  • app.play() : App 클래스(또는 해당 애플리케이션)의 메서드로, 게임을 시작하고 실행하는 역할을 한다. 이 메서드는 숫자 야구 게임과 관련된 로직을 실행하고, 사용자 입력을 받아 게임을 진행하며, 게임이 종료되면 결과를 반환한다.
  • expect : Jest에서 사용되는 함수로, 코드 블록을 실행하고 특정 조건을 검사하여 테스트의 성공 또는 실패를 판단하는 역할을 한다. 주로 테스트의 예상 결과를 검증하는데 사용된다.
  • resolves : expect 함수의 매쳐(matcher) 중 하나로, 비동기 작업에서 프로미스가 성공적으로 완료되는지를 검증한다. resolves를 사용하면 비동기 작업이 예상한 대로 성공적으로 처리되는지 확인할 수 있다.
  • 프로미스(Promise) : 프로미스는 비동기 작업을 처리하기 위한 JavaScript 객체이다. 프로미스는 특정 작업이 완료되었을 때 또는 실패했을 때 처리할 콜백함수를 등록할 수 있다. then 메서드를 사용하여 성공 시 처리할 콜백함수와 실패 시 처리할 콜백함수를 지정할 수 있다. 또한, 프로미스 체인을 통해 여러 비동기 작업을 순차적 또는 병렬로 처리할 수 있다.

  • expect(app.play())app.play() 메서드의 반환값인 프로미스를 테스트 대상으로 설정한다.
  • resolves 는 이 프로미스가 성공적으로 완료되는지를 확인한다. 다시 말해, app.play() 메서드가 에러 없이 정상적으로 실행되는지 검사한다.
  • not.toThrow()는 테스트를 통과하려면 app.play() 메서드가 예외를 던지면 안 되는지를 검사한다. 즉, app.play() 메서드는 예외를 던지지 않아야 테스트가 통과된다.


const messages = ["낫싱", "3스트라이크", "1볼 1스트라이크", "3스트라이크", "게임 종료"];
messages.forEach((output) => {
      expect(logSpy).toHaveBeenCalledWith(expect.stringContaining(output));
    });

: messages 배열에 포함된 각 메시지에 대해서, 예상된 메시지가 logSpy로 출력되는지 확인한다.

💡 logSpy 출력

messages 배열은 테스트 코드에서 사용되는 배열로, 테슽 동작의 결과를 기대한 메시지와 비교하기 위해 사용된다. 각 메시지가 logSpy로 출력되는지 확인하는 목적으로 사용된다.

  • expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("낫싱"))와 같은 코드를 사용하여 logSpy가 "낫싱" 메시지를 포함하는 문자열을 출력했는지를 검증할 수 있다.


await expect(app.play()).rejects.toThrow("[ERROR]")

: 두 번째 테스트 케이스인 "예외 테스트"도 비슷한 방식으로 작동하며, 예외를 발생시키는지 확인하는 테스트이다.

💡 await expect (.rejects.toThrow())

  • expect(app.play())app.play() 메서드의 반환값인 프로미스를 테스트 대상으로 설정한다.
  • rejects 는 이 프로미스가 에러를 발생시키는지를 확인한다. 다시 말해, app.play() 메서드가 예외를 던지는 상황을 테스트한다.
  • toThrow("[ERROR]") 는 예외가 발생할 경우 해당 예외 메시지가 "[ERROR]"와 일치해야 테스트가 통과된다. 즉, app.play() 메서드가 발생한 예외의 메시지가 "[ERROR]"와 일치해야 테스트가 통과된다.


🔆 TIL
테스트 코드의 동작 방식을 이해하지 못한 채 App.js의 코드를 수정하려고 하다보니 한계에 부딪혀서 테스트 코드를 한 줄씩 뜯어보게 되었다. 흐름을 이해하고 나니 어디서 어떻게 수정해야 할지 조금씩 감이 잡히기 시작하는 것 같다.
사용자 입력값이 0번째 인덱스만 반복해서 호출되어 무한 루프에 빠지는 것이 곤란한 상황이었는데, mockQuestions 변수에 대한 이해도와 함께 해결책을 찾아보면 해답이 나오지 않을까 싶다! (please..)

profile
개 발자국 🐾

0개의 댓글