Eternity's Chit-Chat

aeternum.egloos.com



도메인 특화 언어와 단위 테스트 - 2부(上) Software Quality


2부 소스코드 planet-part-2.zip

테스트 코드 리팩토링
테스트와 관련 없는 정보(IRRELEVANT INFORMATION)를 너무 상세하게 노출시켜 애매한 테스트(OBSCURE TEST)가 되어버리는 문제를 해결할 수 있는 한 가지 방법은 안정적인 인터페이스를 이용해 픽스처를 생성하는 코드를 캡슐화시키는 것이다. 가장 간단한 방법은 픽스처 생성 코드를 메서드로 추출(EXTRACT METHOD 리팩토링)해서 별도의 테스트 유틸리티 메서드(TEST UTILITY METHOD)로 분리하는 것이다. 이처럼 픽스처를 생성하기 위한 목적을 가진 독립적인 테스트 유틸리티 메서드(TEST UTILITY METHOD)생성 메서드(CREATION METHOD)라고 한다.

다음은 픽스처를 생성하기 위해 직접 Planet의 생성자를 호출하던 부분을 createPlanet()이라는 독립적인 생성 메서드를 이용하도록 리팩토링한 ContinentSpecification의 테스트 케이스를 나타낸 것이다.

public class ContinentSpecificationTest {
    @Test
    public void continentSize() {
        Planet planet = createPlanet();
       
        ContinentSpecification specification = new ContinentSpecification(2);

       
        assertTrue(specification.isSatisfied(planet));
    }

    private Planet createPlanet() {
        return new Planet(
                       new Atmosphere(Money.wons(5000),
                           element("N", Ratio.of(0.8)),
                           element("O", Ratio.of(0.2))),
                       Arrays.asList(
                           new Continent("아시아"),
                           new Continent("유럽")),
                      Arrays.asList(
                           new Ocean("태평양", Money.wons(1000)),
                           new Ocean("대서양", Money.wons(1000))));
    }
}
생성 메서드 내부로 픽스처 생성 로직을 캡슐화하는 장점은 인터페이스에 민감함(INTERFACE SENSITIVITY) 문제로 인한깨지기 쉬운 테스트(FRAGILE TEST)의 위험성을 낮출 수 있다는 것이다. 앞에서 언급한 것처럼 여러 테스트 케이스에서 클래스의 생성자를 직접 호출할 경우 생성자 변경으로 인해 다수의 테스트 케이스가 영향을 받게 된다. 따라서 픽스처 생성 로직을 생성 메서드 내부로 캡슐화하고 직접 생성자를 호출하는 대신 생성 메서드(CREATION METHOD)를 호출하도록 변경하면 생성자의 시그니처 변경으로 인한 파급 효과의 범위를 제한할 수 있다.

또한 픽스처 생성을 위해 생성자를 직접 이용할 경우 동일한 타입의 픽스처를 생성하는 테스트 메소드 간에 테스트 코드 중복(TEST CODE DUPLICATION) 문제가 발생할 수 밖에 없는데 일반적으로 유사한 픽스처 생성 로직을 복사한 후 일부만 수정해서 사용하기 때문이다. 생성 메서드(CREATION METHOD)는 중복되는 코드를 별도의 메서드로 추출할 수 있기 때문에 제한적이나마 테스트 코드 중복 문제를 해결할 수 있다.

생성 메서드(CREATION METHOD)의 이런 장점에도 불구하고 앞에서  리팩토링한 테스트 코드에서는 여전히 애매한 테스트(OBSCURE TEST) 문제가 존재한다. 리팩토링 전의 코드가 관련 없는 정보를 과도하게 노출시킴으로써 애매한 테스트(OBSCURE TEST) 문제를 초래했다면 리팩토링 후의 코드는 테스트 결과와 관련된 중요한 정보를 은폐하고 있기 때문에 애매한 테스트(OBSCURE TEST) 문제를 초래한다. createPlanet()이라는 메서드의 시그니처 어디에서도 대륙의 개수를 검증하기 위해 Planet을 생성한다는 정보를 알 수가 없다. 즉, 이번에는 너무 많은 정보를 감추고 있기 때문에 애매한 테스트(OBSCURE TEST) 문제가 발생한다.

