04. 테스트 구축하기

June·2022년 11월 8일
0

리팩터링을 제대로 하려면 불기피하게 저지르는 실수를 잡아주는 견고한 Test suite가 뒷반침돼야 한다.

4.1 자가 테스트 코드의 가치

Test suite 는 강력한 버그 검출 도구로 버그를 찾는 데 걸리는 시간을 대폭 줄여준다.

리팩터링에는 테스트가 필요하다. 리팩터링을 하고 싶다면 테스트를 반드시 작성해야 한다.

간혹 테스트가 갖춰지지 않은 코드를 리팩터링해야 할 때도 있다. 그럴 때는 곧바로 리팩터링하지 않고, 먼저 테스트 코드부터 작성한다.

4.2 테스트할 샘플 코드

애플리케이션

  • 사용자가 생산 계획을 검토하고 수정하도록 해주는 애플리케이션
    • 생산 계획은 아래 3가지 요소로 구성
      • province (지역)
      • demand (수요)
      • price (가격)
    • Producer (생산자) : 각 지역에 위치한 생산자들은 각기 제품을 특정 가격으로 특정 수량만큼 생산할 수 있음
      • production: 생산자별 생산량
      • cost : 생산자별 생산 비용
    • full revenue(수익) : 생산자별로 제품을 모두 판매했을 때 얻을 수 있는 수익
    • shortfall (생산 부족분) : 수요 - 총생산량 (예시에서 30 - (9+10+6) = 5)
    • profit (총수익) : 현재 계획에서 거둘 수 있는 총수익 (예시에서 제품 가격(20)*총생산량(25) - 개별 생산자들의 수익을 모두 더한 값(90 + 120 + 60))
  • 위 애플리케이션의 비즈니스 로직(shortfall 과 profit을 계산하는 클래스) 만 살펴보고 View 로직에 대한 코드는 생략
  • 비즈니스 로직 코드는 클래스 두 개로 구성
    • Producer : 생산자를 표현하는 클래스
    • Province: 지역 전체를 표현하는 클래스
      • Province의 생성자는 JSON 문서로부터 만들어진 자바스크립스 객체를 인수로 받는다.
class Province {
	constructor(doc) {
    	this._name = doc.name
      	this._producers = [];
      	this._totalProduction = 0;
      	this._demand = doc.demand;
      	this._price = doc.price;
      	doc.producers.forEach(producer => this.addProducer(new Producer(this, producer));
    }
  
  	addProducer(arg) {
      this._producers.push(arg);
      this._totalProduction += arg.production;
    }
  
    // 데이터 접근자
    // setter는 UI에서 입력한 숫자를 인수로 받는데, 
    // 이 값은 문자열로 전달되기 때문에 계산에 활용하기 위해 숫자로 파싱
    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(producer => {
                const contribution = Math.min(remainingDemand, producer.production);
                remainingDemand -= contribution;
                result += contribution * producer.cost;
              })
      return result;
    }
}
// Province의 생성자의 인수로 쓸 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
  };
}
class 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; }
    // 계산 결과를 지역 데이터(_province)에 갱신하는 코드가 지저분하지만, 
    //리팩터링을 하려면 먼저 테스트를 작성해야 한다. 
    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 첫 번째 테스트

생산 부족분 (Province 클래스의 shortfall) 을 제대로 계산하는지 확인하는 테스트

describe('province', () => {
  it('shortfall', () => {
    const asis = new Province(sampleProvinceData()) // 1)
    assert.equal(asia.shortfall, 5) // 2)
    // 위의 assert 문은 expect(asia.shortfall).equal(5); 랑 같은 의미
  })
})

1) 테스트에 필요한 데이터와 객체를 뜻하는 fixture(고정장치)를 설정한다.

  • 이 예시에서는 샘플 지역 정보로 부터 생성한 Province 객체를 fixture로 설정했다.

2) 이 fixture 의 속성을 검증한다.

  • 예시에서는 주어진 초깃값에 기초하여 생산 부족분을 정확히 계산했는지 확인한다.

describe와 it 블록에 부연 설명용 문자열을 써넣는 방식
- 테스트가 무엇을 검사하는지를 설명해 넣는 경우
- 명확한 코드에는 주석이 필요 없는 것과 같은 맥락으로 그냥 비워두는 경우
- 실패한 테스트가 무엇인지 식별할 수 있는 정도로만 작성하는 경우

각각의 테스트가 모두 통과하더라도 일시적으로 코드에 오류를 주입하여 테스트가 실패하는지 한번 확인해봐야 한다.

class Province {
  //(...)
  get shortfall() {
  	return this._demand - this.totalProduction *2 // *2를 넣어 강제로 오류를 주입
  }

위 코드처럼 강제로 코드에 오류를 주입하여 다시 테스트를 실행하여 실패하는지 확인해본다.

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

4.4 테스트 추가하기

클래스가 하는 일을 모두 살펴보고 각각의 기능에서 오류가 생길 수 있는 조건을 하나씩 테스트하는 식으로 진행한다.

