우아한테크코스 6기 프리코스 2주차 문제를 풀어보면서 정리한 글입니다.
아직 부족한 부분이 많기에 내용에 오류가 있다면 알려주시면 감사하겠습니다.
코딩 테스트 문제를 풀 때에도 테스트 케이스는 항상 정해져 있었기 때문에 테스트 코드를 작성하는 것은 나의 역할이 아니라고 생각했다. 만약 직접 Test 코드를 작성하게 된다면 능동적인 학습이 가능하고, 다양한 예외 케이스를 미리 생각해볼 수 있을 것 같았다. 테스트 코드 작성이 어떤 이점이 있는지 한번 찾아봤다.
보통 이러한 작업을 Test-Driven Development,
TDD
라고 한다.TDD
는 테스트가 중심이 되는 개발 방법이고디버깅을 할 때 시간을 매우 단축시켜준다.
또한 테스트 코드 내에서 직접 설계 의도와, 설명을 적어줄 수 있어서문서를 대체하는 기능
을 한다.
그 중 Facebook에서 개발한 JavaScript 테스팅 프레임워크인 Jest
에 대해 알아보자.
Jest
공식 문서에 들어가면 자세하게 설명이 되어있긴 하지만 관련 함수나 기능이 무수하게 많기 때문에 무엇부터 시작해야 할지 막막하다. 다행스럽게도 우아한테크코스에서 ApplicationTest.js
라는 파일을 제공해주었기 때문에 이 코드를 분석한다면 전반적인 Jest
의 흐름을 파악하기 쉬울 것이다.
같이 한번 살펴보자.
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()
함수는 인자로 들어온 배열을 한칸 씩 왼쪽으로 밀어내며 그 값을 비동기 함수의 리턴 값으로 정하는 것이다.
const mockRandoms = (numbers) => {
MissionUtils.Random.pickNumberInRange = jest.fn();
numbers.reduce((acc, number) => {
return acc.mockReturnValueOnce(number);
}, MissionUtils.Random.pickNumberInRange);
};
앞에서 설명한 부분은 간략하게만 짚고 넘어가자. 우선 랜덤으로 값을 지정하는 함수를 가짜 함수로 만든다. mockReturnValueOnce
함수는 가짜 함수의 리턴 값을 사용자의 지정 값으로 대체해주는 역할을 해준다.
const getLogSpy = () => {
const logSpy = jest.spyOn(MissionUtils.Console, "print");
logSpy.mockClear();
return logSpy;
};
스파이
는 몰래 정보를 캐내는 역할을 한다. 테스트를 작성할 때도 어떤 객체에 속한 함수의 구현을 가짜로 대체하지 않고, 해당 함수의 호출 여부와 어떻게 호출되었는지만을 알아내야 할 때가 있다.
jest.spyOn(a, b)
라면 MissionUtils.Console.print()
에 스파이를 붙인다. 그리고 mockClear()
은 스파이의 기록을 초기화하는 명령입니다. 다른 테스트 케이스 사이에서 스파이의 상태를 클리어한다. 함수의 호출 횟수와 어떤 인자가 넘어갔는지에 대한 정보에 대해 초기화하는 작업이라고 생각하면 된다.
가장 중요한 점은 가짜 함수로 대체한 것이 아니기 때문에 본 함수
를 호출해야 한다.
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]");
}
);
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을 이용하였다.