들어가며

코드를 작성한 후에 그 코드가 제대로 작동하는지 알 수 있는 가장 쉬운 방법은 컴파일해서 실행해보는 것입니다. 이 방법은 프로그래밍을 입문하면서 제일 처음 배우는 디버깅 방법이기도 하지요.

하지만 이 방법은 쉬운만큼 한계가 많습니다.
첫 번째 한계는 코드가 바뀔 때마다 이전에 추가해던 기능이 제대로 동작하는지 하나하나 검사해야된다는 점입니다. 코드의 일부분을 수정했는데 이전에 작동하던 기능에서 오류가 난다면 신뢰할 수 있는 코드라고 할 수 없겠죠.
두 번째 한계는 모든 예외를 검사하는데 상당한 시간과 노력이 든다는 점입니다. 테스트해야 될 경우의 수가 10가지 정도라면 프로그래머가 직접 테스트해도 괜찮을 것입니다. 그러나 그 수가 100개, 200개가 된다면 꽤 골치아프겠죠.
세 번째 한계는 코드 리팩토링(: 코드의 동작을 바꾸지 않으면서 코드의 구조를 개선하는 것) 과정에서 코드가 망가지지 않았는지 알 수 있는 쉬운 방법이 없다는 것입니다. 앞서 말한 것처럼 큰 노력을 들여야만 알 수 있고, 그다지 완벽한 방법도 아닙니다.

위의 한계 때문에 프로덕션 코드를 작성할 때는 테스트 코드를 작성하는 것이 권장됩니다. 실제로 다수의 유명 프로그래머들이 테스트 코드를 작성하는 것, 더 나아가 테스트 주도 개발(TDD)을 주된 개발 프로세스로 삼아야한다고 주장하죠.

하지만 테스트 코드 작성을 시작하려고 하면 조금 막막한 것이 사실입니다. 그래서 비록 대단한 코드는 아니지만 제가 어떤 식으로 테스트 코드를 작성하는지 공유하고자 합니다. 그러니 이번 글은 이런 식으로도 테스트 코드를 작성할 수 있구나하는 식으로 봐주시면 좋을 것 같습니다.

코드

이번 글에 나오는 코드는 에어비앤비 체크인 리마인더 코드의 일부분입니다.

const needsCheckInOrOut = (
    period: string,
    now = new Date(Date.now()),
) => {
    // TODO: needs localization
    const periodMatcher = /(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) (\d{1,2})–(\d{1,2})/;
    const matched = periodMatcher.exec(period);

    if (matched) {
        // map types to number
        let [, month, startDate, endDate] = matched.map((match) =>
            isNaN(Number(match)) ? match : Number(match),
        );

        // month to number
        month = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Oct", "Nov", "Dec"].findIndex(m => m === month)

        const thisMonth = now.getMonth();
        const currentDate = now.getDate();

        let messageType = null;

        if (month === thisMonth && startDate === currentDate) {
            messageType = "check-in";
        }

        if (month === thisMonth && endDate === currentDate) {
            messageType = "check-out";
        }

        return {
            required:
                month === thisMonth &&
                (startDate === currentDate || endDate === currentDate),
            type: messageType,
        };
    }
};

export default needsCheckInOrOut;

위의 코드는 string 타입의 period를 받아서 만약 그 period가 체크인 혹은 체크 아웃하는 날에 해당되면

{
    required: boolean,
    type: "check-in" | "check-out"
}

위와 같은 객체를 돌려주는 코드입니다.

그리고 이 코드에 대해

describe("needsCheckInOrOut", () => {
    const date = new Date("2019 1. 15.");

    test.each([
        ["Jan 13–18, 2019", date, { required: false, type: null }],
        ["Jan 13–15, 2019", date, { required: true, type: "check-out" }],
        ["Jan 15–17, 2019", date, { required: true, type: "check-in" }],
        ["Jan 20–25, 2019", date, { required: false, type: null }],
        ["2019. 01. 02", date, undefined],
        ["2019. 02. 20-25", date, undefined],
    ])("input: %s, %s)", (a, b, expected) => {
        expect(needsCheckInOrOut(a, b)).toEqual(expected);
    });
});

이런 식으로 테스트 코드를 작성했습니다.

몇 가지 유용한 팁은

  1. 의존성(Dependency)을 외부에서 주입할 수 있게 만들어라.

needsCheckInOrOut 함수의 두 번째 인자인 nowDate 객체를 받습니다.