이처럼 픽스처와 검증 로직의 일부가 테스트 메서드 밖에 있어 코드를 봤을 때 이들 간의 인과 관계가 보이지 않는 애매한 테스트(OBSCURE TEST) 문제의 또 다른 형태를 미스터리한 손님(Mysterious Guest)이라고 부른다. 미스터리한 손님(Mysterious Guest) 문제를 해결하는 한 가지 방법은 테스트와 무관한 정보는 기본값을 설정하도록 생성 메서드(CREATION METHOD) 내에 캡슐화하고 테스트와 관련된 정보만 생성 메서드의 파라미터로 전달함으로써 테스트 검증에 중요한 정보가 무엇인지를 한 눈에 파악할 수 있도록 하는 것이다.

이와 같이 애매한 테스트 문제를 해결하기 위해 테스트와 관련된 중요한 정보를 파라미터로 전달받는 생성 메서드(CREATION METHOD)의 특별한 형태를 매개변수화된 생성 메서드(PARAMETERIZED CREATION METHOD)라고 한다. 파라미터로 전달되지 않는 관련 없는 정보에는 미리 정해진 기본값을 할당함으로써 픽스처를 생성하는 이유를 명확하게 드러낼 수 있다.

다음은 createPlanet() 메서드의 파라미터로 Continent 인스턴스의 목록을 받도록 리팩토링함으로써 테스트에 중요한 픽스처 정보를 강조하도록 수정한 것이다. 또한 테스트 검증과 무관한 속성은 외부에 노출하지 않고 createPlanet() 메소드 안에 미리 정의된 기본값으로 초기화함으로써 테스트와 관련 없는 정보는 감추고 있다. 리팩토링된 테스트 코드를 읽어 보면 아시아와 유럽 대륙을 가진 Planet 이 2개의 대륙을 가지고 있는 지 검증하는 ContinentSpecification의 검증 로직을 통과한다는 사실을 쉽게 파악할 수 있다.

public class ContinentSpecificationTest {
    @Test
    public void continentSize() {
        Planet planet = createPlanet(
                                    
new Continent("아시아"),
                                     new
Continent("유럽"));
       
        ContinentSpecification specification = new ContinentSpecification(2);
       
        assertTrue(specification.isSatisfied(planet));
    }

    private Planet createPlanet(Continent ... continents) {
        return new Planet(
                       new Atmosphere(Money.wons(5000),
                           element("N", Ratio.of(0.8)),
                           element("O", Ratio.of(0.2))),
                       Arrays.asList(continents),
                       Arrays.asList(
                          new Ocean("태평양", Money.wons(1000)),
                          new Ocean("대서양", Money.wons(1000))));

    }
}
행성의 제조 요금을 검증하는 PriceSpecification의 테스트 케이스를 리팩토링하는 것은 조금 더 복잡하다. 제조 요금을 테스트하기 위해 필요한 정보는 대기의 제조 요금, 대륙에 포함된 국가들의 총 제조 요금, 대양들의 총 제조 요금을 모두 더한 값이다. 

public class PriceSpecificationTest {
    @Test
    public void price() {
        Planet planet = new Planet(
                                   new Atmosphere(Money.wons(5000),
                                       element("N", Ratio.of(0.8)),
                                       element("O", Ratio.of(0.2))),
                                   Arrays.asList(
                                       new Continent("아시아", new Nation("대한민국", Money.wons(1000))),
                                       new Continent("유럽", new Nation("영국", Money.wons(1000)))),
                                   Arrays.asList(
                                       new Ocean("태평양", Money.wons(1000)),
                                       new Ocean("대서양", Money.wons(1000))));
       
        Specification specification = new PriceSpecification(Money.wons(9000));
       
        assertTrue(specification.isSatisfied(planet));
    }
}
위 테스트 케이스를 행성 제조에 필요한 요금 정보만을 강조하기 위해 createPlanet()이라는 매개변수화된 생성 메서드(PARAMETERIZED CREATION METHOD)를 사용하도록 리팩토링한 코드는 다음과 같다.

