박종훈 기술블로그

테스트 구축하기 - 마틴 파울러 리팩터링 4장

테스트 구축하기 - 리팩터링(마틴 파울러) 4장


리팩터링을 제대로 하려면 불가피하게 저지르는 실수를 잡아주는 견고한 테스트 스위트(suite)가 뒷받침돼야 한다. 이게 단점이 되는 것은 아니다. 리팩터링을 하지 않더라도 좋은 테스트를 작성하는 일은 개발 효율을 높여준다. 놀랍게도 테스트 작성에 시간을 빼앗기는데도 효율이 높아진다.

효율이 높아지는 이유에 대해서 알아보자

4.1 자가 테스트 코드의 가치

프로그래머들이 실제로 코드를 작성하는 시간의 비중은 그리 크지 않다. 현재 상황을 파악하기도 하고, 설계를 고민하기도 한다. 그리고 대부분의 시간은 디버깅에 쓴다. 버그 수정 자체는 대체로 금방 끝난다. 진짜 끔찍한 건 버그를 찾는 여정이다. 버그를 찾느라 수많은 시간을 날린다.

모든 테스트를 완전히 자동화하고 그 결과까지 스스로 검사하게 만들자.

디버깅 시간이 크게 줄어든다. 직전까지 테스트가 성공했다면 마지막 테스트 이후에 작성한 코드에서 버그가 발생했음을 알 수 있다. 의심되는 코드의 양이 많지 않고 아직 기억이 생생하니 버그를 쉽게 찾을 수 있다.

회귀 버그(regression bug)를 잡는데 도움이 된다.

회귀 버그란 잘 동작하던 기능에서 문제가 생기는 현상을 가리키며, 일반적으로 프로그램을 변경하는 중 뜻하지 않게 발생한다. 같은 맥락에서, 잘 작동하던 기능이 여전히 잘 작동하는지 확인하는 테스트를 회귀 테스트(regression test)라 한다.

테스트 스위트는 강력한 버그 검출 도구로, 버그를 찾는 데 걸리는 시간을 대폭 줄여준다

테스트를 작성하려면 소프트웨어 제품 본체 외의 부가적인 코드를 상당량 작성해야 한다. 그래서 테스트가 실제로 프로그래밍 속도를 높여주는 경험을 직접 해보지 않고서는 자가 테스트의 진가를 납득하긴 어렵다.

테스트를 작성하기 가장 좋은 시점은 프로그래밍을 시작하기 전이다. 얼핏 순서가 뒤바뀐 듯 들리지만, 전혀 그렇지 않다. 테스트를 작성하다 보면 원하는 기능을 추가하기 위해 무엇이 필요한지 고민하게 된다. 구현보다 인터페이스에 직중하게 된다는 장점도 있다 (무조건 좋은 일이다). 게다가 코딩이 완료되는 시점을 정확하게 판단할 수 있다. 테스트를 모두 통과한 시점이 바로 코드를 완성한 시점이다.

켄트 벡은 이처럼 테스트부터 작성하는 습관을 바탕으로 테스트 주도 개발 (Test-Driven Development, TDD) 이란 기법을 창시했다. TDD에서는 처음에는 통과하지 못할 테스트를 작성하고, 이 테스트를 통과하게끔 코드를 작성하고, 결과 코드를 최대한 깔끔하게 리팩터링하는 과정을 짧은 주기로 반복한다. 이러한 테스트-코딩-리팩터링 과정을 한 시간에도 여러 차례 진행하기 때문에 코드를 대단히 생산적이면서도 차분하게 작성할 수 있다.

이번 장에서는 자바스크립트 프로그램용으로 테스트 코드를 작성하는 방법을 소개한다. (깊게 들어가지는 않는다.)

4.2 테스트할 샘플 코드

샘플 애플리케이션의 ui는 다음과 같다.

application ui