const needsCheckInOrOut = (
    period: string,
    now = new Date(Date.now()), // now!
) => {

그리고 체크인/체크아웃 기준 시간은 now를 기준으로 판단하기 때문에 저런 식으로 인자로 만들어두면 테스트할 때 사용자가 원하는 임의의 값을 전달할 수 있습니다.

이러한 패턴을 의존성 주입(Dependency Injection) - 위키백과 이라고 합니다. 의존성을 가지는 부분을 외부로부터 제공받기 때문에 테스트하기 훨씬 용이한 코드가 되는 것이죠. 이 패턴은 Angularjs 프레임워크의 핵심 디자인 중 하나이기도 합니다.

  1. 의존성 주입을 할 수 없는 경우에는 Mock을 사용하라.

어떤 코드는 외부의 결과에 크게 의존합니다. 데이터베이스로부터 어떤 결과값을 받는다든가 앞선 경우처럼 특정 시각에만 실행이 된다거나하는 식으로 말이죠. 그 외에도 특정 함수나 객체를 입맛에 맞게 수정해서 사용해야될 때가 있습니다.

이 경우에는 Mock을 사용할 수 있습니다. Mock은 실제 함수나 객체가 아니지만 그와 유사한 동작을 제공합니다. 1.처럼 의존성을 아예 전부 외부 인자로 바꿀 수 있다면 가장 좋겠지만 그게 여의치 않은 경우도 분명 있습니다. 이 경우에는 Mock을 사용할 수 있습니다.

제가 Date 객체를 Mock할 때 사용하는 라이브러리인 MockDate를 사용하면 아래와 같은 코드를 작성할 수 있습니다.

beforeEach(async () => {
  MockDate.set(new Date("2019. 02. 18. 18:20:00"));
});

afterEach(async () => {
  MockDate.reset();
});

단 몇 줄만으로 원하는 날짜를 설정할 수 있게 되었습니다! Mock은 의존성 주입처럼 직접 코드를 수정하지 않더라도 원하는 결과를 시험해볼 수 있기 때문에 편의성이 높습니다. 하지만 Mock은 실제 객체 혹은 함수가 아니기 때문에 100% 정확한 동작을 한다고 볼 수는 없죠.

스크린샷 2019-02-27 오후 3.36.22.png

이 때문에 Mock은 테스트 피라미드(: 그림)의 가장 아래 단계인 유닛 테스트 단계에서 자주 사용됩니다.

  1. test.each를 사용하면 테스트 케이스를 좀 더 보기 좋게 정리할 수 있다.

제 테스트 코드는 아래처럼 test.each를 사용하여 한 눈에 보기 좋게 정리되어 있습니다. 이 방식의 장점은 누구든지 테스트 케이스를 추가할 수 있고, 나중에 함수의 동작을 알고 싶을 때 테스트 케이스만 보면 쉽게 이해할 수 있다는 점입니다. 이는 테스트 코드가 그 자체로 함수를 설명하는 문서가 된 셈으로, 함수 시작 부분에 긴 주석을 다는 것보다 보기가 편하다고 할 수 있습니다.

test.each([
        ["Jan 13–18, 2019", date, { required: false, type: null }],
        ["Jan 13–15, 2019", date, { required: true, type: "check-out" }],
        ["Jan 15–17, 2019", date, { required: true, type: "check-in" }],
        ["Jan 20–25, 2019", date, { required: false, type: null }],
        ["2019. 01. 02", date, undefined],
        ["2019. 02. 20-25", date, undefined],
  ...

위의 코드를 보면 인자에 따라 함수의 리턴 타입이 어떻게 되는지, 함수가 어떤 형식의 날짜 스트링을 유효하다고 판단하는지 금방 알 수 있습니다.

  1. 좀 더 자신감 넘치는 리팩토링

자동화된 테스트를 사용하면 직접 코드의 동작을 확인하는 방식(Manual Test)을 사용할 때보다 좀 더 자주 리팩토링을 할 수 있습니다. 이는 함수의 동작이 코드의 수정으로 인해서 변경되더라도 다시 테스트를 통과하게 고쳐놓는다면 코드의 동작을 담보할 수 있기 때문입니다.

needsCheckInOrOut는 몇 번의 리팩토링을 거쳤는데요, 현재는 아래와 같은 모습으로 바뀌었습니다.

import { Message } from "../types";
import { TWELVE_MONTHS } from "./constants";

// helpers
const mapToNumber = (numString: string[]) => {
    return numString.map((str) => {
        const num = Number(str);

        return isNaN(num) ? str : num;
    });
};

const getMonthNumber = (target: string) =>
    TWELVE_MONTHS.findIndex((month) => month === target);

// main
const needsCheckInOrOut = (
    period: string,
    now = new Date(Date.now()),
):
    | {
            type: Message
            required: boolean,
      }
    | undefined => {
    const periodMatcher = new RegExp(
        `(${TWELVE_MONTHS.join("|")}) (\\d{1,2})–(${TWELVE_MONTHS.join(
            "|",
        )})\?\\s\?(\\d{1,2})`,
    );
    const matched = periodMatcher.exec(period);

    if (matched) {
        // map types to number
        let [, month, startDate, month2, endDate] = mapToNumber(matched);

        if (month2 && typeof month2 === "string") {
            month2 = getMonthNumber(month2);
        }

        if (typeof month === "string") {
            month = getMonthNumber(month);
        }

        const thisMonth = now.getMonth();
        const currentDate = now.getDate();

        const startsThisMonth =
            month === thisMonth && startDate === currentDate;
        const endsThisMonth =
            month === thisMonth && endDate === currentDate;
        const endsNextMonth =
            month2 === thisMonth && endDate === currentDate;

        let messageType: Message;

        if (startsThisMonth) {
            messageType = "check-in";
        }

        if (endsThisMonth || endsNextMonth) {
            messageType = "check-out";
        }

        return {
            required: startsThisMonth || endsThisMonth || endsNextMonth,
            type: messageType,
        };
    }
};

export default needsCheckInOrOut;

언뜻 보기에도 꽤 많은 부분이 수정되었는데도 저는 이 코드가 정확하게 작동한다고 확신할 수 있습니다. 이는 제 테스트 코드가 초록불을 가리키고 있기 때문이죠. (: jest는 테스트가 실패하면 빨간색, 성공하면 초록색으로 결과를 표시함)

몇 가지 눈에 띄는 부분은

  1. 하드코딩된 정규식과 변수가 사라짐

하드코딩(데이터를 직접 코드에 집어넣는 행위)은 코드에 중복을 만들어서 유지보수를 어렵게 만듭니다. 그러므로 TWELVE_MONTHS라는 변수로 묶어서 정리했습니다.

  1. 몇 개의 헬퍼 함수로 코드를 분리

함수형 메쏘드(map, filter, reduce)를 길게 사용하면 코드의 동작이 한 눈에 들어오지 않습니다. 그래서 그 부분을 각각의 함수로 만들어 함수 외부에 정리했습니다.

  1. if문의 길이는 짧게

if문의 조건이 길어지면 길어질 수록 가독성이 떨어지고, 이 조건이 무엇을 의미하는지 파악하기가 어려워집니다. 이때 if문의 boolean 조건을 변수로 만들어서 관리하면 가독성을 높힐 수 있습니다.

  1. (Typescript) 인자의 타입이나 리턴 타입을 명시하고, 필요하다면 커스텀 타입을 사용

Typescript의 가장 강력한 힘은 코드 자동완성입니다. 타입을 명시해두면 자동으로 메쏘드 등을 완성해주기 때문에 편하게 코드를 작성할 수 있습니다. 또 객체의 존재하지 않는 속성을 접근한다거나하는 실수를 함수의 실행 이전에 경고해주기 때문에 코드의 안정성이 높아집니다. 그리고 커스텀 타입을 사용하면 변수가 의미하는 바를 좀 더 명확히 할 수 있습니다.

이와 함께 테스트 코드도 약간의 변화를 겪었습니다.

describe("needsCheckInOrOut", () => {
    const date = new Date("2019 1. 15.");

    test.each([
        ["Jan 13–18, 2019", date, { required: false, type: undefined }],
        ["Jan 13–15, 2019", date, { required: true, type: "check-out" }],
        ["Jan 15–17, 2019", date, { required: true, type: "check-in" }],
        [
            "Jan 15–Feb 1, 2019",
            date,
            { required: true, type: "check-in" },
        ],
        [
            "Dec 31–Jan 15, 2019",
            date,
            { required: true, type: "check-out" },
        ],
    ])("input: %s, %s)", (a, b, expected) => {
        expect(needsCheckInOrOut(a, b)).toEqual(expected);
    });
});

우선 인풋으로 들어올 수 없는 아래 두 개 케이스가 제거되었습니다.
period 스트링은 언제나 Jan 13–18, 2019와 같은 형식으로 들어오기 때문에 아래 두 개의 경우는 테스트할 필요가 없다고 판단했죠.

["2019. 01. 02", date, undefined],
["2019. 02. 20-25", date, undefined],

대신 두 가지 케이스가 추가되었습니다.

[
  "Jan 15–Feb 1, 2019",
  date,
  { required: true, type: "check-in" },
],
  [
  "Dec 31–Jan 15, 2019",
  date,
  { required: true, type: "check-out" },
],

이전의 코드와 정규식은 날짜를 체크할 때 달이 바뀌는 상황을 적절하게 처리하지 못했기 때문에 위의 케이스를 추가하여 예외를 잡았습니다.

마치며

스크린샷 2019-02-27 오후 4.06.06.png

테스트 코드를 작성하고, 좀 더 테스트하기 쉬운 코드를 작성하기 위해 노력하는 것이 좋은 소프트웨어를 만드는 왕도라고 생각합니다. 만약 지금까지 테스트에 관심이 없었다면 지금부터라도 테스트 코드 작성을 시작해보는 것은 어떨까요?

긴 글 읽어주셔서 감사합니다~