본문으로 바로가기

[Refactoring] 4. 테스트 구축하기

category Program/Refactoring 2022. 10. 23. 23:22

4. 테스트 구축하기

리팩터링을 제대로 하려면 불가피하게 저지르는 실수를 잡아주는 견고한 테스트스위트가 뒷밤침 되어야 한다.

  • 리팩터링을 하지 않더라도 좋은 테스트를 작성하는 일은 개발효율을 높혀준다.

4.1 자가 테스트 코드의 가치

  • 개발할때 실제로 코드를 작성하는 시간의 비중은 크지 않다. (현행파악, 설계고민, 디버깅 등)
  • 모든 테스트를 완전히 자동화하고 그 결과까지 스스로 검사하게 만들자
    • 컴파일때마다 테스트도 함께 실행 > 생산성 증가
    • 테스트를 자주 수행하는 습관은 버그를 찾는 강력한 도구가 될 수 있다.
    • 테스트 스위트(test suite)는 강력한 버그 검출 도구로, 버그를 찾는 데 걸리는 시간을 대폭 줄여준다.
    • Junit : 스몰토크 버전의 단위 테스트 프레임워크를 켄트백이 Java로 포팅한 것
  • 테스트를 작성하기 가장 좋은 시점은 프로그래밍을 시작하기 전이다.
    • 기능을 추가할때 테스트 부터 작성한다.
    • 그럼 원하는 기능을 추가하기 위해 무엇이 필요한지 인터페이스에 집중하게 될 수 있다.
    • 테스트를 모두 통과한 시점이 코드를 완성한 시점
  • 테스트 주도 개발(Test-Driven Development) : 테스트 - 코딩 - 리팩터링을 짧은 주기로 반복
  • 테스트가 갖춰지지 않은 코드를 리팩토링해야할 경우는 먼저 자가 테스트 코드부터 작성

4.2 테스트할 샘플 코드

  • 생산 계획은 각 지역(province)의 수요(demand)와 가격(price)으로 결정
  • UI,영속성,외부서비스와 관련 없는 가장 쉬운 코드부터 테스트 작성
  • 코드는 항상 성격에 따라 분리하는 것이 좋다.
  • 비즈니스 로직 코드
    • 지역(Province) : Json 문서로부터 만들어진 자바스크립트 객체를 생성자로 받아 생성
    • 생산자(Producer) : 단순 데이터 저장용

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

    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);
    }  // 숫자로 파싱해서 저장

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

}

/* Province 생성자에 필요한 json doc */
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;
    }

    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)를 사용하나 본 포스팅에서는 Jest 를 사용했습니다.

생산부족분 테스트

  1. 테스트에 필요한 데이터와 객체를 뜻하는 픽스처(fixture)를 설정
  2. 픽스처의 속성 검증
describe('province', function () {
    it('shortfall', function () {
        const asia = new Province(sampleProvinceData()); // 1.픽스처 설정
        expect(asia.shortfall).toBe(5); // 2.검증
    });
});

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

  • 테스트가 실패하는 모습을 최소한 한 번씩은 직접 확인해 봐야한다.
  • 일시적으로 코드에 오류를 주입하여 테스트 실패를 확인해본다.
get shortfall() {
    return this._demand - this.totalProduction * 2;  // 오류 주입
}
/*
Error: expect(received).toBe(expected) // Object.is equality
Expected: 5
Received: -20
*/

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

  • 테스트를 자주 수행하며 리팩터링하거나 추가한 코드에 문제가 없는지 확인하자.• 단체견적 옷 퀄리티가 가격대비 좋지 않음
  • 테스트 진행 상태를 초록막대(green bar), 빨간막대(redbar)로 이야기함.
  • 빨간막대일때는 리팩터링 하지말라! 초록막대로 다시 돌아가라