생산 계획은 각 지역(province)의 수요(demand)와 가격(price)으로 결정된다. 지역에 위치한 생산자(producer)들은 각기 제품을 특정 가격으로 특정 수량만큼 생산할 수 있다. 생산자별로 제품을 모두 판매했을 때 얻을 수 있는 수익(full revenue)도 보여준다. 화면 맨 아래에는 (수요에서 총생산량을 뺀) 생산 부족분(shortfall)과 현재 계획에서 거둘 수 있는 총수익(profit)도 보여준다. 사용자는 수요, 가격, 생산자별 생산량(production)과 비용(cost)을 조정해가며, 그에 따른 생산 부족분과 총수익을 확인할 수 있다. 사용자가 화면에서 숫자를 변경할 때마다 관련 값들이 즉각 갱신된다.

230 = (30 - 5) * 20 - (90 + 120 + 60)

여기서는 비즈니스 로직 부분만 집중해서 살펴본다. 다시 말해 수익과 생산 부족분을 계산하는 클래스들만 살펴보고, HTML 생성 및 변경과 관련된 부분은 생략한다.

이 장의 목적은 어디까지나 자가 테스트 코드 작성법을 파악하는데 있다. 따라서 UI, 영속성, 외부 서비스 연도오가는 관련 없는 가장 쉬운 코드부터 보는 게 합당하다. 코드는 항상 이렇게 성격에 따라 분리하는 것이 좋다.

비즈니스 로직 코드는 클래스 두 개로 구성된다. 하나는 생산자를 표현하는 Producer이고, 다른 하나는 지역 전체를 표현하는 Province다. Province의 생성자는 JSON 문서로부터 만들어진 자바스크립트 객체를 인수로 받는다.

JSON 데이터로부터 지역 정보를 읽어오는 코드는 다음과 같다.

constructor(doc) {
  this._name = doc.name;
  this._producers = [];
  this._totalProduction = 0;
  this._demand = doc.demand;
  this._price = doc.price;
  doc.producers.forEach(d => this.addProducer(new Producer(this, d)));
}

addProducer(arg) {
  this._producers.push(arg);
  this._totalProduction += arg.production;
}

다음의 sampleProvinceData() 함수는 앞 생성자의 인수로 쓸 JSON 데이터를 생성한다. 이 함수를 테스트하려면 이 함수가 반환한 값을 인수로 넘겨서 Province 객체를 생성해보면 된다.

function sampleProvinceData() {
  return {
    name: "Asia",
    producers: [
      { name: "Byzantium", cost: 10, production: 9 },
      { name: "Attalia", cost: 12, production: 10 },
      { name: "Sinope", cost: 10, production: 6 },
    ],
    demand: 30,
    price: 20,
  };
}

Province 클래스의 함수는 다음과 같다.

get name() {return this._name;}
get producers() {return this._producers.slice();}
get totalProduction() {return this._totalProduction;}
set totalProduction(arg) {this._totalProduction = arg;}
get demand() {return this._demand;}
set demand(arg) {this._demand = parseInt(arg);}
get price() {return this._price;}
set price(arg) {this._price = parseInt(arg);}

get shortfall() {
  return this._demand­ - this.totalProduction;
}

get profit() {
  return this.demandValue -­ this.demandCost;
}

get demandValue() {
  return this.satisfiedDemand * this.price;
}

get satisfiedDemand() {
  return Math.min(this._demand, this.totalProduction);
}

get demandCost() {
  let remainingDemand = this.demand;
  let result = 0;
  this.producers
    .sort((a,b) => a.cost -­ b.cost) .forEach(p => {
      const contribution = Math.min(remainingDemand, p.production);
      remainingDemand -­= contribution;
      result += contribution * p.cost;
    });
  return result;
}

Producer 클래스의 함수는 다음과 같다.

constructor(aProvince, data) {
  this._province = aProvince;
  this._cost = data.cost;
  this._name = data.name;
  this._production = data.production || 0;
}
get name() {return this._name;}
get cost() {return this._cost;}
set cost(arg) {this._cost = parseInt(arg);}

