ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 유닛 테스트는 정확히 무엇을 가리키는가
    test 2021. 11. 28. 23:57

    저번 포스팅에 이어서 Unit Testing Principles, Practices, and Patterns의 Chapter 2를 요약하려고 한다. 이번 챕터를 통해 '유닛 테스트란 무엇인가'에 대한 저자의 생각과, 앞으로 책에서 다룰 내용들에 앞서 필요한 배경 지식을 배울 수 있다.

     

    유닛 테스트의 정의와 두 학파

    • 유닛 테스트는 다음을 모두 만족하는 자동화한 테스트이다.
      • 1. 작은 코드(unit)를 검증한다.
      • 2. 빠르게 실행된다.
      • 3. 격리되어(isolated) 실행된다.
    • 이 중 3번에 대해서 학파별로 의견이 극명하게 나뉜다. "유닛 테스트의 정의에서 말하는 isolation의 의미는 무엇인가"에 대한 의견이 핵심이 되어, 유닛 테스트에 대한 철학과 방법이 갈리게 된다.
    • Classical school이 생각하는 isolation은 테스트 간의 분리이다. 각 테스트가 어떤 순서로 실행되든, 동시에 실행되든, 테스트의 결과에 영향이 없어야 한다.
    • (참고로 저자의 입장은 Classical school에 가깝다.)
    • London school은 SUT(System Under Test = 테스트 대상)를 그것의 의존성(collaborators)로부터 분리하는 것이 유닛 테스트의 isolationd이라고 주장한다. 어떤 클래스를 테스트할 때, 그 클래스의 모든 의존성을 test double로 대체해야 한다. 이렇게 하면 오직 그 클래스만을 테스트할 수 있다.
    • 참고로 test double은 실제 코드처럼 행동하지만 보다 단순화된 형태를 가리킨다. SUT와 의존성 간의 상호작용을 검증하는 데 사용되는 mock은 test double의 일종이다.
    • London school의 대표적인 저서로는 Growing Object-Oriented Software, Guided by Tests (Addison-Wesley Professional, 2009)가 있으며, Classical school의 대표적인 저서로는 Kent Beck: Test-Driven Development: By Example (Addison-Wesley Professional, 2002)이 있다. (우연히 두 권 다 가지고 있는 책이네..꼭 읽어봐야지)

     

    테스트 코드로 보는 두 학파의 차이

    먼저 classical school의 방식을 따른 테스트의 예시는 다음과 같다.

    // 1. classical style
    
    public void Purchase_succeeds_when_enough_inventory()
    {
        // Arrange
        var store = new Store();
        store.AddInventory(Proudct.Shampoo, 10);
        var customer = new Customer();
        
        // Act
        bool success = customer.Purchase(store, Proudct.Shampoo, 5);
        
        // Assert
        Assert.True(success);
        Assert.Equals(5, store.GetInventory(Product.Shampoo));
    }
    
    public void Purchase_fails_when_not_enough_inventory()
    {
        // Arrange
        var store = new Store();
        store.AddInventory(Proudct.Shampoo, 10);
        var customer = new Customer();
        
        // Act
        bool success = customer.Purchase(store, Proudct.Shampoo, 15);
        
        // Assert
        Assert.False(success);
        Assert.Equals(10, store.GetInventory(Product.Shampoo));
    }
    
    public enum Proudct 
    {
        Shampoo,
        Book
    }

    위 코드를 보면 SUT는 Customer이지만, Store도 test double을 사용하지 않고 그대로 사용되고 있다. Assert를 하는 부분에서 Store의 상태를 검증하고 있다. 즉, Customer을 테스트하는 과정에서 Store도 테스트된다.

     

    반면 London school에서 작성할 법만 테스트 코드를 보자.

    public void Purchase_succeeds_when_enough_inventory()
    {
        // Arrange
        var storeMock = new Mock<IStore>();
        storeMock
        	.Setup(x => x.HasEnoughInventory(Product.Shampoo, 5))
            .Returns(true);
        var customer = new Customer();
        
        // Act
        bool success = customer.Purchase(storeMock.Object, Proudct.Shampoo, 5);
        
        // Assert
        Assert.True(success);
        storeMock.Verify(
        	x => x.RemoveInventory(Product.Shampoo, 5),
            Times.Once);
    }
    
    public void Purchase_fails_when_not_enough_inventory()
    {
        // Arrange
        var storeMock = new Mock<IStore>();
        storeMock
        	.Setup(x => x.HasEnoughInventory(Product.Shampoo, 5))
            .Returns(false);
        var customer = new Customer();
        
        // Act
        bool success = customer.Purchase(storeMock.Object, Proudct.Shampoo, 5);
        
        // Assert
        Assert.False(success);
        storeMock.Verify(
        	x => x.RemoveInventory(Product.Shampoo, 5),
            Times.Never);
    }

    위 코드에서는 진짜 Store 대신에 mock 객체를 사용한다. 검증 단계에서는 Store의 상태가 아니라, Customer과 Store 사이의 상호작용(Store.RemoveInventory를 호출)을 확인한다.

     

    의존성을 처리하는 방법의 차이

    • 유닛 테스트의 정의 중 isolation(3번)에 대한 견해차는 자연스럽게 테스트의 크기(1번)에 대한 견해차로 이어진다.
    • London school는 모든 클래스를 그것의 의존성으로부터 격리할 것을 주장한다. 따라서 하나의 클래스(또는 하나의 메서드)가 유닛 테스트의 크기가 되고, 모든 의존성은 test double로 대체되어야 한다. 단, 의존성 중에서 value object나 value는 예외이다.
    • Classcial school에서는 테스트들이 서로 영향을 주지 않는 환경에서 실행되어야 하는 것이 핵심이다. 따라서 서로 영향만 안 준다면, 여러 개의 클래스가 하나의 unit이 되기도 한다. 서로 영향을 주지 않기 위해서는 shared state가 없어야 한다. classical school 방식에서는 보통 shared dependency에 대해서만 test double을 사용한다.
    • 이 차이를 표로 나타내면 다음과 같다.
      •  
          Isolation of A unit is Uses test doubles for
        London school Units A class All but immutable dependencies
        Classical school Unit tests A class or a set of classes Shared dependencies
    • 참고: 의존성의 종류
      • shared dependency: 테스트 간에 공유되어 테스트의 결과에 영향을 끼칠 수 있는 의존성을 가리킨다. 그 예로는 static mutable field, 데이터베이스 등이 있다. 모든 shared dependency는 mutable하다.
      • private dependency: 테스트 간에 공유되지 않는 의존성을 가리킨다. 모든 의존성은 shared dependency와 private dependency 중 하나에 속한다. private dependency의 예로는 위 코드에서 Store(mutable), Proudct.Shampoo(immutable)를 들 수 있다.
      • out-of-process dependency: 애플리케이션의 실행 프로세스의 외부에서 작동되는 의존성을 가리킨다. 아직 메모리에 존재하지 않는 데이터에 대한 proxy라고 볼 수 있다.
        • 현업 프로젝트에서 out-of-process가 아닌 shared dependency는 거의 드물고, 반대로 shared가 아니면서 out-of-process dependency가 아닌 경우도 마찬가지로 드물다. 따라서 이 책에서는 별도로 언급하지 않는 한 shared dependency와 out-of-process dependency를 동의어로 사용할 것이다.
        • out-of-process dependency이면서 동시에 shared dependency인 예로는 데이터베이스와 파일 시스템이 있다. 
        • 하지만 각 테스트를 실행할 때마다 매번 데이터베이스를 도커 컨테이너를 사용해서 실행한다면, 이러한 데이터베이스는 out-of-process이지만 shared는 아니다. read-only 데이터베이스도 테스트 간에 재사용되더라도 out-of-process일 뿐 shared는 아니다.
        • 마찬가지로 read-only API 서비스도 out-or-process이지만 shared는 아니다.
        • 반면 싱글톤 객체나 static field는 shared이지만 out-of-process는 아니다.
      • volatile dependency: 다음 중 어딘가에 해당한다면 volatile dependency이다.
        • 개발자의 머신에 디폴트로 설치되어 있는 것 외에, 새로 세팅해야 하는 런타임 환경을 필요로 한다. 데이터베이스와 API 서비스가 그 예이다.
        • 비결정적인(nondeterministic) 방식으로 작동한다. 난수 생성기나 현재 시각을 리턴하는 클래스가 그 예이다.
      • collaborator은 shared dependency 또는 mutable dependency를 가리킨다.

     

    저자가 생각하는 Classical school을 더 지지하는 이유

    • London school의 방식을 따르면 다음의 장점이 있다.
      • 1. 테스트 원칙을 세우기 쉽다. 하나의 클래스당 테스트가 하나씩 반드시 있어야 한다.
      • 2. 복잡한 의존관계를 신경쓰지 않아도 된다. 'A 클래스가 B에 의존하고 있고, B는 C, D에 의존하고, C는 E, F, G에 의존하고....' 이런 복잡한 의존 관계를 따르는 A를 테스트하기 위해 복잡한 세팅을 할 필요 없이, B를 test double로 대체함으로써 쉽게 테스트할 수 있다.
      • 3. 테스트가 실패했을 때 원인을 바로 알 수 있다.
    • 1번에 대한 반박: 객체 지향에 익숙한 프로그래머는 하나의 클래스가 기초 단위처럼 느껴진다. 그래서 London school의 방식이 자연스럽게 느껴진다. 하지만 테스트는 코드 유닛(units of code)이 아니라 행동 유닛(units of behavior)을 테스트해야 한다. 이러한 기능은 도메인적으로 의미가 있는 것이고, 이상적으로는 프로그래머가 아닌 사람도 이해할 수 있는 비즈니스 요건이다. 이를 구현하기 위해 몇 개의 클래스가 필요했는지는 중요하지 않다. 즉, 테스트는 코드가 해결하고자 하는 하나의 스토리를 담고 있어야 한다. (뭔가 Domain-Driven Design스러운 내용이군!)
    • 2번에 대한 반박: 물론 Classical school의 방식을 따른다면 테스트를 위한 의존성을 세팅하기 위해서 매우 고단할 수 있다. 그런데 애초에 이렇게 테스트가 힘든 상황을 만든 것 자체가 문제이다. (London school처럼) 복잡한 의존관계를 가지고 있는 클래스들을 테스트하려는 방법을 찾으려기 보다는, 처음부터 그런 복잡한 의존관계가 없도록 설계해야 한다. (1장에서 말했듯이, 유닛 테스트가 불가능한 것은 프로덕션 코드가 엉망이라는 확실한 증거이지만, 유닛 테스트가 가능하다고 해서 프로덕션 코드가 좋은 코드라는 증거는 되지 못한다.)
    • 3번에 대한 반박: 물론 Classical school의 방식을 따른다면 테스트를 실패하게 만든 원인을 찾느라 고생할 수 있다. 그런데 이는 현실적으로는 문제가 되지 않는 단점이다. 왜냐하면 테스트를 실패하게 만든 범인은 바로 방금 추가된 코드이기 때문이다. 코드 변경을 만들 때마다 테스트를 실행한다면 실패하는 테스트를 바로 확인하여 코드를 수정할 수 있을 것이다. 그리고 방금 수정한 코드 때문에 여러 테스트가 깨진다면, 그만큼 이 코드가 전체 시스템에서 중요하다는 뜻이다.
    • London school의 장점에 대한 반박을 다 했지만, 사실 London school의 가장 큰 문제는 over-specification이다. 즉, 테스트가 구현 디테일을 담고 있다는 것이다. 이런 테스트는 변경에 취약하다. (1장에서 말했던 false alarm의 주범이 될 수 있다)
    • 참고: TDD를 하는 방식도 두 학파가 다르다.

     

    통합 테스트(integration test)에 대한 견해차

    • 통합 테스트는 처음에 정의했던 유닛 테스트의 조건 중 최소한 하나가 만족되지 않는 테스트이다.
    • 이러한 정의에 따르면, Classical school에서 작성한 유닛 테스트는 London school이 보기에는 통합 테스트일 것이다.
    • end-to-end 테스트는 통합 테스트의 한 종류인데, 그 구분은 명확하지는 않다. 대체로 통합 테스트는 1~2개의 out-of-process dependency만 포함하여 테스트하지만, end-to-end는 전부(모든 의존성을 자동화하여 구성하는 것이 가능하다면) 또는 거의 대부분의 out-of-process dependency를 테스트한다.

     

    'test' 카테고리의 다른 글

    좋은 유닛 테스트를 찾아서  (0) 2021.11.13
    Spock 체험기  (0) 2021.10.31

    댓글

Designed by Tistory.