9장 - 단위 테스트

들어가기 전
모든 예시 코드는 JavaScript에 맞게 cypress, jest 등의 라이브러리를 쓴다는 가정하에 작성했다.

1997년만 해도 TDD(Test Driven Development)라는 개념을 아무도 몰랐다1. 단위 테스트란 프로그램이 '돌아간다'는 사실을 확인하는 급조된 일회성 코드에 불과했다.

그 이후 개발 분야는 눈부신 성장을 이루며 애자일과 TDD 덕택에 단위 테스트를 자동화하는 프로그래머가 늘어났고, 더 늘어나는 추세다. 하지만 그 과정에 프로그래머들이 제대로 된 테스트 케이스를 작성해야 한다는 사실을 놓쳐버렸다.

TDD 법칙 세 가지

  1. 실패하는 단위 테스트를 작성할 때까지 실제 코드를 작성하지 않는다.
  2. 컴파일은 실패하지 않으면서 실행이 실패하는 정도로만 단위 테스트를 작성한다.
  3. 현재 실패하는 테스트를 통과할 정도로만 실제 코드를 작성한다.

위 세 규칙을 따르면 개발과 테스트가 약 30초 주기로 묶인다. 이렇게 일하면 매일 수십 개에 달하는, 거의 모든 코드를 테스트하는 테스트 케이스가 나온다. 하지만 실제 코드와 맞먹을 정도로 방대한 테스트 코드는 심각한 관리 문제를 유발하기도 한다.

깨끗한 테스트 코드

깨끗한 테스트 코드를 만들기 위해선 세 가지가 필요하다. 가독성, 가독성, 가독성. 테스트 코드는 최소한의 표현으로 많은 것을 나타내야 한다.

목록 9-1

describe("Serialized Page Responder Test", () => {
    it("Get page hierarchy as XML", () => {
        crawler.addPage(root, PathParser.parse("PageOne"));
        crawler.addPage(root, PathParser.parse("PageOne.ChildOne"));
        crawler.addPage(root, PathParser.parse("PageTwo"));

        request.setResource("root");
        request.addInput("type", "pages");

        const responder = new SerializedPageResponder();
        const response = responder.makeResponse(
            new FitNesseContext(root),
            request,
        );
        const xml = response.getContext();

        expect(response.getContentType()).toBe("text/xml");
        expect(xml).toContain("<name>PageOne</name>");
        expect(xml).toContain("<name>PageTwo</name>");
        expect(xml).toContain("<name>ChildOne</name>");
    });
});

위 테스트 케이스는 개선의 여지가 많다. 먼저 addPageexpect().toContain처럼 중복되는 코드가 많다. 그리고 PathParser를 호출하는 부분, responder 객체를 생성하고 response를 수집해 변환하는 코드 등 테스트와 무관한 잡음도 많다.
이는 코드를 읽는 사람이 테스트를 이해하기 힘들게 만들 뿐이다.

목록 9-2(목록 9-1을 리팩터링한 코드)

describe("Serialized Page Responder Test", () => {
    it("Get page hierarchy as XML", () => {
        makePages("PageOne", "PageOne.ChildOne", "PageTwo");

        submitRequest("root", { type: pages });

        expectResponseIsXml();
        expectResponseContains(
            "<name>PageOne</name>",
            "<name>PageTwo</name>",
            "<name>ChildOne</name>",
        );
    });
});
  1. 테스트 자료를 만드는 부분
  2. 테스트 자료를 조작하는 부분
  3. 조작한 결과가 올바른지 확인하는 부분

위와 같은 테스트 구조에는 각 테스트를 상술한 세 부분으로 명확히 나누는 Build-Operate-Check 패턴2이 적합하다.

잡다하고 세세한 코드를 거의 다 없앴다는 사실에 주목하자. 테스트 코드가 진짜 필요한 자료 유형과 함수만 사용하면 코드를 읽는 사람은 잡다하고 세세한 코드에 휘둘릴 필요 없이 코드가 수행하는 기능을 재빨리 이해할 수 있다.

도메인에 특화된 테스트 언어