get production() {return this._production;}
set production(amountStr) {
  const amount = parseInt(amountStr);
  const newProduction = Number.isNaN(amount) ? 0 : amount; this._province.totalProduction += newProduction -­ this._production; this._production = newProduction;
}

4.3 첫 번째 테스트

테스트하기 위해서는 먼저 테스트 프레임워크를 마련해야 한다. 여기서는 모카(mocha)를 사용한다. 모카에 대한 사용법을 설명하기 보다는 테스트 예시 몇 가지를 보여준다. 다른 프레임워크를 사용하더라도 비슷한 테스트를 어렵지 않게 구축할 수 있을 것이다.

생상 부족분을 제대로 계산하는지 확인하는 테스트는 다음과 같다.

describe("province", function () {
  it("shortfall", function () {
    const asia = new Province(sampleProvinceData());
    assert.equal(asia.shortfall, 5);
  });
});

모카 프레임워크는 테스트 코드를 블록 단위로 나눠서 각 블록에 테스트 스위트를 담는 구조다.

위의 예제에서는 테스트를 두 단계로 진행했다.

  1. 테스트에 필요한 데이터와 객체를 뜻하는 픽스처(fixture: 고정장치)를 설정한다. 이 테스트에서는 샘플 지역 정보로부터 생성한 Province 객체를 픽스처로 설정했다.
  2. 픽스처의 속성들을 검증한다. (여기서는 주어진 초깃값에 기초하여 생산 부족분을 정확히 계산했는지 확인한다.)

이 테스트를 Node.js 콘솔에서 실행하면 다음과 같이 출력된다.

’’’’’’’’’’’’’’
1 passing (61ms)

간결하게 수행한 테스트와 통과한 테스트의 수를 보여준다.


실패해야 할 상황에서는 반드시 실패하게 만들자.

테스트를 작성하고, 모두 통과했다는 건 좋은 일이다. 하지만 때때로 의도와는 다른 방식으로 코드를 다루는 건 아닌지 불안해지기도 한다. 그래서 각각의 테스트가 실패하는 모습을 최소 한 번씩은 직접 확인해본다. 일시적으로 코드에 오류를 주입하면 된다. 예를 들면 다음과 같다.

get shortfall() {
  return this._demand­ - this.totalProduction * 2;
}

* 2 를 추가하여 오류를 주입하였다. 수정 후 테스트를 실행하면 다음과 같이 출력된다.

!
  0 passing (72ms)
  1 failing

  1) province shortfall:
    AssertionError: expected -­20 to equal 5
      at Context.<anonymous> (src/tester.js:10:12)

이처럼 테스트 프레임워크를 이용하면 문제가 생겼을 때 즉시 알 수 있다. 게다가 어느 테스트가 싪패했는지 짚어주고, 실패 원인을 추론해볼 수 있는 단서까지 제공한다.

자주 테스트하라. 작성 중인 코드는 최소한 몇 분 간격으로 테스트하고, 적어도 하루에 한 번은 전체 테스트를 돌려보자.

실전에서는 테스트의 수는 수천 개 이상일 수 있다. 간결한 피드백은 자가 테스트에서 매우 중요하다.

모카 프레임워크는 소위 어서쎤(assertion: 단언, 확인) 라이브러리라고 하는 픽스처 검증 라이브러리를 선택해 사용할 수 있다. 이 책에서는 차이 라이브러리를 사용한다.

차이를 사용하면 위에서 사용한 assert 문을 expect 문으로 표현할 수 있다.

assert.equal(asia.shortfall, 5);
expect(asia.shortfall, 5);

둘의 기능은 동일하다.

테스트 추가하기

테스트는 위험 요인을 중심으로 작성해야 한다. 테스트의 목적은 어디까지나 현재 혹은 향후에 발생하는 버그를 찾는데 있다. 따라서 단순히 public이라고 테스트하거나 접근자처럼 단순한 함수는 테스트할 필요는 없다. 이런 코드는 너무 단순해서 버그가 숨어들 가능성도 별로 없다.

