CS Repository/리팩터링

[리팩터링] 테스트 구축하기

조금씩 차근차근 2025. 8. 26. 22:07

리팩터링을 제대로 하려면 견고한 테스트 스위트(test suite)가 뒷받침되어야 한다.

 

자동 리팩터링 도구를 활영하더라도 이 책에서 소개하는 리팩터링 중 다수는 테스트 스위트로 재차 검증해야 할 것이다.

자가 테스트 코드의 가치

프로그래머가 어떻게 일하는지 가만히 살펴보면, 실제 코드를 작성하는 시간의 비중은 그리 크지 않다.
실제로는 아래와 같은 과정에 훨씬 많은 시간을 쏟는다.

  • 현재 상황 파악
  • 설계에 대한 고민
  • 디버깅

이 "디버깅" 시간을 줄이는데, 자가 테스트 코드는 매우 중요하고 훌륭한 도구이다.

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

참고) 엄밀하게 말하자면, 테스트 코드는 코드가 잘 동작하는지 확인할 수 있는 모든 종류의 코드이고,
자가 테스트 코드는 코드가 스스로 테스트가 성공/실패 여부를 판단하고 그 결과를 반환하는 종류의 코드이다.

눈으로 일일히 테스트 코드를 검사하는 것은 상당히 비효율적이다.

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

이 책의 주제는 리팩터링이지만 TDD 언급을 살짝 하자면,
TDD는 테스트-코딩-리팩터링 과정을 계속 반복하는 프로그래밍 과정을 의미한다.
즉, 테스트가 동반되어야 리팩터링을 빠르고 훌륭하게 진행할 수 있다.

테스트할 샘플 코드

그럼 지금부터 테스트 대상이 될 기능을 살펴보자.

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

그런데 이 모든 기능을 지금 학습 단계에서 테스트하긴 까다로울 것이다.
여기서는 비즈니스 로직 부분만 집중해서 살펴보겠다.

 

다시 말해 수익과 생산 부족분을 계산하는 클래스들만 살펴보고, HTML을 생성하고 필드 값 변경에 반응하여 밑단에 비즈니스 로직을 "적용"하는 코드는 생략한다.

 


비즈니스 로직 코드는 클래스 두 개로 구성된다

  • Producer: 생산자 표현
  • Province: 지역 전체를 표현
    Province의 생성자는 JSON 문서로부터 만들어진 자바스크립트 객체를 인수로 받겠다.

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((d) => this.addProducer(new Producer(this, d)));
    }



    addProducer(producer) {
        this._producers.push(producer);
        this._totalProduction += producer.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);
}

producer 클래스는 아래와 같이 구성된다.
그냥 단순한 데이터 저장소로 쓰인다.

class Producer {
    constructor(aProvince, data) {
        this._province = aProvince;
        this._name = data.name;
        this._cost = data.cost;
        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;
    }
}

set production이 좀 코드가 지저분하다.
이걸 리팩터링하려면, 테스트를 작성을 우선 해야되기 때문에, 일단 나머지 코드를 다 작성하자.

 

생산 부족분과 수익을 계산하는 코드는 아래와 같다.

    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;
    }

첫 번째 테스트

일단 첫 번째 테스트 코드를 작성해보자.
테스트 프레임워크로는 Mocha를 사용했다.

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

 

 

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

테스트가 실패한다면 리팩터링하지 말라.

 

자가 테스트의 핵심은 '모든 테스트가 통과했다'라는 사실을 빨리 알 수 있다는 데 있다.

테스트 추가하기

계속해서 테스트를 추가해보자.

 

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

테스트는 위험한 요인을 중심으로 작성해야 한다.
테스트의 목적은 어디까지나 현재 혹은 향후에 발생하는 버그를 찾는 데 있다.

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

이 맥락에서 샘플 코드의 또 다른 주요 기능인 총 수익 계산 로직을 테스트해보겠다.
앞에서와 마찬가지로 초기 픽스처로부터 총수익이 제대로 계산되는지 간단히 검사하도록 작성한다.

일반 코드에서와 마찬가지로 테스트 코드에서도 중복은 자세히 봐야 한다.
여기선 asia 코드가 중복 생성되고 있는데, 이 중복을 제거해보자.


