우아한테크코스 2주차에 접어들었다! 이번주 미션은 자동차 경주 게임이다. 자세한 미션은 이곳에서 확인할 수 있다.

1주차와 달라진 점

  • indent 관련 요구사항이 추가됐다! 메서드 분리에 조금 더 신경 써야 할 듯하다
  • Jest를 이용해 기능 목록이 정상 작동하는지 테스트 코드로 확인해야 한다 그런데 나는 Jest를 사용해본 적이 없다...!!

  • 커밋 메시지 컨벤션에 관한 요구사항도 추가됐다
    • 기존의 나는 feat: 한글 메시지 형식을 사용했는데 feat(App): english message 형식을 사용해야 한다
    • 기능 목록 단위로 커밋 해야 한다!

2주차를 시작하며

이번 미션은 저번 미션에서 학습한 클래스와 객체 지향을 활용하며, Jest를 파보기로 결심했다. 그리고 어차피 테스트 하는 거 이왕이면 TDD를 시도해볼 생각이다. 일주일 후의 내가 테스트에 익숙한 사람이 되길 바라며...

나만의 목표

  • 단일 책임 원칙에 맞게 Class를 나누자
  • 의존성 역전 원칙에 주의하자
  • private 필드와 getter, setter을 활용해 캡슐화하자
  • 주석이 필요없는 명확한 네이밍을 하자
  • 작성한 기능 목록의 기능 단위로 커밋하자
  • 내가 익숙한 형식과 달라진 커밋 메시지의 형식을 주의하자! feat(App): english message
  • indent는 2까지만!
  • Jest를 활용해 기능 목록의 기능들을 테스트하자
  • 기능을 구현할 때 일단은 TDD를 시도해보자

설계

미션의 리드미를 읽고 일단 어떤 클래스들을 활용할 것인지 설계해보았다. 다른 것보다도 결과물은 어떻게 달라질지 궁금하다.

Jest 알아보기

공식 문서 요약

먼저 작성되어 있는 테스트 코드가 잘 이해가 안 되길래 공식 문서를 읽었다.

Matcher

  • toBe: 실제 값이 기대한 값과 정확히 일치하는지 확인
  • toEqual: 객체나 배열 같은 데이터 구조의 실제 값과 기대한 값이 동일한 내용을 가지는지 확인
  • toBeNull: null을 가지는지 확인
  • toBeUndefined: undefined을 가지는지 확인
  • toBeDefined: undefined을 가지지 않는지 확인
  • toBeTruthy: true인지 확인
  • toBeFalsy: false인지 확인
  • toMatch: 문자열이 정규표현식과 일치하는지 확인
  • toContain: 배열 또는 문자열이 특정 값 또는 원소를 포함하는지 확인
  • toThrow: 함수 호출 시 예외가 발생하는지 확인

기존 테스트 코드 분석하기

mockQuestions(inputs)

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

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

MissionUtils.Console.readLineAsync 대신 inputs 배열 각 호출에 대하여 input 배열로 return한다.

const inputs = ["pobi,woni", "1"];
mockQuestions(inputs);
  • 사용자의 첫 번째 입력: "pobi,woni"
  • 사용자의 두 번째 입력: "1"

mockRandoms(numbers)

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

MissionUtils.Random.pickNumberInRange 대신 numbers 배열에 있는 각 숫자를 순차적으로 반환한다.

mockReturnValue, mockReturnValueOnce

myMock.mockReturnValueOnce(10).mockReturnValueOnce('x').mockReturnValue(true);

console.log(myMock(), myMock(), myMock(), myMock());
// > 10, 'x', true, true
mockRandoms([4, 3]);
  • 첫 번째 Random.pickNumberInRange: 4 반환
  • 두 번째 Random.pickNumberInRange: 3 반환

getLogSpy()

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

MissionUtils.Console.print의 함수 호출 여부, 호출 횟수, 전달된 인수를 확인할 수 있다.

jest.spyOn(object, methodName)

const video = {
  play() {
    return true;
  },
};

test('plays video', () => {
  const spy = jest.spyOn(video, 'play');
  const isPlaying = video.play();

  expect(spy).toHaveBeenCalled();
  expect(isPlaying).toBe(true);
});
  • 어떤 객체에 속한 함수의 구현을 가짜로 대체하지 않음
  • 해당 함수의 호출 여부와 어떻게 호출되었는지만을 알아내기
const outputs = ["pobi : -"];
const logSpy = getLogSpy();

outputs.forEach((output) => {
 // MissionUtils.Console.print가 output을 포함한 string 인수와 호출되었는지 확인
  expect(logSpy).toHaveBeenCalledWith(expect.stringContaining(output));
});

test.each(table)(name, fn, timeout)

