이번 우테코 프리코스 1주차 과제 덕에 Jest와 테스트코드에 대해 처음 접해보게 되었다. 과제에서 주어진 테스트 코드 내용을 이해해보고자 한 줄씩 뜯어보기로 함.
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]");
});
});
async
와 await
이란?async
와 await
은 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()
는 프로미스가 에러를 던지지 않아야 함을 검증한다.
결과적으로,
async
와await
을 사용하면 비동기 작업을 동기식으로 작성하고, 해당 작업의 완료를 기다려서 테스트가 올바르게 동작하도록 만들 수 있다. 이를 통해 테스트 시나리오에서 비동기 작업의 상태를 관리하고, 비동기 코드의 예외 처리를 테스트할 수 있게 된다.
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`를 모의 함수로 설정한다.
테스트를 특정 시나리오에 맞게 조작하고, 특정 입력을 제공하여 애플리케이션 동작을 테스트하는 데 도움이 되기 때문.
- 테스트 가능성 : 실제 입력을 기다릴 필요 없이 원하는 입력 값을 즉시 제공할 수 있어서 테스트를 빠르게 실행할 수 있다.
- 일관성 : 모의 함수를 사용하면 항상 일정한 입력을 제공할 수 있으므로,여러 번 테스트를 실행해도 동일한 조건에서 테스트를 수행할 수 있다.
- 제어 : 원하는 테스트 케이스를 시뮬레이션 하기 위해 입력 값을 제어할 수 있다.
- 외부 의존성 제거 : 실제 외부 의존성에 의존하지 않고 테스트할 수 있기 때문에 테스트 실행 환경을 안정화하고 테스트를 더 예측 가능하게 만든다.
MissionUtils.Console.readLineAsync.mockImplementation(() => {
const input = inputs.shift();
return Promise.resolve(input);
});
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);
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;
jest.spyOn(MissionUtils.Console, "print");
:jest.spyOn
함수를 사용하여MissionUtils.Console
객체의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("게임 종료 후 재시작", async () => { ... })
: Jest의 test
함수를 사용하여 첫 번째 테스트 케이스를 정의한다. 이 테스트는 "게임 종료 후 재시작" 시나리오를 검증하는 테스트이다.
const app = new App();
: App
클래스의 인스턴스를 생성한다.
- 인스턴스란? : 객체 지향 프로그래밍(OOP)에서 "인스턴스"란, 클래스(또는 생성자 함수)를 기반으로 생성된 구체적인 객체를 가리킨다. 클래스는 객체를 만들기 위한 템플릿 또는 설계도 역할을 하며, 클래스로부터 생성된 실제 객체가
인스턴스
이다.🚗 예를 들어 "자동차" 클래스를 정의하면 "자동차" 클래스의
인스턴스
로 여러 대의 실제 자동차 객체를 생성할 수 있다. 각 자동차 객체는 자신만의 속도, 색상, 모델 등의 상태를 가지며, 클래스의 메서드를 사용하여 가속, 감속, 정지 등의 동작을 수행할 수 있다.
인스턴스는 OOP의 핵심 개념 중 하나로, 코드를 구조화하고 객체 지향적으로 문제를 해결하는 데 사용된다.
- 상태 관리 : 인스턴스는 클래스이 속성(멤버 변수)을 포함하며, 이를 통해 객체의 상태를 저장하고 관리할 수 있다. 예를 들어,
App
클래스의 인스턴스가 게임 상태를 저장하고 유지할 수 있는 것이다.- 상호작용 :
App
클래스의 메서드를 호출하기 위해 인스턴스를 생성하면, 해당 메서드를 통해 애플리케이션의 다양한 동작을 실행할 수 있다. 이를 통해 사용자와 상호작용하거나 애플리케이션 로직을 수행할 수 있다.- 테스트 : 인스턴스를 생성하여 애플리케이션의 특정 기능 또는 동작을 테스트하기 용이하다. 애플리케이션의 정확성과 신뢰성을 검증하는 데 중요하다.
await expect(app.play()).resolves.not.toThrow();
: app.play()
메서드를 호출하고, 해당 호출이 예외를 발생시키지 않는지를 테스트한다. expect
함수를 사용하여 비동기 작업을 테스트하고, resolves
를 사용하여 프로미스가 성공적으로 완료되는지 확인한다.
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]")
: 두 번째 테스트 케이스인 "예외 테스트"도 비슷한 방식으로 작동하며, 예외를 발생시키는지 확인하는 테스트이다.
expect(app.play())
는app.play()
메서드의 반환값인 프로미스를 테스트 대상으로 설정한다.rejects
는 이 프로미스가 에러를 발생시키는지를 확인한다. 다시 말해,app.play()
메서드가 예외를 던지는 상황을 테스트한다.toThrow("[ERROR]")
는 예외가 발생할 경우 해당 예외 메시지가"[ERROR]"
와 일치해야 테스트가 통과된다. 즉,app.play()
메서드가 발생한 예외의 메시지가"[ERROR]"
와 일치해야 테스트가 통과된다.
🔆 TIL
테스트 코드의 동작 방식을 이해하지 못한 채App.js
의 코드를 수정하려고 하다보니 한계에 부딪혀서 테스트 코드를 한 줄씩 뜯어보게 되었다. 흐름을 이해하고 나니 어디서 어떻게 수정해야 할지 조금씩 감이 잡히기 시작하는 것 같다.
사용자 입력값이 0번째 인덱스만 반복해서 호출되어 무한 루프에 빠지는 것이 곤란한 상황이었는데,mockQuestions
변수에 대한 이해도와 함께 해결책을 찾아보면 해답이 나오지 않을까 싶다! (please..)