ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 단위 테스트 정리 1
    책책책 책을 읽읍시다/프로그래밍 2022. 6. 21. 22:55

    들어가며


    C# 코드 기반으로 테스트 스위트(test suite) 구축에 대해 설명한 책으로 저자는 블라디미르 코리코프(Vladimir Khorikov)다. 자바와 비슷한 컨셉의 C#으로 예제가 짜여져 있어 자바 개발자인 내가 코드를 읽기에 부담없었고, 한편으론 "대학교 다닐 때 배우던 C#이 아니구나, 자바에서 없는 기능(인스턴스 변수에 get; set;만 붙여도 getter/setter가 생성된다)들도 지원하네"하며 재미있게 읽었다. 회사 내부에 공유하기 위해 정리했는데, 저작권은 나에게 있으므로 재사용한다^^ 이번 글에서는 아래 차례에서 1부 '더 큰 그림'만 다뤄보자.

     

    차례


    1. 더 큰 그림
      1. 단위 테스트의 목표
      2. 단위 테스트란 무엇인가
      3. 단위 테스트 구조
    2. 개발자에게 도움이 되는 테스트 만들기
      1. 좋은 단위 테스트의 4대 요소
      2. 목과 테스트 취약성
      3. 단위 테스트 스타일
      4. 가치 있는 단위 테스트를 위한 리팩터링
    3. 통합 테스트
      1. 통합 테스트를 하는 이유
      2. 목 처리에 대한 모범 사례
      3. 데이터베이스 테스트
    4. 단위 테스트 안티 패턴
      1. 단위 테스트 안티 패턴

     

    더 큰 그림


    단위 테스트의 목표

     소프트웨어 프로젝트의 지속 가능한 성장을 가능하게 하는 것이다. 지속 가능하다는 것이 핵심이다. 코드베이스에서 무언가를 변경할 때마다 무질서도(엔트로피)는 증가한다. 하나의 버그를 수정하면 더 많은 버그를 양산하고, 소프트웨어의 한 부분을 수정하면 다른 부분이 고장난다. 결국 코드베이스를 신뢰할 수 없게 된다.

     테스트는 이러한 경향을 뒤집을 수 있다. 테스트는 안전망 역할을 하며, 대부분의 회귀(regression, 특정 사건, 일반적으로 코드 수정, 후에 기능이 의도한 대로 작동하지 않는 경우다.)에 대한 보험을 제공하는 도구라 할 수 있다. 테스트는 새로운 기능을 도입하거나 새로운 요구 사항에 더 잘 맞게 리팩터링한 후에도 기존 기능이 잘 작동하는지 확인하는 데 도움이 된다.

     한 가지 단점은 초반에 노력이 필요하다는 것이다. 그러나 장기적으로 보면 그 비용을 메울 수 있다. 코드베이스를 지속적으로 검증하는 테스트 없이는 소프트웨어 개발이 쉽게 확장되지 않는다. (이동욱님은 유튜브 채널에서 테스트코드 없이 리팩터링하는 것은 눈 감고 달리는 것과 같다고 했는데 비슷한 맥락같다) 지속성과 확장성이 핵심이며, 이를 통해 장기적으로 개발 속도를 유지할 수 있다.

     

    단위 테스트가 알려주는 신호

     첫째, 괜찮은 부정 지표이다. 코드를 단위 테스트하기 어렵다면 코드 개선이 반드시 필요하다는 것을 의미한다. 보통 강결합에서 저품질이 나타나는데, 여기서 강결합은 제품 코드가 서로 충분히 분리되지 않아서 따로 테스트하기 어려움을 뜻한다.

     두번째, 코드 조각을 단위 테스트할 수 있다는 것은 좋지 않은 긍정 지표다. 코드베이스를 쉽게 단위 테스트할 수 있다고 해도 반드시 코드 품질이 좋은 것을 의미하지 않는다. 낮은 결합도를 보여도 프로젝트는 ‘대참사'가 될 수 있다.

     

    성공적인 테스트 스위트는 다음과 같은 특성을 갖고 있다.

    • 개발 주기에 통합돼 있다.
    • 코드베이스에서 가장 중요한 부분만을 대상으로 한다.
    • 최소한의 유지비로 최대의 가치를 끌어낸다.

     

    단위 테스트란 무엇인가

    단위 테스트의 정의

    • 작은 코드 조각(단위라고도 함)을 검증하고
    • 빠르게 수행하고
    • 격리된 방식으로 처리하는 자동화된 테스트이다.

     

    고전파 vs. 런던파

    테스트 대상 시스템을 협력자에게서 극단적으로 격리하면 런던파, 아니면 고전파. -> 단위 테스트에서 격리 문제를 어떻게 다루는지에 있다.

    •  런던파는 테스트 대상 단위를 서로 분리해야 한다고 한다. 테스트 대상 단위는 코드의 단위, 보통 단일 클래스다. 불변 의존성을 제외한 모든 의존성을 테스트 대역으로 대체해야 한다.
    • 고전파는 단위가 아니라 단위 테스트를 서로 분리히야 한다고 한다. 또한 테스트 대상 단위는 코드 단위가 아니라 동작 단위다. 따라서 공유 의존성만 테스트 대역으로 대체해야 한다.
      격리 주체 단위의 크기 테스트 대역 사용 대상
    런던파 단위 단일 클래스 불변 의존성 외 모든 의존성
    고전파 단위 테스트 단일 클래스 또는 클래스 세트 공유 의존성

     

     공유 의존성(shared dependency)는 테스트 간에 공유되고 서로의 결과에 영향을 미칠 수 있는 수단을 제공하는 의존성이다. 공유 의존성의 전형적인 예는 정적 가변 필드(static mutable field)다. 이러한 필드의 변경 사항은 동일한 프로세스 내에서 실행되는 모든 단위 테스트에서 볼 수 있다. 데이터베이스도 공유 의존성의 전형적인 예이다.

    비공개 의존성(private dependencty)는 공유하지 않는 의존성이다.

    프로세스 외부 의존성(out-of-process dependency)는 애플리케이션 실행 프로세스 외부에서 실행되는 의존성이며, 아직 메모리에 없는 데이터에 대한 프록시이다. 프로세스 외부 의존성은 대부분 공유 의존성에 해당하지만 모두 그런 것은 아니다. 예를 들어 데이터베이스는 프로세스 외부이면서 공유 의존성이다. 그러나 각 테스트 실행 전에 도커 컨테이너로 데이터베이스를 시작하면 테스트가 더 이상 동일한 인스턴스로 작동하지 않기 때문에 프로세스 외부이면서 공유하지 않는 의존성이된다. 이러한 데이터베이스 환경에서는 테스트가 데이터를 변경할 수 없으므로 결과에 서로 영향을 미칠 수 없다.

     

    런던파의 장점

    • 입자성(granualarity)이 좋다. 테스트가 세밀해서(fine-grained) 한 번에 한 클래스만 확인한다.
    • 서로 연결된 클래스의 그래프가 커져도 테스트하기 쉽다. 모든 협력자는 테스트 대역으로 대체되기 때문에 테스트 작성 시 걱정할 필요가 없다.
    • 테스트가 실패하면 어떤 기능이 실패했는지 확실히 알 수 있다. 클래스의 협력자가 없으면 테스트 대상 클래스 외에 다른 것을 의심할 여지가 없다. 물론 테스트 대상 시스템이 값 객체를 사용하는 상황이 있을 수 있으며, 이 값 객체의 변경으로 인해 테스트가 실패하게 된다. 그러나 테스트 내 다른 의존성을 모두 제거했기 떄문에 이러한 경우는 흔하지 않다.

    런던파 테스트의 가장 큰 문제는 과잉 명세, 즉 SUT 세부 구현에 결합된 테스트 문제다.

     

    단위 테스트 구조

    AAA 패턴

    준비 구절(Arange)에서는 테스트 대상 시스템(SUT)과 해당 의존성을 원하는 상태로 만든다.

    실행 구절(Action)에서는 SUT에서 메서드를 호출하고 준비된 의존성을 전달하며 (출력이 있으면) 출력 값을 캡쳐한다.

    검증 구절(Assertion)에서는 결과를 검증한다. 결과는 반환 값이나 SUT와 협력자의 최종 상태, SUT가 협력자에 호출한 메서드 등으로 표시될 수 있다.

    Given-When-Then 패턴은 개발자에게 더 친숙한 패턴으로 아래와 같이 AAA 패턴과 매칭된다.

    Given = Arange

    When = Action

    Then = Assertion

     

    검증 구절로 구분된 여러 개의 실행 구절을 보면, 여러 개의 동작 단위를 검증하는 테스트를 뜻한다. 이러한 테스트는 더 이상 단위 테스트가 아니라 통합 테스트다. 이러한 테스트 구조는 피하는 것이 좋다. 실행이 하나면 테스트가 단위 테스트 범주에 있게끔 보장하고, 간단하고, 빠르며, 이해하기 쉽다. 일련의 실행과 검증이 포함된 테스트를 보면 리팩터링하라. 각 동작을 고유의 테스트로 도출하라.

     

    if 문이 있는 단위 테스트는 안티 패턴이다. 단위 테스트든 통합 테스트든 테스트는 분기가 없는 간단한 일련의 단계여야 한다. if 문은 테스트가 한 번에 너무 많은 것을 검증한다는 표시다. 테스트에 분기가 있어서 얻는 이점은 없다. 단지 추가 유지비만 불어난다. if문은 테스트를 읽고 이해하는 것을 더 어렵게 만든다.

     

    검증문의 크기 : 테스트당 하나의 검증을 갖는 지침을 들어봤을 것이다. 가능한 한 가장 작은 코드를 목표로 하는 전제에 기반을 두고 있지만, 올바르지 않다. 단위 테스트의 단위는 동작의 단위이지 코드의 단위가 아니다. 단일 동작 단위는 여러 결과를 낼 수 있으며, 하나의 테스트로 그 모든 것을 결과를 평가하는 것이 좋다. 그렇기는 해도 검증 구절이 너무 커지는 것은 경계해야 한다. 제품 코드에서 추상화가 누락됐을 수 있다. 예를 들어 SUT에서 반환된 객체 내에서 모든 속성을 검증하는 대신 객체 클래스 내에 적잘한 동등 멤버(equailty memeber)를 정의하는 것이 좋다. 그러면 단일 검증문으로 객체를 기대값과 비교할 수 있다.

     

    테스트 대상 시스템 구별하기 : SUT는 테스트에서 중요한 역할을 하는데, 애플리케이션에서 호출하고자 하는 동작에 대한 진입점을 제공한다. 동작은 여러 클래스에 걸쳐 있을 만큼 클 수도 있고 단일 메서드로 작을 수도 있다. 그러나 진입점은 오직 하나만 존재할 수 있다(동작을 수행할 하나의 클래스다). 따라서 SUT를 의존성과 구분하는 것이 중요하다. 특히 SUT가 꽤 많은 경우, 테스트 대상을 찾는 데 시간을 너무 많이 들일 필요가 없다. 그렇게 하기 위해 테스트 내 SUT 이름을 sut로 하라.

     

    단위 테스트 명명법

    가장 유명하지만 가장 도움이 되지 않는 방법 중 하나가 다음과 같은 관습이.

    • 테스트 대상 메서드: 테스트 중인 메서드의 이름
    • 시나리오: 메서드를 테스트하는 조건
    • 예상 결과: 현재 시나리오에서 테스트 대상 메서드에 기대하는 것

    동작 대신 구현 세부 사항에 집중하게끔 부추기기 때문에 분명히 도움이 되지 않는다. 간단하고 쉬운 영어 구문이 훨씬 더 효과적이며, 엄격한 명명 구조에 얽매이지 않고 표현력이 뛰어나다. 간단한 문구로 고객이나 도메인 전문가에게 의미 있는 방식으로 시스템 동작을 설명할 수 있다. 

     

    단위 테스트 명명 지침

    • 엄격한 명명 정책을 따르지 않는다. 복잡한 동작에 대한 높은 수준의 설명을 이러한 정책의 좁은 상자 안에 넣을 수 없다. 표현의 자유를 허용하자.
    • 문제 도메인에 익숙한 비개발자들에게 시나리오를 설명하는 것처럼 테스트 이름을 짓자. 도메인 전문가나 비즈니스 분석가가 좋은 예다.
    • 단어를 밑줄 표시로 구분한다. 그러면 특히 긴 이름에서 가독성을 향상시키는 데 도움이 된다.

    테스트명 내 테스트 대상 메서드

    테스트 이름에 SUT의 메서드 이름을 포함하지 말라.

    코드를 테스트하는 것이 아니라 애플리케이션 동작을 테스트하는 것이라는 점을 명심하자. 따라서 테스트 대상 메서드의 이름이 중요하지 않다. 앞에서 언급했듯이 SUT는 단지 진입점, 동작을 호출하는 수단일 뿐이다. 테스트 대상 메서드의 이름을 isDeliveryCorrect로 변경할 수 있으며, SUT의 동작에는 아무런 영향을 미치지 않는다. 반면 원래 명명 규칙을 따르면 테스트 이름을 바꿔야 한다. 동작 대신 코드를 목표로 하면 해당 코드의 구현 세부 사항과 테스트 간의 결합도가 높아진다는 것을 다시 한 번 보여주는데, 이는 테스트 스위트 유지 보수성에 부정적인 영향을 미친다.

    이 지침의 유일한 예외는 유틸리티 코드를 작업할 때다. 유틸리티 코드는 비즈니스 로직이 없고, 코드의 동작이 단순한 보조 기능에서 크게 벗어나지 않으므로 비즈니스 담당자에게는 아무런 의미가 없다. 여기는 SUT 메서드 이름을 사용해도 괜찮다.

     

    테스트 명 발전 예시

     

    [Fact]
    public void IsDeliveryVaild_InvaildDate_ReturnsFalse() {
        DeleiveryService sut = new DelivertService();
        DateTime pastDate = DateTime.Now.AddDays(-1);
        Deleivery delivery = new Delivery 
        { 
            Date = pastDate 
        };
    
        bool isVaild = sut.IsDeliveryValid(delivery);
        Assert.False(isVaild)
    }

    이 테스트는 DeliveryService가 잘못된 날짜의 배송을 올바르게 식별하는지 검증지만, 테스트 이름은 가독성에 도움이 되지 않는 엄격한 명명 정책으로 작성됐다.

     

    테스트 이름을 쉬운 영어로 어떻게 다시 작성할까? 다음은 괜찮은 첫 시도다.

    public vod Delivery_with_invalid_date_should_be_considered_invalid();

    • 이제 이름이 프로그래머가 아닌 사람들에게 납득되고, 마찬가지로 프로그래머도 더 쉽게 이해할 수 있다.
    • sut의 메서드 이름(IsDeliveryValid)은 더 이상 테스트명에 포함되지 않는다.

     

    public void Delivery_with_past_date_should_be_considered_invalid()

    나아지긴 했지만 여전히 이상적이지 않다. 그리고 장황하다. considered라는 단어를 제거해도 의미가 퇴색되지 않는다.

     

    public void Delivery_with_past_date_should_be_invalid()

    should be 문구는 또 다른 안티 패턴이다. 테스트는 동작 단위에 대해 단순하고 원자적인 사실이다. 사실을 서술할 때는 소망이나 욕구가 드러가지 않는다. is로 바꾸자.

     

    public void Delivery_with_past_date_is_invalid()

    기초 영문법을 지켜 관사 a를 추가하자.

     

    public void Delivery_with_a_past_date_is_invalid()

    이 최종 버전은 사실에 대해 단도직입적으로 설명했으므로, 테스트 대상인 애플리케이션 동작의 관점 중 하나를 설명한다. 이 경우에는 배송 가능 여부를 결정하는 관점이다.

     

    댓글

Designed by Tistory.