(Working Effectively with Legacy Code) 013. I Need to Make a Change, but I Don’t Know What Tests to Write

I Need to Make a Change, but I Don’t Know What Tests

일반적으로 레거시 코드에서 버그를 찾아내는 것은 문제가 없지만, 전략적 관점에선 잘못된 방향의 노력일 수 있다.

애시당초 코드에 버그가 생기지않는 것이 바람직한 것은 당연하며, 기존 동작의 보장은 자동화 테스트를 통해 달성하는 것이어야 한다.

반면 레거시 코드를 취급할 때에는 테스트 루틴이 전혀 없는 경우가 있을 수 있다.

테스트 루틴이 없다는 건 변경시 기존 동작을 보장할 수 없다는 의미이며, 최선은 테스트 루틴을 일부 작성해 최소한의 안전망을 제공하는 것이다.

1. 문서화 테스트(Characterization Tests)

어찌되었건 우리에게는 테스트 루틴이 필요하다.

먼저 과거의 요구 사항 문서와 메모 주석등을 찾아서 이에 기반한 테스트 루틴을 작성할 수 있을 것이다.

하지만 좀 더 좋은 접근법은 시스템이 어떻게 동작하는지 확인하는 것보다 어떻게 동작하는지 확인하는 것이다.

버그를 찾아내는 것보다 중요한 목표는 변경 작업을 명확히 하기 위한 테스트 루틴을 정확한 위치에 작성하는 것이기 때문이다.

기존 동작 유지에 필요한 테스트를 문서화 테스트(Characterization Test) 라고 부른다.

문서화 테스트는 코드의 실제 동작을 나타내는 테스트로, 시스템의 현재 동작을 그대로 문서화하는 테스트이다.

문서화 테스트를 작성하는 순서는 아래와 같다.

  1. 테스트 하네스 내에서 대상 코드를 호출한다.
  2. 실패할 것임을 알고 있는 확중문(Assertion)을 작성한다.
  3. 실패 결과로부터 실제 동작을 확인한다.
  4. 코드로 구현할 기대동작으로 테스트 루틴을 변경한다.
  5. 위의 과정을 반복한다.

참고 저자의 블로그에 문서화 테스트에 대한 아티클이 있다. Micheal Feathers Silvrback’s Characterization Testing

이 문서화 테스트를 예제를 통해 이해해보자.

1
2
3
4
void testGenerator() {
PageGenerator generator = new PageGenerator();
assertEquals("fred", generator.generate());
}

PageGenerator 클래스의 generate() 메서드를 호출하면 어떠한 문자열이 만들어진다.

단 여기서 만들어지는 문자열이 “fred”가 아님을 명확하게 알고 있다고 할때, 위의 테스트는 실패할 것이다.

이는 코드의 동작을 분명히 하기위해 의도적으로 실패시킨 테스트 코드다.

실제로 generate() 메서드가 생성하는 문자열이 공백이 “” 라고 할때 아래와 같이 수정하여 테스트를 통과시켜보자.

1
2
3
4
void testGenerator() {
PageGenerator generator = new PageGenerator();
assertEquals("", generator.generate());
}

이 수정은 단순히 테스트를 성공시킨 것이 아니라, PageGenerator 클래스에 대한 동작 하나를 테스트 코드로 문서화한 것이다.

이는 다른 데이터를 제공할 경우 어떻게 동작하는 지 알아낼 수 있는 단초가 될 수 있다.

단순히 소프트웨어가 생성해내는 값을 테스트에 적용하는 것이 조금 이상하게 느껴질 수 있다.

다만 어떠한 변경으로 인해 테스트 코드로 문서화된 시스템의 현재 동작이 바뀌는 지를 감지하여 버그를 특정하는 데 그 의의가 있다.

2. 클래스 문서화(Characterizing Classes)

어떤 클래스를 대상으로 무엇을 테스트해야할지 결정하려면 어떻게 해야할까?

가장 먼저 해야할 일은 상위 수준에서 클래스의 동작을 파악하는 것이다.

가장 간단한 동작에 대한 테스트 루틴을 시작으로 클래스에 대한 커버리지를 점진적으로 넓혀나가는 식이다.

아래 프로세스를 따라해보자.

  1. 로직이 엉켜있는 부분을 찾는다.
    1-1. 이해할 수 없는 코드가 있다면 감지 변수 등을 사용해 해당 부분을 문서화 한다.
    1-2. 코드의 특정 부분이 실행되는 지 확인하기 위해 감지 변수를 사용한다.
  2. 클래스나 메서드의 책임을 파악했으면, 실패하는 케이스의 리스트를 만든다.
    2-1. 해당 리스트의 테스트 루틴을 작성할 수 있는지 고려한다.
  3. 테스트 루틴에 전달되는 입력값을 검토한다.
    3-1. 극단적인 케이스의 입력값이 전달되는 경우 어떠한 일이 발생하는 지 확인한다.
  4. 객체의 생명주기동안 유지되는 불변 조건이 있는지 확인한다.
    4-1. 불변 조건을 검증하기 위한 테스트 루틴을 작성한다.
    4-2. 불변 조건을 발견하기 위해 리팩토링을 진행해야할 수 있으며, 이를 통해 코드의 개선이 이루어질 수 있다.

참고 위의 프로세스에서 불변 조건 이란 객체가 살아있는 동안 항상 참이어야하는 조건을 말한다.

3. 목표가 정해진 테스트(Targeted Testing)

코드의 동작을 이해하기 위한 테스트 루틴을 작성하였다면, 변경 대상의 범위를 살펴보고 테스트 루틴이 그 범위를 포함하고 있는지 확인해보아야 한다.

