Home
All Posts
📟

[우아한테크코스] 프리코스 테스트코드 분석

Created
2023/11/24
Tags
Test
Category
Project
2 more properties

테스트 코드를 분석하는 이유

문제를 풀어내기 위해서는 그 문제에서 요구하는 바를 정확히 알아야 하기 때문이다. 코딩 테스트에서는 기능 요구 사항과 더불어, 테스트 코드에서 요구하는 바를 정확하고 빠르게 알아야 하기에 먼저 코드를 샅샅이 파헤쳐보고자 한다. 더 나아가, 앞으로 단위 테스트뿐만 아니라 직접 애플리케이션 코드도 짜보고 싶었다.
프리코스 기간에는 '그렇구나~' 하고 넘겼던 테스트 코드를 프리코스 종료 후에 시간을 내어 깊게 파보았다. 더불어, Jest나 테스트 코드 관련 개념도 함께 공부해 보았다. 참고로 전체 테스트 코드는 우아한테크코스 repo에서도 볼 수 있다.

시작! 일단 불러오기

// App 컴포넌트를 불러옴 import App from '../src/App.js'; // @woowacourse/mission-utils 라이브러리에서 MissionUtils 객체를 불러옴 import { MissionUtils } from '@woowacourse/mission-utils';
JavaScript
복사
우선, 테스트하고자 하는 컴포넌트를 불러오고, 우테코에서 제공하는 라이브러리에서 MissionUtils라는 객체를 불러온다.

사용자 입력값에 대해 테스트하기 - mock 이란?

이 테스트는 단순히 '하나의 메서드가 잘 작동하느냐'를 검증하는 것이 아니라, 사용자가 인풋을 넣었을 때 그에 맞는 아웃풋이 나오는지를 검증하는 것이다. 그런데 여기서 사용자의 인풋을 어떻게 만들까?
바로 '모의(mock)'하는 방법이 있다. 즉, 흉내 내어 실제처럼 해보는 것이다.
“모의 함수를 사용하면 함수의 실제 구현을 삭제하고, 코드 간의 상호 작용을 쉽게 테스트할 수 있습니다. mock 함수를 사용하면 호출 시 함수에 전달되는 인자와 함께 new를 통해 생성할 때 인스턴스도 *캡처할 수 있으며, 테스트 과정 중 return 값을 설정할 수 있습니다.”
 여기서 말하는 *캡처는 Jest 공식문서 번역 말투라 이해가 어려울 수 있다. 쉽게 풀어 말하자면 함수가 호출될 때 그 함수에 전달되는 인자들을 기록하고, 나중에 이 인자들을 검사할 수 있게 하는 것을 의미한다고 보면 된다.
Jest에서 모의 함수를 사용하면 해당 함수의 실제 구현을 대체하고, 함수 호출과 그 호출에 사용된 인자들을 추적한다. 이를 통해 개발자는 함수가 예상대로 호출되었는지, 올바른 인자들이 전달되었는지 등을 테스트 코드 내에서 확인할 수 있다.
숫자야구 테스트 코드에서도 아래와 같이 입력값을 모의하는 mockQuestions 함수를 먼저 정의한다.
// 입력값을 모의하는 함수를 정의 const mockQuestions = (inputs) => { // MissionUtils.Console.readLineAsync 함수를 가짜 함수로 대체 MissionUtils.Console.readLineAsync = jest.fn(); // readLineAsync 함수의 동작을 구현함 - 입력값 배열에서 순차적으로 값을 가져옴 MissionUtils.Console.readLineAsync.mockImplementation(() => { const input = inputs.shift(); return Promise.resolve(input); }); };
JavaScript
복사
모의 함수를 정의하고 나서는 MissionUtils.Console.readLineAsync 함수를 가짜 함수로 대체한다. 여기서 사용되는 jest.fn()mockImplementation()은 어떤 기능을 할까?
“구현이 jest.fn()에 전달되면 올바른 모의 타이핑이 추론됩니다. 구현이 생략되는 사용 사례가 많이 있습니다. 유형 안전성을 보장하기 위해 일반 유형 인수를 전달할 수 있습니다.”
 공식문서에서 번역 말투로 다소 어렵게 설명하고 있지만, 풀어 설명하자면 jest.fn()은 Jest에서 제공하는 모의 함수를 생성하는 함수다. 그리고 mockImplementation()는 모의 함수의 구현을 정의하고, 꽤나 복잡한 로직까지 대체할 수 있다. 이렇게 되면 readLineAsync 함수 대신, 가짜 함수가 사용되어 테스트 동안 실제 사용자 입력을 받는 대신 가짜 input 인자를 받아 사용할 수 있게 된다.