테스트를 너무 많이 만들면 오히려 필요한 테스트를 놓치기 쉽기 때문에 아주 중요한 포인트다. 잘못될까봐 가장 걱정되는 영역을 집중적으로 테스트하라. 적은 수의 테스트로 테스트에 쏟는 노력의 효과를 극대화 하라.

완벽하게 만드느라 테스트를 수행하지 못하느니, 불완전한 테스트라도 작성해 실행하는 게 낫다.

이 맥락에서 총수익 계산 로직을 테스트해보자.

describe("province", function () {
  it("shortfall", function () {
    const asia = new Province(sampleProvinceData());
    expect(asia.shortfall).equal(5);
  });
  it("profit", function () {
    const asia = new Province(sampleProvinceData());
    expect(asia.profit).equal(230);
  });
});

profit 부분을 추가하였다.

코드를 보면 중복되는 부분이 있다. 중복을 제거하려면 아래와 같이 수정할 수 있을 것이다.

describe("province", function () {
  const asia = new Province(sampleProvinceData()); // 이렇게 하면 안 된다.
  it("shortfall", function () {
    expect(asia.shortfall).equal(5);
  });
  it("profit", function () {
    expect(asia.profit).equal(230);
  });
});

하지만 주석에 적은 것처럼 이렇게 하면 안된다. 이렇게 하면 일시적인 효과는 있겠지만, 테스트 관련 버그 중 가장 지저분한 유형인 ‘테스트끼리 상호작용하게 하는 공유 픽스처’가 된다. 테스트에서 공유 객체의 값을 수정하면 이 픽스처를 사용하는 또 다른 테스트가 실패할 수 있다. 즉, 테스트의 실행 순서에 따라 결과가 달라질 수 있게 된 것이다. 이렇게 되면 테스트 결과가 제멋대로가 되어 버그를 잡기가 어렵고 오래걸린다. 더 심하면 테스트 자체를 믿지 못하게 된다.

아래와 같이 수정하는 것이 좋다.

describe("province", function () {
  let asia;
  beforeEach(function () {
    asia = new Province(sampleProvinceData());
  });
  it("shortfall", function () {
    expect(asia.shortfall).equal(5);
  });
  it("profit", function () {
    expect(asia.profit).equal(230);
  });
});

beforeEach 구문은 각각의 테스트 바로 전에 실행되어 asia를 초기화하기 때문에 모든 테스트가 자신만의 새로운 asia를 사용하게 된다. 이처럼 개별 테스트를 실행할 때마다 픽스처를 새로 만들면 모든 테스트를 독립적으로 구성할 수 있어서, 결과를 예측할 수 없어 골치를 썩는 사태를 예방할 수 있다.

beforeEach 구문을 통해서 테스트들이 모두 똑같은 픽스처에 기초하여 검증을 수행하는 것을 보장할 수 있다(표준 텍스처). 코드를 읽는 사람은 describe 블록 안의 모든 테스트가 똑같은 기준 데이터로부터 시작한다는 사실을 쉽게 알 수 있다.

4.5 픽스처 수정하기

실전에서는 사용자가 값을 변경하면서 픽스처의 내용도 수정되는 경우가 흔하다.

이러한 수정 대부분은 새터에서 이뤄지는데, 세터는 보통 아주 단순하여 버그가 생길 일이 별로 없지만 Producer의 production() 세터는 좀 복잡한 동작을 수행하기 때문에 테스트해볼 필요가 있다.