4.4 테스트 추가하기

  • 테스트위험요인을 중심으로 작성 되어야 한다.
  • 테스트의 목적은 현재 혹은 향후에 발생하는 버그를 찾는데 있다.
  • 테스트를 너무 많이 만들다 보면 오히려 필요한 테스트를 놓치기가 쉽다.
  • 단순 접근자 같은 코드는 테스트 할 필요가 없다.

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

기대값 채우기

  • 기대값 230은 임의의 값을 넣고 테스트 후 실제 계산되는 값을 대입함
  • 임시값 설정 > 실제값 대체 > 오류심기 > 되돌리기
it('profit', function () { // 총 수익 테스트
    const asia = new Province(sampleProvinceData());
    expect(asia.profit).toBe(230);
});

중복 제거

  • 테스트 코드에서도 중복은 의심해야 한다.
  • 테스트끼리 상호작용하게 하는 공유 픽스처는 지양해야 한다.
  • beforeEach 구문을 사용해 테스트 바로전에 asia를 초기화 하여 모든 테스트가 각각의 asia 인스턴스를 사용
    • 매번 생성한다고 테스트가 느려지는 경우는 거의 없다.
    • 예외적으로 불변임이 확실한 픽스처는 공유하기도 한다.
    • 공유픽스처를 사용하면 디버깅하는데 고생할 수 있다.
describe('province', function () {
    // const asia = new Province(sampleProvinceData()); // 테스트끼리 상호작용하게 하는 공유 픽스처 사용 X
    let asia;
    beforeEach(function(){  // 각각의 테스트마다 픽스처 초기화
        asia = new Province(sampleProvinceData());
    })

    it('shortfall', function () { // 생산부족분 테스트
        expect(asia.shortfall).toBe(5); // 2.검증
    });

    it('profit', function () { // 총 수익 테스트
        expect(asia.profit).toBe(230);
    });
});

4.5 픽스처 수정하기

  • 실전에서는 사용자가 값을 변경하면서 픽스처의 내용도 수정되는 경우가 흔하다.
  • 이런 수정은 대부분 setter 에서 이루어져서 잘 테스트하지 않지만 이번 예제의 Producer > production() setter가 복잡하니 테스트를 수행할 필요가 있다.
it('change production', function () { // production 변경 테스트
    asia.producers[0].production = 20;
    expect(asia.shortfall).toBe(-6);
    expect(asia.profit).toBe(292);
});
  • beforeEach 블록에서 설정한 표준픽스처를 취하고
  • 테스트를 수행
  • 픽스처가 일을 기대한대로 처리했는지 검증
  • 설정(setup)-실행(exercise)-검증(verify)
  • 조건(given)-발생(when)-결과(then)
  • 준비(arrange)-수행(act)-단언(assert)
  • 이 세 가지 단계가 한 테스트안에 모두 담겨 있을 수도 있고 초기 준비작업중 공통 부분을 beforeEach 같은 표준 설정 루틴에 모아서 처리하기도 한다.
  • 해제(teardown) 혹은 청소(cleanup) 라는 네번째 단계에서 픽스처를 제거하여 테스트들이 서로 영향을 주지 못하게 막는 단계를 두기도 함. (beforeEach 를 사용했기에 생략)

4.6 경계 조건 검사하기

  • 꽃길(Happypath) 상황 외에 범위를 벗어나는 경계 지점에서 문제가 생기면 어떤일이 벌어지는지 확인하는 테스트도 작성하면 좋다.

컬렉션이 있을 경우 컬렉션이 비었을때 발생하는 테스트를 확인

describe('no producers', function () { // 생산자가 없다.
    let noProducersProvince;
    beforeEach(function(){  
        const data = {
            name: "No producers",
            producers: [],
            demand: 30,
            price: 20
        };
        noProducersProvince = new Province(data)
    })

    it('shortfall', function () { // 생산부족분 테스트
        expect(noProducersProvince.shortfall).toBe(30);
    });

    it('profit', function () { // 총 수익 테스트
        expect(noProducersProvince.profit).toBe(0);
    });

});