MissionUtils.Console.readLineAsync.mockImplementation(() => { const input = inputs.shift(); return Promise.resolve(input);
JavaScript
복사
 이 부분만 따로 떼어 와서 다시 보겠다. 우선 readLineAsync 함수의 가짜 구현을 설정하는데 inputs 배열에서 shift( )를 사용해 첫 번째 요소를 제거하고, 이 값을 반환한다. readLineAsync는 비동기 함수이므로, 반환된 값은 Promise.resolve(input)를 사용하여 프로미스로 감싸져 반환된다.

예측 가능한 결과로 테스트하기

// 랜덤 숫자를 모의하는 함수를 정의const mockRandoms = (numbers) => { // MissionUtils.Random.pickNumberInRange 함수를 가짜 함수로 대체 MissionUtils.Random.pickNumberInRange = jest.fn(); // pickNumberInRange 함수의 반환값을 순차적으로 정의 numbers.reduce((acc, number) => { return acc.mockReturnValueOnce(number); }, MissionUtils.Random.pickNumberInRange); };
JavaScript
복사
 숫자 야구 미션에서는 Random.pickNumberInRange (랜덤 숫자 생성 함수)를 사용하는데, 테스트 코드에서는 Jest를 사용해 이 함수를 mock 한다. mockRandoms라는 이름으로 선언된 함수는 테스트 중에 특정 범위 내에서 랜덤 숫자를 선택하는 MissionUtils.Random.pickNumberInRange 함수의 동작을 대체한다.
위의 MissionUtils.Console.readLineAsync 함수를 가짜 함수로 대체한 것처럼, MissionUtils.Random.pickNumberInRange 함수 또한 Jest의 jest.fn()을 사용하여 가짜 함수로 대체한다. 이렇게 하면 원래의 pickNumberInRange 함수 대신 가짜 함수가 사용되어, 랜덤 숫자 생성을 모의할 수 있다.
numbers.reduce((acc, number) => { return acc.mockReturnValueOnce(number); }, MissionUtils.Random.pickNumberInRange);
JavaScript
복사
 모의 함수의 리턴 값을 설정하는 위 부분만 따로 떼어 와서 다시 보겠다. 여기서는 위의 mockRandoms 함수가 인자로 받은 numbers라는 배열을 사용한다. 이 배열은 모의할 때 반환될 랜덤 숫자들을 포함한다.
우선 numbers  라는 배열을 순회하면서 reduce 함수를 사용한다. 이 과정에서 MissionUtils.Random.pickNumberInRange 함수의 반환값을 numbers 배열의 각 요소로 순차적으로 설정한다.
그리고 mockReturnValueOnce 메서드는 함수가 호출될 때마다 지정된 순서대로 한 번씩 값을 반환하도록 설정한다. 예를 들어, 첫 번째 호출에서는 numbers 배열의 첫 번째 요소를 반환하고, 두 번째 호출에서는 두 번째 요소를 반환하는 식이다.
정리하자면, 위 테스트 코드의 목적은 랜덤 숫자 생성 함수의 동작을 통제하고, 테스트 중에 예측 가능하고 일관된 결과를 얻기 위함이다. '랜덤'이 내포하는 불규칙하고, 예측 불가능한 성질을 제거하고 테스트 코드 안에서 만의 방식으로 정의한다고 보면 된다.

출력 함수를 감시하기

// 로그 출력을 감시하는 함수를 정의 const getLogSpy = () => { // MissionUtils.Console.print 함수의 호출을 감시 const logSpy = jest.spyOn(MissionUtils.Console, 'print'); // 감시 로그 초기화 logSpy.mockClear(); // 감시 객체 반환 return logSpy; };
JavaScript
복사
그 다음에는 로그 출력 함수를 감시하는 getLogSpy 함수를 정의한다. 이 함수는 로그 출력을 위해 사용되는 MissionUtils.Console.print 함수의 호출을 추적하고, 그 결과를 테스트에서 사용할 수 있게 한다.  (spyOn에 대한 설명은 Jest 공식 문서의 예제를 참고하면 이해가 쉬울 것이다) 여기서는 MissionUtils.Console 객체의 print 메서드가 호출될 때마다 이를 추적하도록 설정했다.
더불어, logSpy.mockClear()를 호출하여 logSpy의 기록을 초기화해 준다. 이는 테스트 시작 전에 이전 테스트에서의 호출 기록을 제거하기 위해 사용되며, 각 테스트 전에 자동으로 모의 항목들을 지울 수 있다고 한다. (참고: Jest 공식문서 mockClear)
그 다음에는 초기화된 logSpy 객체를 반환한다. 이 객체를 사용하면 테스트 코드에서 MissionUtils.Console.print 함수의 호출 여부, 호출 횟수, 호출 시 사용된 인자 등을 검사할 수 있을 것이다.
정리하자면, 이 코드는 테스트 중에 출력 함수가 어떻게 사용되는지 추적하고, 로그 출력이 예상대로 동작하는지 검증하는 데 사용된다. 예를 들어 특정 상황에서 특정 메시지가 잘 출력되는지, 혹은 출력 함수가 정해진 횟수만큼 잘 호출되는지 등을 검증할 수 있다는 의미다.

게임 종료 후 재시작 로직 검증하기

코드 분석에 앞서, 우선 테스트 코드의 구조를 알아보자. 테스트 코드는 하나의 큰 테스트 스위트(Test Suite) 안에 여러 개의 관련된 테스트 케이스(Test Case)들로 이루어져 있다.
Test Plan @parasoft.com
테스트 코드 구조는 간단하게 알아보았으니, 아래의 첫 번째 테스트 케이스를 쪼개가며 깊숙하게 파고들어 가 보자.
// '숫자 야구 게임'에 대한 테스트 스위트를 정의 describe('숫자 야구 게임', () => { // '게임 종료 후 재시작'에 대한 테스트 케이스를 정의 test('게임 종료 후 재시작', async () => {
JavaScript
복사
우선, 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스트라이크', '게임 종료'];
JavaScript
복사
 다음으로는 테스트 초기값을 설정한다. 코드에서 그대로 읽히듯 randoms 배열에는 게임에 사용될 랜덤 숫자들을 직접 정의, answers 배열에는 사용자의 입력을 모의하는 데에 사용될 예상 답변들을 정의, logSpy는 출력을 감시하는 함수의 리턴값으로 출력 함수 호출을 추적한다. 그리고 messages 배열은 예상되는 로그 메시지들을 포함한다.
mockRandoms(randoms); mockQuestions(answers);
JavaScript
복사
모의 함수들을 호출해 활성화해 준다. 두 mock 함수를 호출할 때는 위에서 선언한 배열을 인자로 넣어주면서 게임 로직에서 사용될 랜덤 숫자와 사용자 입력을 모의한다.
// when // App 인스턴스 생성 및 play 함수 실행 const app = new App(); await expect(app.play()).resolves.not.toThrow();
JavaScript
복사
new로 App 클래스의 인스턴스를 생성하고, play()를 호출한다. 그리고 이 play()가 예외를 발생시키지 않고(not.toThrow) 정상적으로 수행되는지 검증(expect) 한다.
// then // 로그 출력이 예상대로 이루어졌는지 검증 messages.forEach((output) => { expect(logSpy).toHaveBeenCalledWith(expect.stringContaining(output)); });
JavaScript
복사
그러고 나서는 messages 배열에 포함된 각각의 예상 메시지들에 대해, 아까 출력 함수를 감시한다던 logSpy가 나선다. 이 logSpy는 해당 메시지를 포함하여 호출되었는지 검증하는 역할을 한다. 쉽게 말해, 게임이 진행되며 각 상황에 맞는 로그 메시지를 출력하고 있는지 아닌지 확인하는 것이다.
참고로, 여기서 toHaveBeenCalledWith()는 모의 함수가 특정 인수로 호출되었는지 확인하는 역할을 하며, expect.stringContaining는 상황에 맞게 정확한 예상 문자열을 포함하는 문자열인 경우를 확인하는 역할을 한다.

예외 상황 처리 여부 테스트하기

// '예외 테스트'에 대한 테스트 케이스를 정의 test('예외 테스트', async () => {
JavaScript
복사
 우선, '예외 테스트'라는 이름의 테스트 케이스를 정의하며 이 테스트 또한 비동기 함수를 테스트하기 위해 async를 사용했다.
// given // 테스트를 위한 초기값 설정 const randoms = [1, 3, 5]; const answers = ['1234'];
JavaScript
복사
첫 번째 테스트 케이스와 유사하게 테스트 초기값을 설정한다. randoms 배열은 테스트에 사용될 랜덤 숫자들, answers 배열은 테스트에 사용될 사용자 입력을 모의하여 예상 답변을 정의한다.
참고로 이 테스트는 예외를 발생시키는지 알아보는 테스트이기 때문에 에러를 발생시켜야 한다. 때문에 answers에는 예외 처리 대상인 답변(세 자리가 아닌 네 자리 문자열)을 넣어야 한다.
mockRandoms(randoms); mockQuestions(answers);
JavaScript
복사
 마찬가지로 위에서 선언한 배열들을 인자로 넣어 mock 함수를 호출한다.
// when & then // App 인스턴스 생성 및 play 함수 실행, 예외 발생 검증 const app = new App(); await expect(app.play()).rejects.toThrow('[ERROR]');
JavaScript
복사
 new로 App 클래스의 인스턴스를 생성하고, play()를 호출한다. 그리고 이 play()가 예외를 발생시키고(toThrow), 예외 메시지로 [ERROR]가 출력되는지 확인한다.

테스트 코드 분석 소감

확실히 전보다 테스트 코드가 잘 이해된다. 처음 봤을 때는 당연하게도 Jest의 각 메서드가 어떤 역할을 하는지 모르기에 까막눈이 된 기분이었지만, 하나하나 찾아보고 분석해보니 잘 읽힌다. 마치 영어를 하나도 모르는데 영어사전에서 단어 하나하나 찾아가면서 문단을 읽어낸 느낌?
어쨌든 테스트 코드를 세세하게 파보니 문제에서 요구하는 바를 정확하게 알 수 있었다. 다음 미션들도 이렇게 파봐야지.