public class PriceSpecificationTest {
   
@Test
    public void price() {
        Planet planet = createPlanet(
                                     Money.wons(5000),                       
                                     Arrays.asList(Money.wons(1000), Money.wons(1000)),

                                     Arrays.asList(Money.wons(1000), Money.wons(1000)));

        Specification specification =  PriceSpecification(Money.wons(9000));
       
        assertTrue(specification.isSatisfied(planet));
    }

    private Planet createPlanet(Money atmospherePrice,
        List<Money> nationsPrice, List<Money> oceansPrice) {
        return new Planet(
                        createAtmosphere(atmospherePrice),
                        createContinents(nationsPrice),
                        createOceans(oceansPrice));
    }

    private Atmosphere createAtmosphere(Money atmospherePrice) {
        return new Atmosphere(atmospherePrice,
                               element("N", Ratio.of(0.8)),
                               element("O", Ratio.of(0.2)));
    }
 
    private List<Continent> createContinents(List<Money> nationsPrice) {
        List<Continent> result = new ArrayList<Continent>();
        for(Money each : nationsPrice) {
            result.add(new Continent("대륙", new Nation("국가", each)));
        }
        return result;
    }
   
    private List<Ocean> createOceans(List<Money> oceansPrice) {
        List<Ocean> result = new ArrayList<Ocean>();
        for(Money each : oceansPrice) {
            result.add(new Ocean("대양", each));
        }
        return result;
    }
}
만족스럽지는 않지만 createPlanet() 메서드의 인자를 살펴 보는 것만으로도 테스트에서 검증하려는 결과 값을 쉽게 유추할 수 있다(결과 값은 간단하게 createPlanet()에 나열된 Money의 금액을 합하면 된다). 또한 테스트에 사용할 요금 정보를 수정하고 싶을 경우에도 간단하게 createPlanet()에 파라미터로 전달된 Money의 값을 변경하거나 개수를 변경하기만 하면 된다.


슈퍼 클래스를 이용한 생성 메서드의 중복 제거
이제 ContinentSpecificationTest에는 대륙의 목록을 인자로 받아 행성을 생성하는 createPlanet() 메서드가 정의되어 있고, PriceSpecificationTest에는 대기, 대륙, 대양의 제조 요금 정보를 인자로 받아 Planet을 생성하는 createPlanet() 메서드가 정의되어 있다. 새로운 문제는 위 두 테스트 케이스 이외의 다양한 테스트 케이스에서도 이 2가지 버전의 createPlanet()을 사용할 수 있어야 한다는 것이다. 현재의 구조 상으로는 생성 메서드(CREATION METHOD)가 특정한 테스트 케이스 클래스와 강하게 결합되어 있기 때문에 다른 테스트 케이스 클래스에서 원하는 메서드를 재사용하기가 쉽지 않다.

한 가지 방법은 각 테스트 케이스로 적절한 createPlanet() 메서드를 복사하는 것이다. 그러나 메서드를 복사하는 것은 테스트 코드 중복(TEST CODE DUPLICATION) 문제로의 회귀를 의미한다.

더 좋은 방법은 EXTRACT SUPER CLASS 리팩토링을 통해 두 테스트 케이스 클래스의 공통 부모 클래스를 추출한 후 PULL UP METHOD 리팩토링을 통해 생성 메서드를 공통의 부모 클래스로 옮기는 것이다. Planet을 생성할 필요가 있는 테스트 케이스에서는 추출한 부모 클래스를 상속 받는 것으로 간단하게 생성 메서드(CREATION METHOD)를 재사용할 수 있다. 이처럼 테스트 케이스 클래스 간의 중복 코드를 제거하기 위해 공통 코드를 포함하도록 생성되는 부모 클래스를 테스트 케이스 슈퍼클래스(TESTCASE SUPERCLASS)라고 한다. 생성 메서드(CREATION METHOD)는 서브 클래스에서만 접근 가능하도록 제한하는 것이 좋으므로 가시성을 protected로 변경한다.

