박종훈 기술블로그

테스트 코드 개선하기 - 테스트 리팩터링 하기 (+ 예제)

테스트 코드 개선하기 - 테스트 리팩터링 하기 (+ 예제)
xUnit 테스트 패턴 - 제라드 메스자로스 - 0장


테스트는 애자일 개발 프로세스에서 금방 병목이 될 수 있다. 간단하고 알기쉬운 테스트와 복잡하고 무디며 유지 보수하기 어려운 테스트는 생산성에서 엄청난 차이가 있다.

예시를 통해 실제적으로 어떻게 테스트 코드를 개선할지에 대한 예시를 같이 알아보자.

복잡한 테스트

최초 코드는 다음과 같다.

public void testAddItemQuantity_severalQuantity_v1(){
    Address billingAddress = null;
    Address shippingAddress = null;
    Customer customer = null;
    Product product = null;
    Invoice invoice = null;
    try {
        // 픽스처 설치 (set up fixture)
        billingAddress = new Address("1222 1st St SW", "Calgary", "Alberta", "T2N 2V2","Canada");
        shippingAddress = new Address("1333 1st St SW", "Calgary", "Alberta", "T2N 2V2", "Canada");
        customer = new Customer(99, "John", "Doe", new BigDecimal("30"), billingAddress, shippingAddress);
        product = new Product(88, "SomeWidget", new BigDecimal("19.99"));
        invoice = new Invoice(customer);
        // SUT 실행 (exercise SUT)
        invoice.addItemQuantity(product, 5);
        // 결과 검증 (Verify outcome)
        List lineItems = invoice.getLineItems();
        if (lineItems.size() == 1) {
            LineItem actItem = (LineItem) lineItems.get(0);
            assertEquals("inv", invoice, actItem.getInv());
            assertEquals("prod", product, actItem.getProd());
            assertEquals("quant", 5, actItem.getQuantity());
            assertEquals("discount", new BigDecimal("30"), actItem.getPercentDiscount());
            assertEquals("unit price",new BigDecimal("19.99"), actItem.getUnitPrice());
            assertEquals("extended", new BigDecimal("69.96"), actItem.getExtendedPrice());
        } else {
            assertTrue("Invoice should have 1 item", false);
        }
    } finally {
        // 해체 (Teardown)
        deleteObject(invoice);
        deleteObject(product);
        deleteObject(customer);
        deleteObject(billingAddress);
        deleteObject(shippingAddress);
    }
}

이 코드는 보다 싶이 꽤 길고 필요 이상으로 복잡하다. 이런 코드는 애매한 테스트(Obscure Test - 15장 코드 냄새) 에 속하는데 테스트 코드 줄이 너무 길다보니 큰 그림을 보기가 어려워 이해하기 힘들다. (그 외에도 앞으로 하나씩 살펴볼 여러 다른 종류의 문제점을 가지고 있다.)

테스트 정리하기

검증로직 정리하기

현재의 검증로직 부분은 다음과 같다.

// 결과 검증 (Verify outcome)
List lineItems = invoice.getLineItems();
if (lineItems.size() == 1) {
    LineItem actItem = (LineItem) lineItems.get(0);
    assertEquals("inv", invoice, actItem.getInv());
    assertEquals("prod", product, actItem.getProd());
    assertEquals("quant", 5, actItem.getQuantity());
    assertEquals("discount", new BigDecimal("30"), actItem.getPercentDiscount());
    assertEquals("unit price",new BigDecimal("19.99"), actItem.getUnitPrice());
    assertEquals("extended", new BigDecimal("69.96"), actItem.getExtendedPrice());
} else {
    assertTrue("Invoice should have 1 item", false);
}

아래 코드는 둔한 선언문(obtuse assertion) 이다.

assertTrue("Invoice should have 1 item", false);

assertTrue에 false를 인자로 주고 호출하면 테스트는 항상 실패한다. 그럴 것이라면 바로 실패시켜 버리는 게 낫다.

fail("Invoice should have exactly one line item")

하드 코딩된 인자가 들어있는 결과 지정 단언문(Stated Outcome Assertion) 대신 좀 더 의도가 잘 드러나는 단일 결과 단언문(Single Outcome Assertion) 으로 변경하였기 때문에 메소드 뽑아내기(Extract Method [Fowler]) 리팩터링을 했다고 볼 수 있다.