목록 9-2는 도메인에 특화된 언어DSL로 테스트 코드를 구현하는 기법을 보여준다. API 위에다 함수와 유틸리티를 구현한 뒤, 그 함수와 유틸리티를 사용하므로 테스트 코드를 작성하기도, 읽기도 쉬워진다. 이런 함수와 유틸리티는 테스트 코드에서 사용하는 특수 API가 되어 구현하는 당사자와 나중에 읽어볼 독자를 도와주는 언어가 된다.

숙련된 개발자라면 목록 9-1에서 9-2로 리팩터링했듯 코드를 좀 더 간결하고 표현력이 풍부하게 리팩터링해야 한다.

이중 표준

테스트 코드에 적용하는 표준은 실제 코드에 적용하는 표준과 다르다. 실제 환경에서 작동하는 코드가 아니기에 단순하고, 간결하며, 표현력이 풍부해야 하지만, 실제 코드만큼 효율적일 필요는 없다.

목록 9-3

it("Turn on low temperature alarm at threshold", () => {
    hw.setTemp(WAY_TOO_COLD);
    controller.tic();
    expect(hw.heaterState()).toBe(true);
    expect(hw.blowerState()).toBe(true);
    expect(hw.coolerState()).toBe(false);
    expect(hw.hiTempAlarm()).toBe(false);
    expect(hw.loTempAlarm()).toBe(true);
});

위 코드는 굳이 설명하지 않더라도 온도가 '급격하게 떨어지면' 경보, 온풍기, 송풍기가 모두 가동되는지 확인하는 테스트 코드임을 알 수 있다.
물론 tic이란 함수가 뭔지 모르겠고, 상태값이 나열된 모습이 별로란 문제가 있다.

목록 9-4(목록 9-3을 리팩터링한 코드)

it("Turn on low temperature alarm at threshold", () => {
    wayTooCold();
    expect(hw.getState()).toBe("HBchL");
});

tic 함수를 숨기고, 상태를 하나하나 확인하는 대신 HBchL이란 이상한 문자열과 비교를 진행한다. {heater, blower, cooler, hi-temp-alarm, lo-temp-alarm} 순서로 대문자는 켜짐on, 소문자는 꺼짐off을 의미하는 문자열이다.
이는 그릇된 정보를 피하라3는 규칙에 위반되지만, 의미를 안다면 눈길이 문자열을 따라 움직이며 빠른 판단을 돕기에 여기서는 적절해 보인다.

목록 9-5(더 복잡한 선택)

it("Turn on cooler and blower if too hot", () => {
    tooHot();
    expect(hw.getState()).toBe("hBChl");
});

it("Turn on heater and blower if too cold", () => {
    tooCold();
    expect(hw.getState()).toBe("HBchl");
});

it("Turn on high temperature alarm at threshold", () => {
    wayTooHot();
    expect(hw.getState()).toBe("hBCHl");
});

it("Turn on low temperature alarm at threshold", () => {
    wayTooCold();
    expect(hw.getState()).toBe("HBchL");
});

위 코드를 살펴보면 9-3과 비교했을 때 테스트 코드의 이해가 한결 쉬워졌단 사실이 분명히 드러난다.

목록 9-6

function getState(): string {
    let state: string = "";

    state += heater ? "H" : "h";
    state += blower ? "B" : "b";
    state += cooler ? "C" : "c";
    state += hiTempAlarm ? "H" : "h";
    state += loTempAlarm ? "L" : "l";

    return state;
}

getState 함수는 += 연산자를 사용해 문자열을 연결하고 있다.
저자는 StringBuffer와 비교를 통해 += 연산자의 사용이 성능상 좋지 않지만 테스트 환경에선 컴퓨터 자원과 메모리가 제한적이지 않을 가능성이 높으므로 문제 없다고 말한다4.

문자열 연결 방식 벤치마크
const profile = (func) => {
    const start = Date.now();

    for (var i = 0; i < 10000000; i++) func("test");

    console.log(Date.now() - start);
};

profile((x) => "testtesttesttesttest");
profile((x) => x.repeat(5));
profile((x) => `${x}${x}${x}${x}${x}`);
profile((x) => x + x + x + x + x);
profile((x) => {
    let s = x;

    s += x;
    s += x;
    s += x;
    s += x;

    return s;
});
profile((x) => [x, x, x, x, x].join(""));
profile((x) => {
    const a = [];

    a.push(x);
    a.push(x);
    a.push(x);
    a.push(x);
    a.push(x);

    return a.join("");
});
type time (ms)
assignment 6
repeat 334
template literals 491
plus 356
plus equals 355
array join 1934
array push join 2150