지금 이 코드의 문제점이 보이는가?

테스트는 반복가능해야 한다.
테스트는 서로 간에 간섭이 없어야 한다.

현재 위 코드는 '테스트끼리 상호작용하게 하는 공유 픽스처'가 존재하고 있다.

 

그러니 아래와 같은 방식으로 코드를 작성하자.


이렇게 작성하면 매번 픽스처를 생성하느라 테스트가 느려지지 않냐고 묻는 사람이 있다.

눈에 띄게 느려지는 일은 거의 없다.
정말 문제가 될 때는 공유 픽스처를 사용하기도 하지만, 이럴 때는 어떠한 테스트도 픽스처 값을 변경하지 못하도록 주의한다.
또한 불변임이 확실한 픽스처는 공유하기도 한다.
그래도 가장 선호하는 방식은 매번 새로운 픽스처를 만드는 것이다.


 

테스트마다 beforeEach 구문이 실행된다면 그 안의 코드를 각각의 it 블록에 넣으면 되지 않냐고 물을 수 있다.
나는 내 테스트들이 모두 똑같은 픽스처에 기초하여 검증을 수행하기를 바란다.
그래야 표준 픽스처에 익숙해져서 테스트할 속성을 다양하게 찾아낼 수 있기 때문이다.
beforeEach 블록의 등장은 내가 표준 픽스처를 사용한다는 사실을 알려준다.

픽스처 수정 테스트하기

지금까지 작성한 테스트 코드를 통해 픽스처를 불러와 그 속성을 확인하는 방법을 알 수 있었다.
그런데 실전에서는 사용자가 값을 변경하면서 픽스처의 내용도 수정되는 경우가 흔하다.
이러한 수정 중 우리 코드에선 production()이 좀 복잡한데, 이 코드를 테스트해보자.


이 코드에서도 한가지 문제가 있다.
테스트 하나당, 하나의 속성에 대한 검증만 수행하는 것이 좋다.
여기서는 shortfall과 profit을 동시에 검증하고 있는데, 이런 방식보다는 아래의 방식이 좋다.


이렇게 나눠서 테스트하게 되면, 앞에서 실행한 단정문이 실패했을 때에도 뒤에서 실행한 단정문의 결과를 확인할 수 있다.

하지만, 여기서는 한 테스트로 묶어도 문제되지 않을 정도로 두 속성이 밀접하다고 판단하여 이렇게 작성하도록 하겠다.
별개의 it 구문으로 나누고 싶다면 언제든지 나눌 수 있다.

경계 조건 검사하기

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

이번 예시에선 producers 와 같은 컬렉션과 마주하면 그 컬렉션이 비었을 때 어떤 일이 일어나는지 확인해보자.


숫자형이면 0일 때를 검사해보자.

음수도 넣어보면 좋다.

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

 

 

이러한 실패 케이스에 대한 테스트는 경험과 논리로 구성해야 한다.
수없이 틀려보고 논리적으로 코딩하다 보면 자연스레 떠오르는 경계 조건들이 생길 것이다.

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

끝이 아니다.

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

"어느 정도 하면 충분히 테스트했다고 할 수 있나요?"
이 질문에 대한 명확한 기준은 없다.

이 세가지를 기억하자.

  • 틀릴만한 부분을 테스트하자.
  • 이런 경우엔 테스트가 과하게 작성된 것이다.
    • 제품 코드보다 테스트 코드를 수정하는 데 시간이 더 걸린다면
    • 테스트 때문에 개발 속도가 느려진다고 생각되면
  • 하지만 너무 많은 경우보다는 너무 적은 경우가 훨씬 훨씬 많다.

 

본 내용은 마틴 파울러의 리팩터링 도서를 참고하여 작성되었습니다.

 

리팩터링 2판 - 예스24

개발자가 선택한 프로그램 가치를 높이는 최고의 코드 관리 기술마틴 파울러의 『리팩터링』이 새롭게 돌아왔다.지난 20년간 전 세계 프로그래머에게 리팩터링의 교본이었던 『리팩토링』은,

www.yes24.com