이후에도 여러 문제가 남아 있다.

우선 단언문이 매우 많다. 객체 필드마다 단언문을 거는 대신 기대 객체(Expected Object - 21장 결과 검증 패턴)로 단언문을 걸면 개선할 수 있다. 기대하는 결과 상태와 같은 객체를 하나 정의하여 검증한다.

그러면 아래와 같이 코드가 개선된다.

// 결과 검증 (Verify outcome)
List lineItems = invoice.getLineItems();
if (lineItems.size() == 1) {
    LineItem expected = new LineItem(invoice, product,5, new BigDecimal("30"), new BigDecimal("69.96"));
    LineItem actItem = (LineItem) lineItems.get(0);

    assertEquals("invoice", expected, actItem);
} else {
    fail("Invoice should have exactly one line item");
}

객체를 인자로 넘기기(Preserve Whole Object [Fowler]) 리팩터링 덕분에 코드가 훨씬 간단하고 분명해졌다.

위 테스트 코드에는 조건문이 있다. 테스트 코드에 조건문이 있다면 실제로 어떤 실행 경로로 실행되었는지 판단해야 한다. 따라서 테스트 내 조건문 로직(Conditional Test Logic - 15장 코드 냄새)을 제거할 수 있다면 훨씬 좋을 것이다.

이런 상황에서는 보호 단언문(Guard Assertion - 21장 결과 검증 패턴) 패턴을 사용할 수 있다. 중첩 조건문을 보호절로 바꾸기(Replace Nested Conditional with Guard Clauses [Fowler]) 리팩터링을 써서 if … else fail() 문을 같은 조건에 대한 단언문으로 바꾼다.

List lineItems = invoice.getLineItems();
assertEquals("number of items", 1,lineItems.size());
LineItem expected = new LineItem(invoice, product, 5, new BigDecimal("30"), new BigDecimal("69.96"));
LineItem actItem = (LineItem) lineItems.get(0);
assertEquals("invoice", expected, actItem);

12줄의 검증코드를 5줄로 줄였고 훨씬 단순해졌다.

더 개선할 방법이 있을까?

이 코드로 검증하려는 게 뭘까? 이 코드의 의도는 line item이 하나만 있어야 하고, 이 item이 expected 객체와 같음을 보여주는 데 있다. 메소드 뽑아내기 리팩터링으로 맞춤 단언문(Custom Assertion - 21장 결과 검증 패턴) 을 정의하면 이를 명시적으로 보여줄 수 있다.

LineItem expected = new LineItem(invoice, product, 5, new BigDecimal("30"), new BigDecimal("69.96"));
assertContainsExactlyOneLineItem(invoice, expected);

이제 검증코드를 단 2줄로 줄였다. 전체 코드에 적용해보자.

public void testAddItemQuantity_severalQuantity_v6(){
    Address billingAddress = null;
    Address shippingAddress = null;
    Customer customer = null;
    Product product = null;
    Invoice invoice = null;
    try {
        // 픽스처 설치 (set up fixture)
        billingAddress = new Address("1222 1st St SW", "Calgary", "Alberta", "T2N 2V2","Canada");
        shippingAddress = new Address("1333 1st St SW", "Calgary", "Alberta", "T2N 2V2", "Canada");
        customer = new Customer(99, "John", "Doe", new BigDecimal("30"), billingAddress, shippingAddress);
        product = new Product(88, "SomeWidget", new BigDecimal("19.99"));
        invoice = new Invoice(customer);
        // SUT 실행 (exercise SUT)
        invoice.addItemQuantity(product, 5);
        // 결과 검증 (Verify outcome)
        LineItem expected = new LineItem(invoice, product, 5, new BigDecimal("30"), new BigDecimal("69.96"));
        assertContainsExactlyOneLineItem(invoice, expected);
    } finally {
        // 해체 (Teardown)
        deleteObject(invoice);
        deleteObject(product);
        deleteObject(customer);
        deleteObject(billingAddress);
        deleteObject(shippingAddress);
    }
}

픽스처 해체 로직 정리

} finally {
    // 해체 (Teardown)
    deleteObject(invoice);
    deleteObject(product);
    deleteObject(customer);
    deleteObject(billingAddress);
    deleteObject(shippingAddress);
}