describe("Math operations", () => {
  test.each([
    [1, 2, 3],    // Input: 1, 2, Expected Output: 3
    [5, 5, 10],   // Input: 5, 5, Expected Output: 10
    [0, 0, 0],    // Input: 0, 0, Expected Output: 0
  ])("add(%i, %i) returns %i", (a, b, expected) => {
    expect(add(a, b)).toBe(expected);
  });
});
  • 테스트 반복을 쉽게 할 수 있다
test.each([
  [["pobi,javaji"]],
  [["pobi,eastjun"]]
])("이름에 대한 예외 처리", async (inputs) => {
  // given
  mockQuestions(inputs);

  // when
  const app = new App();

  // then
  await expect(app.play()).rejects.toThrow("[ERROR]");
});
  • "pobi,javaji"을 입력했을 때 [ERROR]를 던지는지
  • "pobi,eastjun"을 입력했을 때 [ERROR]를 던지는지

이제 어느 정도 jest에 대해 알게 된 것 같으니 본격적으로 jest를 활용해보자.

TDD 해보기

InputHandler

일단 가장 단순한 메서드인 자동차 이름 입력(성공)에 대한 테스트 코드를 작성했다.

class InputHandler {
  static async getCarNameArray() {
    const inputStr = await Console.readLineAsync(MESSAGE.ENTER_CAR_NAMES);
    return inputStr.split(",");
  }
}

test.each([
    ["pobi,woni,joo,java", ["pobi", "woni", "joo", "java"]],
    ["pobi,woni", ["pobi", "woni"]],
    ["java,woo,wa,han,tech", ["java", "woo", "wa", "han", "tech"]],
    ["joo", ["joo"]],
  ])("유효한 자동차 이름 입력 - %s", async (input, expectedOutput) => {
    mockQuestions([input]);

    const inputHandler = new InputHandler();
    const result = await inputHandler.getCarNameArray();

    expect(result).toEqual(expectedOutput);
    expect(MissionUtils.Console.readLineAsync).toHaveBeenCalledWith(
      MESSAGE.ENTER_CAR_NAMES
    );
  });

test.each()를 활용했는데 테스트 제목 형식에 printf 서식을 사용해 매개변수를 삽입해야 하는 것을 처음에는 알지 못해서 밤새 헤맸다. 😨 심지어 테스트는 다 통과되고 있었어서 나중에 잘못된 테스트 케이스가 통과되는 것을 보고 잘못됐다는 것을 알았다. 아무튼 이제라도 알아서 다행!

printf 서식은 다음과 같다.

  • %p: pretty-format
  • %s: String
  • %d: Number
  • %i: Integer
  • %f: Floating point value
  • %j: JSON
  • %o: Object
  • %#: Index of the test case

결과는 성공! 쉬워보이지만 jest가 처음이라 너무너무 뿌듯하다. ㅋㅋㅋㅋ

기능 구현

이후 나머지 기능 구현을 해주었다. 결과적으로는 아래와 같은 구조로 기능을 구현했다. 클래스의 관심사를 분리하고 의존성 역전 원칙을 지키려고 노력했다.

그리고 클래스마다 테스트를 작성해주었다!
테스트 초안 작성 -> 기능 구현 -> 테스트 -> 기능 보충 -> 다시 테스트

사실 이렇게 하는 게 TDD인지는 잘 모르겠다 어쨌든 내가 생각한 TDD 방식으로 진행했는데 기능을 구현하면서 일일이 이미 구현한 기능에 영향을 줬는지 수작업으로 테스트할 필요가 없다는 것이 매우!! 유용했다 왜 테스트 하는지 알 것 같다

느낀점

  • 테스트는 생각한 것처럼 리소스가 엄청 많이 들지는 않는다 오히려 수작업 테스트의 리소스를 줄일 수 있다
  • jest 정말 막막했지만 공식 문서와 우테코 예시 테스트를 참고하니 생각만큼 어렵지는 않았다
  • 그래도 test.each() 이슈는 나에게 너무 큰 위기였다 사용하는 기능의 공식 문서를 정말 꼼꼼히! 읽을 필요가 있다
  • 이번에 커밋 메시지를 우테코에서 제시한 커밋 컨벤션 형식과 더불어 영어를 사용했는데 조금 후회한다 영어 문법이 자꾸 신경 쓰여서 리소스 낭비인 것 같다 다음에는 형식만 가져가고 한글로 적어야지
  • 내 코드가 괜찮은지 피드백을 받고 싶다 객체 지향과 테스트 모두 처음이라 궁금하다 그런데 지금 해외에 있고 디스코드 핸드폰 인증이 안 되어서 코드리뷰 신청을 못하고 있다 너무 아쉽다 ㅠㅠ

제 코드는 깃허브에서 확인할 수 있습니다

참고자료

Jest 공식 문서

0개의 댓글

Powered by GraphCDN, the GraphQL CDN