describe('province'{
  /* before each */
  it('change production', function() {
    asia.producers[0].production = 20;
    expect(asia.shortfall).equal(-­6);
    expect(asia.profit).equal(292);
  });
}

이 테스트는 beforeEach 블록에서 설정한 표준 픽스처를 취해서 테스트를 수행하고 이 픽스처가 일을 기대한 대로 처리했는지 검증한다.

이는 테스트에서 흔히 볼 수 있는 패턴이다. 이 패턴을

  • 설정-실행-검증(setup-exercise-verify)
  • 조건-발생-결과(given-when-then)
  • 준비-수행-단언(arrange-act-assert)

등으로 부른다.

이 세가지 단계가 한 테스트 안에 모두 담겨 있을 수도 있고, 초기 준비 작업 중 공통되는 부분을 beforeEach와 같은 표준 설정 루틴에 모아서 처리하기도 한다.

해체(teardown) 혹은 청소(cleanup)라고 하는 네 번째 단계도 있는데 명시적으로 언급하지 않을 때가 많다.

이 테스트는 it 구문 하나에서 두 가지 속성을 검증하고 있는데 일반적으로는 it 구문 하나당 검증도 하나씩만 하는게 좋다. 앞쪽 검증을 통과하지 못하면 나머지 검증은 실행해보지 못하고 테스트가 실패하게 되는데, 그러면 실패 원인을 파악하는 데 유용한 정보를 놓치기 쉽기 때문이다. 여기서는 한 테스트로 묶어도 문제되지 않을 정도로 두 속성이 밀접하다고 판단하여 이렇게 작성했다.

4.6 경계 조건 검사하기

지금까지 작성한 테스트는 모든 일이 순조롭고 사용자도 우리 의도대로 사용하는, 일명 ‘꽃길(happy path)’ 상황에 집중하였다. 그런데 이 범위를 벗어나는 경계 지점에서 문제가 생기면 어떤 일이 벌어지는지 확인하는 테스트도 함꼐 작성하면 좋다.

예를 들어 컬렉션을 테스트 해야한다면 비어 있을 때를 검사해보고 숫자형이라면 0일 때나 음수 일 때를 검사해본다.

문제가 생길 가능성이 있는 경계 조건을 생각해보고 그 부분을 집중적으로 테스트하자.

어처피 모든 버그를 잡아낼 수 없다고 생각하여 테스트를 작성하지 않는다면 대다수의 버그를 잡을 수 있는 기회를 날리는 셈이다.

그렇다면 테스트를 어느 수준까지 해야 할까? 위험한 부분에 집중하자. 코드에서 처리 과정이 복잡한 부분을 찾아보자. 함수에서 오류가 생길만한 부분을 찾아보자. 테스트가 모든 버그를 걸러주지는 못할지라도, 안심하고 리팩터링할 수 있는 보호막은 되어준다. 그리고 리팩터링을 하면서 프로그램을 더욱 깊이 이해하게 되어 더 많은 버그를 찾게 된다.

4.7 끝나지 않은 여정

테스트는 리팩터링에 반드시 필요한 토대일 뿐만 아니라, 그 자체로도 프로그래밍에 중요한 역할을 한다.

이 장에서 보여준 테스트는 단위 테스트(unit test)에 해당한다. 단위 테스트란 코드의 작은 영역만을 대상으로 빠르게 실행되도록 설계된 테스트다. 단위 테스트는 자가 테스트 코드의 핵심이자, 자가 테스트 시스템은 대부분 단위 테스트가 차지한다. 그 외에도 다양한 테스트 종류가 있다.

한 번에 완벽한 테스트를 갖출 순 없다. 테스트 스위트도 지속해서 보강해야 한다. 새로운 기능을 추가할 때마다 테스트도 추가하는 것은 물론, 기존 테스트도 살펴봐야한다. 기존 테스트가 충분히 명확한지, 테스트 과정을 더 이해하기 쉽게 리팩터링할 수는 없는지, 제대로 검사하는지 등을 확인한다.

버그 리포트를 받으면 가장 먼저 그 버그를 드러내는 단위 테스트부터 작성하자.

버그를 발견하는 즉시 발견한 버그를 명확히 잡아내는 테스트부터 작성하는 습관을 들이자.

테스트 커버리지 분석은 코드에서 테스트하지 않은 영역을 찾는 데만 도움이 될 뿐, 테스트 스위트의 품질과는 크게 상관 없다. 너무 과도하게 테스트를 작성하는 경우도 개발 속도를 느려지게 할 수 있다.