public abstract class AbstractPlanetTest {
    protected Planet createPlanet(Continent ... continents) {
        return new Planet(
                       new Atmosphere(Money.wons(5000),
                           element("N", Ratio.of(0.8)),
                           element("O", Ratio.of(0.2))),
                       Arrays.asList(continents),
                       Arrays.asList(
                           new Ocean("태평양", Money.wons(1000)),
                           new Ocean("대서양", Money.wons(1000))));

    }


    protected Planet createPlanet(Money atmospherePrice,
        List<Money> nationsPrice, List<Money> oceansPrice) {
        return new Planet(
                           createAtmosphere(atmospherePrice),
                           createContinents(nationsPrice),
                           createOceans(oceansPrice));
    }

    private Atmosphere createAtmosphere(Money atmospherePrice) {
        return new Atmosphere(atmospherePrice,
                           element("N", Ratio.of(0.8)),
                           element("O", Ratio.of(0.2)));
    }
 
    private List<Continent> createContinents(List<Money> nationsPrice) {
        List<Continent> result = new ArrayList<Continent>();
        for(Money each : nationsPrice) {
            result.add(new Continent("대륙", new Nation("국가", each)));
        }
        return result;
    }
   
    private List<Ocean> createOceans(List<Money> oceansPrice) {
        List<Ocean> result = new ArrayList<Ocean>();
        for(Money each : oceansPrice) {
            result.add(new Ocean("대양", each));
        }
        return result;
    }

}
이제 테스트 케이스 슈퍼클래스(TESTCASE SUPERCLASS)를 각 테스트 케이스 클래스가 상속받도록 테스트 케이스를 리팩토링하면 생성 메서드(CREATION METHOD)를 재사용할 수 있게 된다. 또한 테스트 케이스 클래스에 존재하던 생성 메서드를 부모 클래스로 이동 시킴으로써 테스트의 목적과는 무관한 생성 코드를 테스트 케이스 클래스로부터 제거할 수 있다. 다음은 리팩토링된 ContinentSpecificationTest 코드를 나타낸 것이다.

public class ContinentSpecificationTest extends AbstractPlanetTest {
    @Test
    public void continentSize() {
        Planet planet = createPlanet(
new Continent("아시아"), new Continent("유럽"));
       
        ContinentSpecification specification = new ContinentSpecification(2);
       
        assertTrue(specification.isSatisfied(planet));
    }

}
이제 PriceSpecificationTest 역시 요금 계산과 무관한 생성 로직을 제거할 수 있으므로 테스트 케이스 클래스를 좀 더 간결하고 단순하게 유지할 수 있다.
public class PriceSpecificationTest extends AbstractPlanetTest {
   
@Test
    public void price() {
        Planet planet = createPlanet(
                                   Money.wons(5000),                       
                                   Arrays.asList(Money.wons(1000), Money.wons(1000)),

                                   Arrays.asList(Money.wons(1000), Money.wons(1000)));

        Specification specification = new PriceSpecification(Money.wons(9000));
       
        assertTrue(specification.isSatisfied(planet));
    }
}
테스트 케이스 슈퍼클래스(TESTCASE SUPERCLASS)를 이용해 관련 없는 정보를 배제하고 테스트 코드 중복을 제거하면 깔끔하고, 의도가 명확하며, 원인과 결과를 파악하기 쉬운 테스트 케이스를 만들 수 있다.
<그림 1> TEST CASE SUPERCLASS를 이용한 중복 제거

핑백

  • 이터너티님의 도메인 특화 언어와 단위 테스트 소개 &laquo; 괴발개발 2015-03-14 00:18:46 #

    ... 를 패턴을 이용한 간결한 코드로 바꾼다. 도메인 특화 언어와 단위 테스트 &#8211; 1부(上) 도메인 특화 언어와 단위 테스트 &#8211; 1부(下) 도메인 특화 언어와 단위 테스트 &#8211; 2부(上) 도메인 특화 언어와 단위 테스트 &#8211; 2부(下) 도메인 특화 언어와 단위 테스트 &#8211; 3부(上) 도메인 특화 언어와 단위 테스트 ... more