finally 절은 테스트 통과/실패 여부에 상관없이 정리코드 실행을 보장해준다. 하지만 이 코드에는 결정적인 문제가 있다. 해체 코드 실행 도중에 문제가 발생되면 어떻게 될까? 문제가 발생한 부터 나머지 deleteObject는 실행되지 않을 것이다.

무식한 해결방식은 해제 문 안에서 try … finally 를 중첩할 수 있다. 이 방식으로는 위에서 제기한 문제는 해결할 수 있으나 코드가 늘어나기 때문에 유지 보수하기가 어려워진다.

이 문제는 복잡한 해체(Complex Teardown - 15장 코드냄새) 이다. 이 문제의 근본 원인은 모든 테스트마다 상세하게 해체 코드를 작성해야 한다는 것이다.

테스트가 끝나도 해체하지 않는 공유 픽스처(Shared fixture - 18장 테스트 전략 패턴)를 통해 테스트 맨 위에서 객체 생성을 하지 않는 방법도 있다. 하지만 이 방법을 쓰면 공유 픽스처를 통해 상호작용이 발생해 반복 안 되는 테스트(Unrepeatable Test - 16장 동작 냄새)서로 반응하는 테스트(Interacting Test - 16장 동작 냄새) 와 같은 여러 테스트 냄새가 날 수 있다. 또한 공유 픽스처에서 객체를 참조했을 때 미스터리한 손님(Mystery Guest - 15장 코드 냄새) 이 되는 경우도 있다.

가장 좋은 방법은 신선한 픽스처(Fresh Fixture - 18장 테스트 전략 패턴 )를 쓰되 모든 테스트마다 해체 코드를 쓰지 않아도 되는 방법이다. 테스트 자동 프레임워크를 확장해 우리가 해야 하는 대부분의 일을 대신 시키자. 우리가 생성한 객체를 프레임워크에 등록하고, 프레임워크에서 해당 객체를 삭제할 수 있게 만들면 된다.

// 픽스처 설치 (set up fixture)
billingAddress = new Address("1222 1st St SW", "Calgary", "Alberta", "T2N 2V2","Canada");
registerTestObject(billingAddress);
shippingAddress = new Address("1333 1st St SW", "Calgary", "Alberta", "T2N 2V2", "Canada");
registerTestObject(shippingAddress);
customer = new Customer(99, "John", "Doe", new BigDecimal("30"), billingAddress, shippingAddress);
registerTestObject(customer);
product = new Product(88, "SomeWidget", new BigDecimal("19.99"));
registerTestObject(product);
invoice = new Invoice(customer);
registerTestObject(invoice);

등록 코드에서는 객체를 테스트 객체 컬렉션에 추가한다.

List testObjects;

protected void setUp() throws Exception {
     super.setUp();
     testObjects = new ArrayList();
}

protected void registerTestObject(Object testObject) {
     testObjects.add(testObject);
}

등록한 객체는 tearDown 메소드에서 테스트 객체 리스트를 돌면서 각기 삭제한다.

public void tearDown() {
    Iterator i = testObjects.iterator();
    while (i.hasNext()) {
        try {
            deleteObject(i.next());
        } catch (RuntimeException e) {
            // 아무것도 하지 않아도 됨
            // 리스트에 있는 다음 객체로 계속 작업할 수만 있으면 됨
        }
    }
}

이제 테스트는 다음과 같이 개선되었다.

public void testAddItemQuantity_severalQuantity_v9(){
    // 픽스처 설치 (set up fixture)
    billingAddress = new Address("1222 1st St SW", "Calgary", "Alberta", "T2N 2V2","Canada");
    registerTestObject(billingAddress);
    shippingAddress = new Address("1333 1st St SW", "Calgary", "Alberta", "T2N 2V2", "Canada");
    registerTestObject(shippingAddress);
    customer = new Customer(99, "John", "Doe", new BigDecimal("30"), billingAddress, shippingAddress);
    registerTestObject(customer);
    product = new Product(88, "SomeWidget", new BigDecimal("19.99"));
    registerTestObject(product);
    invoice = new Invoice(customer);
    registerTestObject(invoice);
    // SUT 실행 (exercise SUT)
    invoice.addItemQuantity(product, 5);
    // 결과 검증 (Verify outcome)
    LineItem expected = new LineItem(invoice, product, 5, new BigDecimal("30"), new BigDecimal("69.96"));
    assertContainsExactlyOneLineItem(invoice, expected);
}