Reference: Most efficient way to concatenate strings in JavaScript?

위 벤치마크를 확인해보면 JavaScript에선 사정이 좀 다르지만, 테스트 코드는 성능보다 가독성을 중시해야 하고, 배포 환경에 사용하는 규칙을 모두 똑같이 적용할 필요가 없다는 사실만 염두에 두도록 하자.

테스트 당 assert(expect) 하나

it("Get page hierarchy as XML", () => {
    givenPages("PageOne", "PageOne.ChildOne", "PageTwo");

    whenRequestIsIssued("root", { type: pages });

    thenResponseShouldBeXml();
});

it("Get page hierarchy as XML", () => {
    givenPages("PageOne", "PageOne.ChildOne", "PageTwo");

    whenRequestIsIssued("root", { type: pages });

    thenResponseShouldContain(
        "<name>PageOne</name>",
        "<name>PageTwo</name>",
        "<name>ChildOne</name>",
    );
});

위 코드는 깨끗한 테스트 코드에서 작성한 테스트 코드를 쪼개 expect를 각자 수행하게 수정한 코드다.

함수 이름을 바꿔 given-when-then이라는 관례를 사용해 코드의 가독성을 높였으나, 중복되는 코드가 많아졌다.
beforeAll 등을 활용하여 given, when 부분을 한 번만 작성하게 할 수도 있지만, 배보다 배꼽이 더 크다. 이것저것 고려해보면 결국 9-2에서처럼 expect 문을 여러 번 사용하는 편이 좋다고 생각한다.

expect 문을 여러 번 사용하는 것을 조장하는 것이 아니라, 최대한 적게 사용하려 노력하되, 모든 상황에 expect 문을 한 번만 사용하려 필요 이상의 노력을 투자하지 말자는 뜻이다.

테스트 당 개념 하나

테스트 케이스가 테스트하는 개념은 하나로 두는 것이 좋다.

it("Test add months", () => {
    const d1 = new SerialDate(31, 5, 2004);

    const d2 = d1.addMonths(1);
    expect(d2.getDayOfMonth()).toBe(30);
    expect(d2.getMonth()).toBe(6);
    expect(d2.getYYYY()).toBe(2004);

    const d3 = d1.addMonths(2);
    expect(d3.getDayOfMonth()).toBe(31);
    expect(d3.getMonth()).toBe(7);
    expect(d3.getYYYY()).toBe(2004);

    const d4 = d1.addMonths(1).addMonths(1);
    expect(d4.getDayOfMonth()).toBe(30);
    expect(d4.getMonth()).toBe(7);
    expect(d4.getYYYY()).toBe(2004);
});

위 함수는 세 가지 개념을 테스트한다.

expect 문이 여러번 활용된 것이 문제가 아니라, 여러 개념을 테스트하는 것이 문제란 뜻이다.

더불어 2월처럼 28일(혹은 29일)로 끝나는 달에 대한 테스트도 추가되면 좋겠다.

F.I.R.S.T.

깨끗한 테스트 케이스는 후술할 다섯 가지 규칙을 따른다.

결론

테스트 코드는 실제 코드만큼, 어쩌면 실제 코드보다 더욱 중요하다. 테스트 코드는 실제 코드의 유연성, 유지 보수성, 재사용성을 보존하고 강화하기 때문이다. 테스트 코드를 지속해서 깨끗하게 관리하며, 표현력을 높이고 간결하게 정리하자. 더불어 테스트 API를 구현해 도메인 특화 언어Domain Specific Language, DSL를 만들자. 그러면 그만큼 테스트 코드를 짜기가 쉬워진다.

Footnotes

  1. 2003년 Kent Beck에 의해 널리 알려진 것으로 추정됨.
    Beck, Kent (2002-11-08). Test-Driven Development by Example. Vaseem: Addison Wesley. ISBN 978-0-321-14653-3.

  2. http://butunclebob.com/FitNesse.BuildOperateCheck

  3. 2장 참조

  4. Java에선 String 자료형이 불변immutable해, += 연산이 일어날 때마다 새로운 String 객체를 생성한다. 반면 StringBuffer 객체는 append 메서드로 문자열을 추가해도 새로운 객체를 생성하지 않는다.