아래 예제를 보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class FuelShare {
private long cost = 0;
private double corpBase = 12.0;
private ZonedHawthorneLease lease;
// ...

public void addReading(int gallons, Date readingDate) {
if (lease.isMonthly()) {
if (gallons < Lease.CORP_MIN) {
cost += corpBase;
} else {
cost += 1.2 * priceForGallons(gallons);
}
}
//...
lease.postReading(readingDate, gallons);
}
// ...
}

위의 예제는 탱크 내의 연료량을 계산하는 클래스 FuelShare의 일부이다.

FuelShare 클래스는 이미 테스트 루틴이 작성되었다고 가정하고 아래와 같이 변경한다.

  1. 최상의 if문 전체를 새로운 메서드로 추출한 후 ZonedHawthorneLease 클래스로 옮긴다.
  2. lease 변수는 ZonedHawthorneLease 클래스의 객체이다.

변경 후 코드는 아래와 같다.

1
2
3
4
5
6
7
8
public class FuelShare {
public void addReading(int gallons, Date readingDate){
cost += lease.computeValue(gallons, priceForGallons(gallons));
// ...
lease.postReading(readingDate, gallons);
}
// ...
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class ZonedHawthorneLease extends Lease {
public long computeValue(int gallons, long totalPrice) {
long cost = 0;
if (lease.isMonthly()) {
if (gallons < Lease.CORP_MIN) {
cost += corpBase;
} else {
cost += 1.2 * totalPrice;
}
}
return cost;
}
// ...
}

리팩토링이 제대로 이루어졌는지 확인하기 위한 테스트를 작성해보자.

먼저 변경되지않은 로직을 하나 발견할 수 있다.

1
2
3
if (gallons < Lease.CORP_MIN) {
cost += corpBase;
}

상수 Lease.CORP_MIN 보다 작은 값일 경우 어떻게 계산되는 지 테스트 루틴을 통해 확인해볼 수도 있지만 여기서 꼭 필요한 테스트는 아니다.

꼭 변경해야하는 부분은 아래와 같이 변경된 else 블럭이다.

1
2
3
4
5
6
7
8
9
// Before
else {
cost += 1.2 * priceForGallons(gallons);
}

// After
else {
cost += 1.2 * totalPrice;
}

기존 코드를 기준으로 보았을 때 상수 Lease.CORP_MIN 보다 큰 값을 넣었을 경우 else 문이 실행되었음을 알 수 잇다.

아래와 같이 테스트 코드를 작성해보자.

1
2
3
4
5
6
7
public void testValueForGallonsMoreThanCorpMin() { 
StandardLease lease = new StandardLease(Lease.MONTHLY);
FuelShare share = new FuelShare(lease);

share.addReading(FuelShare.CORP_MIN + 1, new Date());
assertEquals(12, share.getCost());
}

이 예제처럼 조건 분기를 문서화 테스트할때엔 입력한 값으로 인해 실패해야 할 테스트가 성공하는 일이 없는지 파악하는 것이 중요하다.

만약 금액 표시에 int가 아닌 double 타입이 사용된다고 가정하자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class FuelShare {
private double cost = 0.0;
// ...
public void addReading(int gallons, Date readingDate) {
if (lease.isMonthly()) {
if (gallons < CORP_MIN) {
cost += corpBase;
} else {
cost += 1.2 * priceForGallons(gallons);
}
}
// ...
lease.postReading(readingDate, gallons);
}
// ...
}

타입의 변경 하나만으로 부동소수점의 반올림 오차 이슈가 발생하게 된다.

만약 Lease.CORP_MIN 값이 10이고, corpBase의 값이 12.0 이라면 아래 테스트 코드는 어떤 영향이 발생할까?

1
2
3
4
5
6
7
8
public void testValue() { 
StandardLease lease = new StandardLease(Lease.MONTHLY);
FuelShare share = new FuelShare(lease);

// share.addReading(FuelShare.CORP_MIN + 1, new Date());
share.addReading(1, new Date()); // HERE
assertEquals(12, share.getCost());
}

입력으로 주어진 값 1은 Lease.CORP_MIN 값인 10보다 작으므로 costcostBase의 값이 12를 더하게 된다.

최종적으로 cost의 값은 12.0이 된다.

이때 추출한 메서드가 double이 아닌 intcost를 선언하면 어떻게 될까?

묵시적 형변환을 통해 소숫점 값을 절삭되긴 하지만 소숫점의 값이 .0이라서 테스트는 통과한다.

하지만 만약 0.1, 0.2 라면 어떻게 되었을까?

예상되는 값을 일일이 계산하거나, 형 변환 여부를 감지하는 등의 방법, 그리고 좀 더 잘게 메서드를 쪼개는 방법도 있을 것이다.

목표로 하는 클래스의 테스트 루틴을 작성하면서 메서드를 추출할 때 좀 더 유의해야 할 것이다.

4. 문서화 테스트를 작성하면서 경험적으로 얻은 작성 절차 (A Heuristic for Writing Characterization Tests)

  1. 변경 대상 부분을 위한 테스트 루틴을 작성한다.
    1-1. 코드의 동작을 이해하는 데 필요하다면 최대한 많은 테스트 케이스를 작성한다.
  2. 테스트 루틴을 작성한 후, 변경하려는 코드들을 조사하고 이에 대한 테스트 루틴을 작성한다.
  3. 기능을 추출하거나 이동하려는 경우, 기존의 동작이나 동작 간의 관계를 검증하는 테스트를 개별적으로 작성한다.
    3-1. 위의 검증 테스트를 통해 이동 대상인 코드가 실ㄹ행되는지, 적절한 관계를 유지하는 지 검증한다.
    3-2. 검증이 완료된 이후에 코들르 이동시킨다.