픽스처 설치 정리

생성자 호출과 registerTestObject에 메소드 뽑아내기(Extract Method) 리팩터링을 해서 생성 메소드(Creation Method - 20장 픽스처 설치 패턴) 를 정의하는 것이다. 이러면 테스트가 좀 더 읽고 쓰기 간단해진다. 생성 메소드를 쓰면 SUT API를 캡슐화할 수 있고, 여러 객체의 생성자가 변경됐을 때 각 테스트를 변경하지 않고 한곳에 있는 코드만 수정하면 되므로 테스트 유지 보수 비용을 줄일수 있다는 장점도 있다.

public void testAddItemQuantity_severalQuantity_v10(){
    // 픽스처 설치 (set up fixture)
    Address billingAddress = createAddress("1222 1st St SW", "Calgary", "Alberta", "T2N 2V2","Canada");
    Address shippingAddress = createAddress("1333 1st St SW", "Calgary", "Alberta", "T2N 2V2", "Canada");
    Customer customer = createCustomer(99, "John", "Doe", new BigDecimal("30"), billingAddress, shippingAddress);
    Product product = createProduct(88, "SomeWidget", new BigDecimal("19.99"));
    Invoice invoice = createInvoice(customer);
    // SUT 실행 (exercise SUT)
    invoice.addItemQuantity(product, 5);
    // 결과 검증 (Verify outcome)
    LineItem expected = new LineItem(invoice, product, 5, new BigDecimal("30"), new BigDecimal("69.96"));
    assertContainsExactlyOneLineItem(invoice, expected);
}

픽스처 설치 로직에는 아직도 문제가 많다.

먼저 픽스처와 기대 결과 값이 어떻게 연관되어있는지 알기 어렵다. 이 테스트는 무엇을 검증하고 있는가? 고객 주소나 다른 값이 결과에 어떤 식으로든 영향을 미치는가?

테스트에 하드 코딩된 테스트 데이터(Hard-Coded Test Data - 15장 코드 냄새)가 있는 것도 문제다. 하드 코딩된 테스트 데이터를 사용하면 데이터 중 유일해야 하는 게 있을 경우 반복 안 되는 테스트, 서로 반응하는 테스트, 테스트 실행전쟁(Test Run War - 16장 동작 냄새) 이 생길 수 있다.

이런 문제는 테스트 별로 고유 값을 만든 후 이 값으로 테스트에서 생성한 객체의 멤버 변수 값을 만들어 해결할 수 있다. 이렇게 하면 테스트가 실행될 때마다 고유 값이 다른 객체를 생성할 수 있다.

public void testAddItemQuantity_severalQuantity_v11(){
    final int QUANTITY = 5;
    // 픽스처 설치 (set up fixture)
    Address billingAddress = createAnAddress();
    Address shippingAddress = createAnAddress();
    Customer customer = createACustomer(new BigDecimal("30"), billingAddress, shippingAddress);
    Product product = createAProduct(new BigDecimal("19.99"));
    Invoice invoice = createInvoice(customer);
    // SUT 실행 (exercise SUT)
    invoice.addItemQuantity(product, QUANTITY);
    // 결과 검증 (Verify outcome)
    LineItem expected = new LineItem(invoice, product, 5, new BigDecimal("30"), new BigDecimal("69.96"));
    assertContainsExactlyOneLineItem(invoice, expected);
}

createAProduct는 아래와 같이 구현한다.

private Product createAProduct(BigDecimal unitPrice) {
    BigDecimal uniqueId = getUniqueNumber();
    String uniqueString = uniqueId.toString();
    return new Product(uniqueId.toBigInteger().intValue(), uniqueString, unitPrice);
}

객체 간의 특수성을 고려하지 않는다는 점에서 이런 패턴을 익명 생성 메소드(Anonymouse Creation Method - 20장 픽스처 설치 패턴) 라 한다. SUT의 기대 동작이 특정 값에 의존한다면 이 값을 인자로 넘기거나 생성 메소드의 이름으로 암시해줘야 한다.

또 리팩터링을 진행해보자. 기대 결과 값은 고객의 주소에 의존하고 있지 않다. 따라서 해당 부분을 메소드 뽑아내기 리팩터링 으로 우리 대신 주소를 생성해주는 createACustomer 메소드를 만들어 아예 주소를 숨겨보자.