숫자형이라면 0, 음수확인

it('zero demand', function () { // 수요(demand)가 없다.
    asia.demand = 0
    expect(asia.shortfall).toBe(-25);
    expect(asia.profit).toBe(0);
});

it('negative demand', function () { // 수요(demand) 마이너스
    asia.demand = -1
    expect(asia.shortfall).toBe(-26);
    expect(asia.profit).toBe(-10);
});
  • 수요가 음수일때 수익이 음수인게 말이 되나?
  • 경계를 확인하는 테스트를 작성할때 특이 상황을 어떻게 처리할지 생각해본다.

수요 입력란이 비어있는 경우 - Null

it('empty string demand', function () { // 수요(demand) 입력란이 비어있다.
    asia.demand = "";
    expect(asia.shortfall).NaN;
    expect(asia.profit).NaN;
});
  • 의식적으로 프로그램을 망가뜨리는 방법을 모색한다.

생산자 수 필드에 문자열 대입

describe('string for producers', function () { // 생산자 수 필드에 문자열 대입
    let prov;
    beforeEach(function(){
        const data = {
            name: "String producers",
            producers: "",
            demand: 30,
            price: 20
        };
        prov = new Province(data)
    })
    it('', function () { // 생산부족분 테스트
        expect(prov.shortfall).equal(0);
    });
});
/*
doc.producers.forEach is not a function
TypeError: doc.producers.forEach is not a function
*/
  • 실패(Fail) : 실제 값이 예상 범위를 벗어났다는 의미
  • 에러(Error): 프로그램 예외 상황 발생
    • 오류 메시지 출력, 빈배열 설정 등 후처리
    • 혹은 그대로 두고 입력 객체를 신뢰할 수 있는 곳에서 만들어 주는 코드를 둔다.
    • 같은 코드 베이스 안에서 유효성검사 코드가 너무 많으면 중복으로 검증하여 오히려 문제될 수 있음
    • 리팩터링 전이라면 이런 테스트 코드는 작성하지 않음
    • 리팩터링은 겉보기 동작에 영향을 주지 않아야 하기 때문이다.

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

  • 테스트는 프로그래밍 속도를 높여 준다.
  • 테스트에는 다양한 기법이 있지만 너무 빠져들 필요는 없다. 너무 많이 작성하면 의욕이 떨어져 나중에 하나도 작성하지 않을 수 있다.
  • 따라서 테스트는 위험한 부분에 집중하여 작성하는 것이 좋다.
  • 테스트는 모든 버그를 걸러주지는 못해도 안심하고 리팩터링 할 수 있는 보호막이 되어준다.
  • 리팩터링 전에 테스트 스위트부터 갖추고 리팩터링하는 동안에도 계속 테스트를 추가한다.

4.7 끝나지 않은 여정

  • 단위테스트(Unit Test) : 코드의 작은 영역만 대상으로 빠르게 실행되도록 설계된 테스트
  • 단위테스트는 자가 테스트코드의 핵심이며 대부분 테스트 시스템은 단위테스트가 차지한다.
  • 프로그래밍하면서 테스트도 반복적으로 진행하며 지속적으로 테스트 스위트를 보강해 나가야 한다.
  • 기존 테스트가 명확한지 테스트 과정을 리팩터링 할 수 없는지도 검토해본다.

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

  • 버그를 발견하는 즉시 버그를 명확하게 잡아낼 수 있는 테스트부터 작성하는 습관을 들이자
  • 테스트 스위트가 충분한지를 평가하는 기준은 주관적이다.
  • 테스트를 너무 많이 작성할 경우 수정시 제품코드보다 테스트 코드 수정이 더 걸릴 수 있다.
  • 하지만 보통 테스트코드가 더 적은 경우가 많다.

예제 코드 GitHub

 

GitHub - IfUwanna/refactoring: refactoring

refactoring. Contribute to IfUwanna/refactoring development by creating an account on GitHub.

github.com