ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 단위 테스트 정리 4 - 목과 테스트 취약성
    책책책 책을 읽읍시다/프로그래밍 2022. 7. 4. 23:18

    단위 테스트 스타일


    출력 기반(output-based), 상태 기반(state-based), 통신 기반(communication-based)이라는 세 가지 테스트 스타일이 있다. 출력 기반 스타일의 테스트가 가장 품질이 좋고, 상태 기반 테스트는 두 번째로 좋은 선택이며, 통신 기반 테스트는 간헐적으로만 사용해야 한다.

    안타깝게도 출력 기반 테스트 스타일은 아무데서나 사용할 수 없으며, 순수 함수 방식으로 작성된 코드에만 적용된다. 이를 위해 함수형 프로그래밍 원칙을 사용해 기반 코드가 함수형 아키텍처를 지향하게끔 재구성해야 한다.

    출력 기반 스타일

    public class PriceEngine
    {
       public decimal CalculateDiscount(params Product[] products)
       {
           decimal discount = products.Length * 0.01m;
           return Math.Min(discount, 0.2m);
       }
    }
     
    [Fact]
    public void Discount_of_two_products()
    {
       var prodcut1 = new Product("Hand wash");
       var product2 = new Product("Shampoo");
       var sut = new PriceEngine();
     
       decimal discount = sut.CalculateDiscount(product1, product2);
     
       Assert.Equal(0.02m, discount);
    }

    PriceEngine은 상품 수에 1%를 곱하고 그 결과를 20%로 제한한다. 이 클래스에는 다른  것이 없으며, 내부 컬렉션에 상품을 추가하거나 데이터베이스에 저장하지 않는다. CalculateDiscount() 메서드의 결과는 반환된 할인, 즉 출력 값뿐이다.

    출력 기반 단위 테스트 스타일은 함수형(functional)이라고도 한다. 이 이름은 부작용 없는 코드 선호를 강조하는 프로그래밍 반식인 함수형 프로그래밍(functional programming)에 뿌리를 두고 있다.

    상태 기반 스타일 정의

    상태 기반 스타일은 작업이 완료된 후 시스템 상태를 확인하는 것이다. 이 테스트 스타일에서 상태라는 용어는 SUT나 협력자 중 하나, 또는 데이터베이스나 파일 시스템 등과 같은 프로세스 외부 의존성의 상태 등을 의미할 수 있다.

    public class Order
    {
       private readonly List<Product> _products = new List<Product>();
       public IReadOnlyList<Product> Products => _products.ToList();
     
       public void AddProduct(Product product)
       {
           _products.Add(product);
       }
    }
     
    [Fact]
    public void Adding_a_product_to_an_order()
    {
       var product = new Product("Hand Wash");
       var sut = new Order();
     
       sut.AddProduct(product);
     
       Assert.Equal(1, sut.Products.Count);
       Assert.Equal(product, sut.Products[0]);
    }

    Order 클래스를 통해 클라이언트가 새로운 상품을 추가할 수 있다. 테스트는 상품을 추가한 후 Products 컬렉션을 검증한다. 출력 기반 테스트의 예제와 달리 AddProduct()의 결과는 주문 상태의 변경이다.

    통신 기반 스타일 정의

    이 스타일은 목을 사용해 테스트 대상 시스템과 협력자 간의 통신을 검증한다.

    [Fact]
    public void Sending_a_greeting_email()
    {
       var emailGatewayMock = new Mock<IEmailGateway>();
       var sut = new Controller(emailGatewayMock.Object);
     
       sut.GreetUser("user@email.com");
     
       emailGatewayMock.Verify(
           x => x.SendGreetingsEmail("user@email.com"),
           Times.Once
       );
    }

     

    단위 테스트 스타일 비교

    회귀 방지와 피드백 속도 지표로 스타일 비교하기

    회귀 방지 지표는 다음 세 가지 특성으로 결정된다.

    • 테스트 중에 실행되는 코드의 양
    • 코드 복잡도
    • 도메인 유의성

    보통 실행하는 코드가 많든 적든 원하는 대로 테스트를 작성할 수 있다. 어떤 스타일도 이 부분에서 도움되지 않는다. 코드 복잡도와 도메인 유의성 역시 마찬가지다. 통신 기반 스타일에는 예외가 하나 있다. 남용하면 작은 코드 조각을 검증하고 다른 것은 모두 목을 사용하는 등 피상적인 테스트가 될 수 있다. 이는 통신 기반 테스트의 결정적인 특징이 아니라 기술을 남용하는 극단적인 사례다.

    테스트 스타일과 피드백 속도 사이에는 상관관계가 거의 없다. 테스트가 프로세스 외부 의존성과 떨어져 단위 테스트 영역에 있는 한, 모든 스타일은 테스트 실행 속도가 거의 동일하다.

     

    리팩터링 내성 지표로 스타일 비교하기

    리팩터링 내성은 리팩터링 중에 발생하는 거짓 양성(허위 경보)수에 대한 척도다. 결국 거짓 양성은 식별할 수 있는 동작이 아니라 코드의 구현 세부 사항에 결합된 테스트의 결과다.

    • 출력 기반 테스트는 테스트가 테스트 대상 메서드에만 결합되므로 거짓 양성 방지가 가장 우수하다.
    • 상태 기반 테스트는 일반적으로 거짓 양성이 되기 쉽다. 테스트 대상 메서드 외에도 클래스 상태와 함께 작동한다. 테스트와 제품 코드 간의 결합도가 클수록 유출되는 구현 세부 사항에 테스트가 얽매일 가능성이 커진다. 상태 기반 테스트는 큰 API 노출 영역에 의존하므로, 구현 세부 사항과 결합할 가능성도 더 높다.
    • 통신 기반 테스트가 허위 경보에 가장 취약하다. 테스트 대역으로 상호 작용을 확인하는 테스트는 대부분 깨지기 쉽다. 이는 항상 스텁과 상호 작용하는 경우다. 이러한 상호 작용을 확인해서는 안된다.

     

    유지 보수성 지표로 스타일 비교하기

    유지 보수성 지표는 단위 테스트 스타일과 밀접한 관련이 있다. 단위 테스트의 유지비를 측정하며, 다음 두 가지 특성으로 정의한다.

    • 테스트를 이해하기 얼마나 어려운가(테스트 크기에 대한 함수)?
    • 테스트를 실행하기 얼마나 어려운가(테스트에 직접적으로 관련 있는 프로세스 외부 의존성 개수에 대한 함수)?

     

    출력 기반 테스트가 가장 유지 보수하기 용이하다. 거의 항상 짧고 간결하기 때문이다. 이러한 이점은 메서드로 입력을 공급하는 것과 해당 출력을 검증하는 두 가지로 요약할 수 있다는 사실에서 비롯된다. 단 몇 줄로 이 두 가지를 수행할 수 있다. 출력 기반 테스트의 기반 코드는 전역 상태나 내부 상태를 변경할 리 없으므로, 프로세스 외부 의존성을 다루지 않는다. 따라서 두 가지 유지 보수성 모두의 측면에서 출력 기반 테스트가 가장 좋다.

     

    상태 기반 테스트는 일반적으로 출력 기반 테스트보다 유지 보수가 쉽지 않다. 상태 검증은 종종 출력 검증보다 더 많은 공간을 차지하기 때문이다.

    [Fact]
    public void Adding_a_comment_to_an_article()
    {
       var sut = new Article();
       var text = "Comment text";
       var author = "John Doe";
       var now = new DateTime(2019, 4, 1);
     
       sut.AddComment(text, author, now);
     
       Assert.Equal(1, sut.Comments.Count);
       Assert.Equal(text, sut.Comments[0].Text);
       Assert.Equal(author, sut.Comments[0].Author);
       Assert.Equal(now, sut.Comments[0].DateCreated);
    }

    이 테스트는 글에 댓글을 추가한 후 댓글 목록에 댓글이 나타나는지 확인한다. 단순하고 댓글이 하나만 있지만, 검증부는 네 줄에 걸쳐 있다. 상태 기반 테스트는 종종 훨씬 많은 데이터를 확인해야 하므로 크기가 대폭 커질 수 있다. 헬퍼나 Equals 메서드로 완화할 수 있지만, 본질적으로 클래스가 값에 해당하고 값 객체로 변환할 수 있을 떄만 효과적이다. 그렇지 않으면 코드 오염(code pollution, 단지 단위 테스트를 가능하게 하거나 단순화하기 위한 목적만으로 제품 코드베이스를 오염시키는 것)으로 이어진다.

     

    통신 기반 테스트에는 테스트 대역과 상호 작용 검증을 설정해야 하며, 이는 공간을 많이 차지한다. 목이 사슬 형태로 있을 때(mock chain, 목이 다른 목을 반환하고, 그 다른 목은 또 다른 목을 반환하는 식으로 여러 계층이 있는 목이나 스텁) 테스트는 더 커지고 유지 보수하기가 어려워진다.

      출력 기반 상태 기반 통신 기반
    리팩터링 내성을 지키기 위해 필요한 노력 낮음 중간 중간
    유지비 낮음 중간 높음

     

    출력 기반 테스트가 가장 결과가 좋지만 함수형으로 작성된 코드에만 적용할 수 있다.

     

    함수형 아키텍처 이해

    함수형 프로그래밍은 수학적 함수(mathematical function, 순수함수라고도 함)를 사용한 프로그래밍이다. 수학적 함수는 숨은 입출력이 없는 함수(또는 메서드)다. 수학적 함수의 모든 입출력은 메서드 이름, 인수, 반환 타입으로 구성된 메서드 시그니처(method signature)에 명시해야 한다. 수학적 함수는 호출 횟수에 상관없이 주어진 입력에 대해 동일한 출력을 생성한다.

     

    입출력을 명시한 수학적 함수는 이에 따르는 테스트가 짧고 간결하며 이해하고 유지 보수하기 쉬우므로 테스트하기가 매우 쉽다. 출력 기반 테스트를 적용할 수 있는 메서드 유형은 수학적 함수뿐이다. 이는 유지 보수성이 뛰어나고 거짓 양성 빈도가 낮다. 반면에 숨은 입출력은 코드를 테스트하기 힘들게 한다(가독성도 떨어짐). 숨은 입출력의 유형은 다음과 같다.

    • 부작용 : 메서드 시그니처에 표시되지 않은 출력이며, 따라서 숨어있다. 연산은 클래스 인스턴스의 상태를 변경하고 디스크의 파일을 업데이트하는 등 부작용을 발생시킨다.
    • 예외 : 메서드가 예외를 던지면, 프로그램 흐름에 메서드 시그니처에 설정된 계약을 우회하는 경로를 만든다. 호출된 예외는 호출 스택의 어느 곳에서도 발생할 수 있으므로, 메서드 시그니처가 전달하지 않는 출력을 추가한다.
    • 내외부 상태에 대한 참조 : DateTime.Now와 같이 정적 속성을 사용해 현재 날짜와 시간을 가져오는 메서드가 있을 수 있다. 데이터베이스에서 데이터를 질의할 수 있고, 비공개 변경 가능 필드를 참조할 수 도 있다. 이 모두 메서드 시그니처에 없는 실행 흐름에 대한 입력이며, 따라서 숨어있다.

     

    함수형 프로그래밍의 목표는 부작용을 완전히 제거하는 것이 아니라 비즈니스 로직을 처리하는 코드와 부작용을 일으키는 코드를 분리하는 것이다.

    다음 두 가지 코드 유형을 구분해서 비즈니스 로직과 부작용을 분리할 수 있다.

    • 결정을 내리는 코드 : 이 코드는 부작용이 필요 없기 때문에 수학적 함수를 사용해 작성할 수 있다.
    • 해당 결정에 따라 작용하는 코드 : 이 코드는 수학적 함수에 의해 이뤄진 모든 결정을 데이터베이스의 변경이나 메세지 버스로 전송된 메세지와 같이 가시적인 부분으로 변환한다.

    결정을 내리는 코드는 종종 함수형 코어(functional core, 불변 코어라고도 함)라고도 한다. 해당 결정에 따라 작용하는 코드는 가변 셸(mutable shell)이다.

    함수형 코어와 가변 셸은 다음과 같은 방식으로 협력한다.

    • 가변 셸은 모든 입력을 수집한다.
    • 함수형 코어는 결정을 생성한다.
    • 셸은 결정을 부작용으로 변환한다.

    목표는 출력 기반 테스트로 함수형 코어를 두루 다루고 가변 셸을 훨씬 더 적은 수의 통합 테스트에 맡기는 것이다.

     

    관심사 분리, 의존성 간의 단방향 흐름이라는 특징이 육각형 아키텍처와 비슷하다. 이 둘의 차이점은 부작용에 대한 처리에 있다. 함수형 아키텍처는 모든 부작용을 불변 코어에서 비즈니스 연산 가장자리로 밀어낸다. 이 가장자리는 가변 셸이 처리한다. 반면 육각형 아키텍처는 도메인 계층에 제한하는 한, 도메인 계층으로 인한 부작용도 문제없다. 육각형 아키텍처의 모든 수정 사항은 도메인 계층 내에 있어야 하며, 계층의 경계를 넘어서는 안된다. 예를 들어 도메인 클래스 인스턴스는 데이터베이스에 직접 저장할 수 없지만, 상태는 변경할 수 있다. 애플리케이션 서비스에서 이 변경 사항을 데이터베이스에 적용한다.

     

    함수형 아키텍처 단점에 대한 고민

    함수형 아키텍처와 전통적인 아키텍처 사이의 선택은 성능과 코드 유지 보수성 간의 절충이다. 성능 영향이 그다지 눈에 띄지 않는 일부 시스템에서는 함수형 아키텍처를 사용해 유지 보수성을 향상시키는 편이 낫다. 비즈니스 로직에 의해 접근하지 않아도 되지만, 가변 셸에서 데이터베이스에 무조건 질의해 데이터를 취득하는 것이 예이다.

    코드베이스의 크기도 마차낙지다. 함수형 아키텍처는 함수형 코어와 가변 셸 사이를 명확하게 분리해야 한다. 궁극적으로 코드 복잡도가 낮아지고 유지 보수성이 향상되지만, 초기에 코딩이 더 필요하다. 초기 투자로 성과를 내지 못하는 너무 단순하거나 비즈니스 관점에서 그다지 중요하지 않은 프로젝트에 함수형 아키텍처를 사용하는 것은 별 의미가 없다.

    마지막으로, 함수형 방식에서 순수성에 많은 비용이 든다면 순수성을 따르지 말라. 대부분의 프로젝트에서는 모든 도메인 모델을 불변으로 할 수 없기 때문에 출력 기반 테스트에만 의존할 수 없다. 대부분의 경우 출력 기반 스타일과 상태 기반 스타일을 조합하게 되며, 통신 기반 스타일을 약간 섞어도 괜찮다. 목표는 모든 테스트를 출력 기반 스타일로 전환하는게 아니라 가능한 한 많은 테스트를 전환하는 것이다.

    댓글

Designed by Tistory.