  • 일부 프로그래머들이 선호하는 public method를 모두 테스트하는 방식과는 다르다
    • 테스트의 목적은 현재 혹은 향후에 발생하는 버그를 찾는 데 있기 때문에 단순히 필드를 읽고 쓰기만 하는 접근자 메서드는 테스트할 필요가 없다.
  • 테스트를 너무 많이 만들다 보면 오히려 필요한 테스트를 놓치기 쉽기 때문에 잘못될까봐 가장 걱정되는 영역만을 집중적으로 테스트해서 테스트에 쏟는 노력의 효과를 극대화하는 것이 좋다.

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

이제 총 수익 계산 로직을 테스트해보자

describe('province', () => {
  //(...)
  it('profit', () => {
    const asis = new Province(sampleProvinceData()) 
    expect(asia.profit).equal(230) // (1)
  })
})

1) 230이라는 숫자를 넣을 때, 기댓 값 자리에 임의의 값을 넣고 먼저 테스트를 수행한 다음 프로그램이 내놓는 실제 값(230)으로 대체한다. 그런 다음 테스트가 제대로 작동한다고 확인되면 총 수익 계산 로직에 오류 코드를 주입해서 잘못된 값이 나오도록 수정하고, 일부러 주입한 코드 때문에 테스트가 잘 실패하는게 확인되면, 원래 코드로 되돌린다.

지금까지 작성한 두 테스트에서 겹치는 부분을 제거해보자

// 안 좋은 예시 
describe('province', () => {
  const asis = new Province(sampleProvinceData()) // 이렇게 하면 안된다.
  it('shortfall', () => {
    expect(asia.shortfall).equal(5)
  })
  it('profit', () => {
    expect(asia.profit).equal(230)
  })
})

위 코드처럼 하게되면 테스트끼리 상호작용하게 하는 공유 픽스처를 생성하게 된다. 이는 테스트 관련 버그 중 가장 지저분한 유형이다.

  • 나중에 이 공유 객체의 값을 수정하면 이 fixture를 사용하는 또 다른 테스트가 실패할 수 있다.
  • 테스트를 실행하는 순서에 따라 결과가 달라질 수 있다. 이렇게 되면 테스트 결과가 제멋대로 되어 버그를 잡기가 어렵다