public void testAddItemQuantity_severalQuantity_v12(){
    final int QUANTITY = 5;
    // 픽스처 설치 (set up fixture)
    Customer customer = createACustomer(new BigDecimal("30"));
    Product product = createAProduct(new BigDecimal("19.99"));
    Invoice invoice = createInvoice(customer);
    // SUT 실행 (exercise SUT)
    invoice.addItemQuantity(product, QUANTITY);
    // 결과 검증 (Verify outcome)
    LineItem expected = new LineItem(invoice, product, 5, new BigDecimal("30"), new BigDecimal("69.96"));
    assertContainsExactlyOneLineItem(invoice, expected);
}

아직도 정리해야 할 게 남아있다.

하드 코딩된 테스트 데이터가 테스트 안에서 두 번씩 반복된다. 매직 넘버를 기호 상수로 바꾸기(Replace Matic Number with Symbolic Constant[Fowler]) 리팩터링으로 이들 숫자에 역할을 보여주는 이름을 지어주면 의미를 분명하게 할 수 있다. 그리고 69.96 이라는 값도 어떻게 나온 값인지 알 수 있게 해주면 좋다.

또한

LineItem expected = new LineItem(invoice, product, 5, new BigDecimal("30"), new BigDecimal("69.96"));

이 부분은 SUT 자체에서 사용된다기 보다는 테스트 전용 코드에 가깝다. 이런 테스트 전용 코드는 테스트 하네스(harness)안에 구현한 외부 메소드(Foreign Method[Fowler]) 로 바꿔줘야한다. Customer와 Product에 한 작업과 동일하게 인자를 받는 생성 메소드(Parameterized Creation Method - 20장 픽스처 설치 패턴) 를 사용한다.

 public void testAddItemQuantity_severalQuantity_v14(){
    final int QUANTITY = 5;
    final BigDecimal UNIT_PRICE = new BigDecimal("19.99");
    final BigDecimal CUST_DISCOUNT_PC =  new BigDecimal("30");
    // 픽스처 설치 (set up fixture)
    Customer customer = createACustomer(CUST_DISCOUNT_PC);
    Product product = createAProduct(UNIT_PRICE);
    Invoice invoice = createInvoice(customer);
    // SUT 실행 (exercise SUT)
    invoice.addItemQuantity(product, QUANTITY);
    // 결과 검증 (Verify outcome)
    final BigDecimal BASE_PRICE = UNIT_PRICE.multiply(new BigDecimal(QUANTITY));
    final BigDecimal EXTENDED_PRICE =  BASE_PRICE.subtract(BASE_PRICE.multiply(CUST_DISCOUNT_PC.movePointLeft(2)));
    LineItem expected = createLineItem(QUANTITY, CUST_DISCOUNT_PC,  EXTENDED_PRICE, product, invoice);
    assertContainsExactlyOneLineItem(invoice, expected);
}

계산을 좀 더 잘 문서화 하기 위해 설명적인 변수 추가하기(introduce Explaining Variable[Fowler]) 리팩터링을 거쳤다. 이제 테스트는 처음의 코드보다 훨씬 작고 명확해졌다. 문서로서의 테스트(Test as Documentation - 3장 테스트 자동화의 목표) 역할에도 만족한다.

이 테스트는 송장(invoice)에 추가된 품목명(line item)이 실제로 invoice에 추가되는지, 확장 비용(extended cost)이 제품 가격과 고객의 할인율, 주문량으로 결정되는지를 확인한다.

마무리

테스트를 정리하는데 많은 노력을 들였다. 테스트를 작성할 때마다 이런 고생을 해야 하는 걸까? 그렇지는 않다. 테스트를 위한 유틸리티 메소드 들이 자리 잡고 나면 다른 테스트를 작성하기가 훨씬 쉬워진다.

테스트 유틸리티 메소드를 다른 테스트케이스 클래스에서도 재사용하려면 상위클래스 뽑아내기(Extract Superclass[Fowler]) 리랙토링으로 테스트케이스 상위클래스(24장 테스트 조직 패턴) 를 만든 후 메소드 올리기(Pull-Up Method[Fowler]) 리팩터링으로 테스트 유틸리티 메소드를 상위클래스로 옮겨 재사용할 수 있게 한다.