// 좋은 예시 
let asia;
describe('province', () => {
  beforeEach(() => {
    asia = new Province(sampleProvinceData())
  }
             
  it('shortfall', () => {
    expect(asia.shortfall).equal(5)
  })
  
  it('profit', () => {
    expect(asia.profit).equal(230)
  })
}

beforEach 구문은 각각의 테스트 바로 전에 실행되어 asia 를 초기화하기 때문에 모든 테스트가 자신만의 새로운 asia를 사용하게 된다.

  • 개별 테스트를 실행할 때마다 fixture를 새로 만들면 모든 테스트를 독립적으로 구성할 수 있어서 결과를 예측할 수 없어지는 문제를 예방할 수 있다.

불변임이 확실한 fixture 는 공유하기도 하지만, 매번 새로운 fixture를 만드는 것이 가장 좋다.

beforeEach 는 코드를 읽는 사람에게 표준 fixture를 사용한다는 사실을 알려줘서 describe 블록 안의 모든 테스트가 똑같은 기준 데이터로부터 시작한다는 사실을 쉽게 알 수 있다.

4.5 fixture 수정하기

실전에서는 사용자가 값을 변경하면서 fixture 의 내용도 수정하는 경우가 흔하다.
이런 수정은 대부분 setter 에서 이뤄지는데, setter 는 대부분 매우 단순해서 잘 테스트하지 않지만 Producer의 production setter는 복잡한 동작을 수행하기 때문에 테스트해볼 필요가 있다.

describe('province', () => {
  (...)
  it('change production', () => {
    asia.producers[0].production = 20
    expect(asia.shortfall).equal(-6);
    expect(asia.profit).equal(292);
  }
}

위 예시에는 beforeEach 블록에서 설정한 표준 fixture 를 취해서 테스트를 '수행'하고, 이 fixture 가 일을 기대한 대로 처리했는지 '검증' 한다. 이 패턴을 setup-exercise-verify / given-when-then / arrange-act-assert 등으로 부른다.

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

teardown / cleanup 이라고 하는 네 번째 단계도 있는데, 이 단계에서 fixture 를 제거하여 테스트들이 서로 영향을 주지 못하게 막는다. 설정을 모두 beforeEach에서 수행하도록 작성해두면 테스트들 사이에 걸친 fixture 를 테스트 프레임워크가 알아서 해체해주기 때문에 굳이 단계를 나눌필요는 없다. 대부분의 경우 이 단계는 무시할 때도 많지만 생성하는 데 시간이 오래걸려서 여러 테스트가 공유해야만 하는 fixture가 있는 경우 이 단계를 수행하기도 한다.

일반적으로 it 구문 하나당 검증도 하나씩만 수행하는 게 좋다.

  • 앞쪽 검증을 통과하지 못하면 나머지 검증은 실행이 안되고 테스트가 실패하기 때문에 원인을 파악하는데 유용한 정보를 놓치기 쉽다.
  • 한 테스트로 묶어도 문제되지 않을 정도로 두 속성이 밀접하다면 묶어도 좋다.

4.6 경계 조건 검사하기

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

// 경계 조건 테스트 예시
// Producers 의 컬렉션이 비었을 때 조건을 검사한다. 
describe('no producers', () => {
  let noProducers
  beforeEach(() => {
    const data = {
      name: 'No producers',
      producers: [],
      demand: 30,
      price: 20
    }
    noProducers = new Province(data)
  })
  
  it('shortfall', () => {
    expect(noProducers.shortfall).equal(30)
  })
  
  it('profit', () => {
    expect(noProducers.profit).equal(0)
  })
})

//숫자형이라면 0일 때를 검사해본다. 
describe('province', () => {
  //(...)
  it('zero demand', () => {
  	asia.demand = 0
    expect(asia.shortfall).equal(-25)
    expect(asia.profit).equal(0)
  })
})

// 음수도 넣어본다. 
describe('province', () => {
  //(...)
  it('negative demand', () => { //수요가 음수?
  	asia.demand = -1
    expect(asia.shortfall).equal(-26)
    expect(asia.profit).equal(-10)
  })
})

위의 예시에서 수요가 음수라는 시나리오는 이상하다. 수요의 최솟값은 0이어야 하거나 수요 setter 에 전달된 인수가 음수라면 에러를 던지거나 무조건 0으로 설정하는 식으로 처리하는게 정상적인 것으로 생각할 수 있다.

이처럼 경계를 확인하는 테스트를 작성해보면 특이 상황을 어떻게 처리하는 게 좋을지 생각해볼 수 있다.

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

이 프로그램은 UI로부터 문자열을 취하고 있다. 그러다보니 필드가 아예 비어 있을 수도 있다. 이때도 의도한 대로 잘 처리하는지 반드시 테스트해야 한다.

describe('province', () => {
  //(...)
  it('empty string demand', () => { //수요가 음수?
  	asia.demand = ""
    expect(asia.shortfall).NaN
    expect(asia.profit).NaN
  })
})

생산자 수 필드에 문자열을 대입하는 테스트를 해보자

describe('string for producers', () => {
  it('', () => { 
    const data = {
      name: "string producers",
      producers: "",
      demand: 30,
      price: 20
    }
    const prov = new Province(data)
    expect(prov.shortfall).equal(0)
  })
})

이 테스트는 shorfall이 0 이 아니라는 실패 메세지를 출력하는 대신 에러 메세지를 출력한다.

이런 경우에 대해 테스트 프레임워크 마다 에러와 실패를 구분하기도, 구분하지 않기도 한다. 실패란 검증 단계에서 실제 값이 예상 범위를 벗어났다는 뜻이다. 에러는 검증보다 앞선 과정에서 발생한 예외 상황을 말한다.

이런 오류로 프로그램 내부에 잘못된 데이터가 흘러서 디버깅하기 어려운 문제가 발생한다면 Assertion 추가하기 (10.6 절)을 적용하여 오류가 최대한 빨리 드러나게 한다. assertion 도 일종의 테스트로 볼 수 있으니 테스트 코드를 따로 작성할 필요는 없다

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

테스트를 어느 수준까지 해야 할까? 테스트를 너무 많이 작성하다보면 오히려 의욕이 떨어져 나중에는 하나도 작성하게 될 위험이 있다. 따라서 위험한 부분에 집중하는 게 좋다. 코드에서 처리 과정이 복잡한 부분을 찾고 함수에서 오류가 생길만한 부분을 찾아서 테스트한다.

테스트가 모든 버그를 걸러주지는 못할지라도 안심하고 리팩터링할 수 있는 보호막을 될 수 있다.

리팩터링을 하면서 프로그램을 더욱 깊이 이해하게 되어 더 많은 버그를 찾을 수 있다.

항상 test suite부터 갖추고 리팩터링을 하고, 리팩터링을 하는 동안에도 계속해서 테스트를 추가한다.

4.7 끝나지 않은 여정

테스트는 리팩터링에 반드시 필요한 토대이다.

테스트 용이성을 아키텍처 평가 기준으로 활용하는 사례도 많다.

다른 프로그래밍 활동과 마찬가지로 테스트도 반복적으로 진행한다.

제품 코드에 못지 않게 test suite도 지속해서 보강한다.

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

0개의 댓글