Home > Software Engineering > Test > 단위 테스트 (Unit Testing) - 단위 테스트의 목표와 구조

단위 테스트 (Unit Testing) - 단위 테스트의 목표와 구조
Test

단위 테스트의 목표

  • 단위 테스트의 목표: 소프트웨어 프로젝트의 지속 가능한 성장
    • 버그 없이 주기적으로 리팩토링하고 새로운 기능을 추가할 수 있도록 지원
    • 테스트 없는 프로젝트
      • 초기 개발 속도가 빠름 -> 시간이 갈수록 엔트로피(시스템 내 무질서도) 증가 및 개발 속도 감소
    • 테스트 있는 프로젝트
      • 초반에 상당한 노력이 들어감 -> 프로젝트 후반에도 안정적으로 잘 성장
  • 단위 테스트 적용은 필수이고 논쟁거리가 아님
    • 테스트는 코드베이스의 일부
    • 애플리케이션의 정확성을 보장하는 책임을 가진 코드
  • 현재의 논쟁: 좋은 단위테스트는 어떤 것인가?
    • 개발 주기에 통합되어 있는 것
      • 매 배포 전 테스트 실행
    • 코드베이스에 가장 중요한 부분을 대상으로 하는 것
      • 핵심인 도메인 모델을 다른 것과 구분해 테스트
    • 최소한의 유지비최대 가치를 끌어내는 것
      • 고품질 테스트는 동작의 단위검증하는 것 (비즈니스 로직 테스트)
        • 식별할 수 있는 동작은 테스트하고 구현 세부사항은 테스트 X
      • 필요 사항
        • 가치 있는 테스트 식별하기
        • 가치 있는 테스트 작성하기
  • 테스트 스위트 품질 측정 방법
    • 커버리지 지표
      • 테스트 스위트가 소스 코드를 얼마나 실행하는지 백분율로 표현
      • 중요한 피드백을 줄 순 있지만 테스트 스위트 품질 측정에 부적합
        • 커버리지가 낮으면 테스트가 충분치 않다는 좋은 증거
          • 시스템의 핵심 부분커버리지를 높게 두는게 좋음
        • 하지만 커버리지가 높다고 품질을 보장하지는 못함
          • 모든 결과의 검증을 보장하지 못함 (100%여도 빠져나가는 케이스들이 있음)
          • 외부 라이브러리는 경로 검증 불가
      • 지표로만 보고 목표로 삼아서는 안됨!!
        • 특정 커버리지를 목표로하면 개발자들은 시스템을 속일 방법을 궁리하는 부작용 발생
      • 종류
        • 코드 커버리지(code coverage, test coverage)
          • 코드 커버리지 = 실행 코드 라인 수 / 전체 라인 수
          • 커버리지 숫자는 조작이 가능,,,
            • e.g. return input.Length() > 5 //input='abc', 100% 커버리지
        • 분기 커버리지(branch coverage)
          • 분기 커버리지 = 통과 분기 / 전체 분기수
          • 코드 커버리지를 조금은 보완
            • e.g. return input.Length() > 5 //input='abc', 50% 커버리지

회귀(=소프트웨어 버그)

코드 수정 후 기능이 의도한 대로 작동하지 않는 경우를 의미한다.

단위 테스트란?

  • 단위 테스트의 속성
    • 작은 코드 조각(Unit)을 검증
    • 빠르게 수행
    • 격리된 방식으로 처리하는 자동화된 테스트 (쟁점)
      • 격리가 무엇인지에 대한 의견 차이가 근본적으로 고전파와 런던파를 가름
  • 단위 테스트 접근 방식에 대한 분파
    unit_testing_classical_school_vs_london_school
    unit_testing_dependency_hierarchy
    • 고전파 (Classical School, Detroit) - 지향
      • 원론적인 접근 추구
      • 상향식 TDD (도메인 모델부터 시작)
        • 상태를 검증 (e.g. 고객이 상점을 통해 구매하면 상점의 재고 차감 여부를 검증)
      • 격리 방식에 대한 관점: 단위 테스트끼리 격리
        • 테스트는 적합한 순서(순차적 or 병렬적)로 실행 가능하며 서로의 결과에 영향 X
        • 공유 의존성에 대해서만 테스트 대역 사용
          • 단위 테스트 간 공유되는 의존성은 테스트 대역으로 교체
            • 싱글톤 객체 같은 경우 각 테스트마다 생성하면 테스트 간 공유 X
            • 설정 파일도 각 테스트마다 생성자 주입하는 방식으로 가능
        • 공유 의존성 = 프로세스 외부 의존성
          • 실무에서 예외 케이스가 거의 없음!
          • 예외 상황: 읽기 전용 외부 API (불변 의존성이므로 공유 의존성 X)
            • 테스트 속도와 안정성을 위해 테스트 대역으로 교체 권장
            • 빠르고 안정적이라면 원칙에 맞춰 그대로 사용해도 괜찮
          • 교체 시 테스트 속도 상승 (단위 테스트의 2번째 요건 충족)
            • 공유 의존성은 대부분 프로세스 외부에 있어 호출이 느림
            • 외부 공유 의존성 테스트는 보통 통합 테스트의 영역
      • 코드 조각 범위 (Unit): 공유 의존성만 없으면 여러 클래스 묶어 테스트 가능
      • 통합 테스트: 단위 테스트 정의를 충족하지 않는 테스트
        • 고전파 관점의 단위 테스트 정의
          • 단일 동작 단위를 검증하고
          • 빠르게 수행하고
          • 다른 테스트와 별도로 처리한다.
        • e.g. 둘 이상의 동작 단위 검증 테스트, 다른 팀 개발 코드와 통합해 검증하는 테스트, 프로세스 외부 의존성, 공유 의존성
      • 장점
        • 단위 테스트 목표 달성에 적합 -> 동작의 단위 검증에 유용
      • 단점
        • SUT가 올바르게 동작하더라도 협력자에 버그에 있는 경우 테스트 실패
    • 런던파 (London School, Mockist)
      • 런던의 프로그래밍 커뮤니티에서 시작
      • 하향식 TDD (상위 레벨 테스트부터 시작)
        • 상호작용을 검증 (e.g. 목 객체의 메서드가 올바르게 호출되었는지, 호출 횟수는 맞는지…)
      • 격리 방식에 대한 관점: 테스트 대상 시스템에서 협력자를 격리
        • 한 클래스의 의존성을 모두 테스트 대역(test double)으로 대체 (불변 의존성은 그대로)
          • e.g. 인터페이스를 통해 목 객체를 만들고 SUT에 인자로 넘김
      • 코드 조각 범위 (Unit): 단일 클래스 혹은 해당 클래스 내 메서드
      • 통합 테스트: 실제 협력자 객체를 사용하는 모든 테스트
      • 장점
        • 테스트가 실패하면 테스트 대상 시스템이 고장난 것이 확실해짐 (테스트가 세밀)
          • 중요성 떨어짐 (정기적으로 테스트 실행하면 고장난 부분 좁히기 쉬움)
        • 테스트 준비 시 복잡한 의존성 조립을 피하고 대역 하나로 대체 가능
          • e.g. 한 번에 한 클래스만 테스트 -> 전체 단위 테스트 구조 간단해짐
          • 중요성 떨어짐 (복잡한 의존성은 잘못된 설계이므로 설계를 바꿔야 함)
      • 단점
        • 목을 다루는 것은 불안정함 내포
          • 테스트가 SUT의 구현 세부 사항에 빈번히 결합

테스트 대상 시스템(SUT, System Under Test) & 협력자(Collaborator)

SUT현재 테스트에 대상이 되는 시스템을 의미한다. 테스트 대상 메서드는 MUT(Method Under Test)라고도 한다.
반면에 협력자불변 의존성(값 객체)을 제외한 시스템에 엮여 있는 모든 의존성들을 의미한다.
즉, 일반적인 클래스는 협력자값 객체로 2가지 유형의 의존성으로 동작할 수 있다.

의존성 종류

공유 의존성(shared dependency)
테스트 간 공유되고 서로의 결과에 영향을 미칠 수 있는 의존성
e.g. 정적 가변 필드, 데이터베이스

비공개 의존성(private dependency)
테스트 간 공유하지 않는 의존성

프로세스 외부 의존성(out-of-process dependency)
unit_testing_out_of_process_dependency
애플리케이션 프로세스 외부에서 실행되는 의존성. 대부분 공유 의존성이지만 아닌 경우도 있다.
e.g. 데이터베이스는 외부 의존성이면서 공유 의존성인 반면,
테스트 실행 전 도커 컨테이너로 시작한 데이터베이스는 외부 의존성이면서 비공개 의존성
읽기 전용 API처럼 프로세스 외부 의존성이지만 불변 의존성이어서 공유 의존성이 아님

엔드 투 엔드 테스트 (end-to-end test)

시스템을 최종 사용자 관점에서 검증하는 것을 의미한다. (동의어: UI 테스트, GUI 테스트, 기능 테스트)

엔드 투 엔드 테스트는 통합 테스트의 일부다.
둘 모두 코드가 프로세스 외부 의존성과 함께 어떻게 작동하는지 검증한다. 다만, 엔드 투 엔드 테스트가 일반적으로 의존성을 더 많이 포함한다.
(통합 테스트가 프로세스 외부 의존성이 1~2개, 엔드 투 엔드 테스트전부 혹은 대다수)
예를 들어, DB, 파일 시스템, 결제 게이트 웨이라는 3가지 프로세스 외부 의존성이 있다면, 보통의 통합 테스트완전히 제어 가능하지 않은 결제 게이트 웨이만 테스트 대역으로 대체하는 반면에, 엔드 투 엔드 테스트는 전부 테스트에 포함한다.

통합 테스트와 엔드 투 엔드 테스트의 뚜렷한 경계는 없다. 테스트 버전이 없거나 자동으로 가져오는 것이 불가능한 프로세스 외부 의존성의 경우 엔드 투 엔드 테스트 역시 테스트 대역을 사용해야 한다.

또한, 엔드 투 엔드 테스트는 유지보수 측면에서 가장 비용이 많이 들기 때문에 모든 단위 테스트와 통합 테스트가 통과한 후 빌드 프로세스 후반에 실행하는 것이 좋다.

단위 테스트 구조

AAA 패턴 (Arrage, Act, Assert)

  • 준비, 실행, 검증패턴으로 테스트하는 일반적인 방식
  • 단순하고 균일한 구조를 만들어 가독성유지보수성이 향상
  • Given, When, Then은 비기술자에게 조금 더 읽기 쉬운 점말고 AAA와 차이가 없음

단위 테스트 구조에 대한 지침

  • 한 테스트여러 개의 준비, 실행, 검증 구절 -> 여러 테스트로 나눠라!
    • 여러 구절 = 테스트가 여러 개의 동작 단위를 한 번에 검증 = 통합 테스트
  • 테스트 내 if 문 피하자 -> 여러 테스트로 나눠라!
    • 분기가 있다는 것 역시 한 번에 너무 많은 것을 검증한다는 표시
    • 단위 테스트 든 통합 테스트 든 테스트에 분기가 있어서 얻는 이점은 없음 (가독성만 감소)
  • 각 구절의 적정 크기
    • 준비 구절: 세 구절 중 가장 큼
      • 많이 크다면 테스트 내 비공개 메서드, 별도 팩토리 클래스 추출해 재사용
    • 실행 구절: 한 줄 (이상적)
      • 두 줄 이상인 경우 SUT API 설계 문제 의심 -> 캡슐화 지키기
        • e.g. 테스트 내 Purchase(), RemoveInventory() 각각 실행-> 캡슐화가 깨짐
      • 단, 유틸리티나 인프라 코드는 덜 적용되므로, 절대 두줄 이상 두지 말라고 할 수는 없음!
    • 검증 구절: 여러 검증이 있을 수 있지만 너무 커지는 것을 경계
      • equals() 정의해 객체 끼리 Assert문 한번에 검증하는 것이 좋음
    • 참고) 종료 구절: 리소스 정리 목적으로 통합테스트에 주로 쓰임 (메서드 추출해 재사용)
  • SUT 구분하기
    • SUT는 동작에 대한 유일한 진입점
    • 테스트 내 SUT 이름을 sut로 명명해 구분하자
  • 구절 주석 지침
    • AAA 패턴 따르고 주석 없이 빈 줄로 각각 구절을 3등분해 구분
    • 구절 내에 빈 줄이 있다면, 주석 추가하기 (//arrage, //act, //assert)
  • 테스트 픽스처 재사용하기
    • 테스트 픽스처: 단위 테스트를 수행할 때 필요한 초기 상태설정
      • e.g. 계좌 잔고 확인 테스트를 위한 초기 입금 설정 작업
    • 준비 구절 코드 재사용좋은 방법
    • 픽스처 재사용 방법
      • 권장: 테스트 클래스비공개 팩토리 메서드 두자 (가독성, 재사용성 향상)
        • CustomerTests -> CreateStoreWithInventory(), CreateCustomer()
      • 안티 패턴: 테스트 클래스 생성자에서 픽스처 초기화
        • e.g. _store.AddInventory(Product.Shampoo, 10)
        • 테스트 간 결합도 상승 및 가독성 감소
          • 테스트마다 재고 변경을 다르게 설정 하고 싶어도 결합되어 어려움 (공유 상태)
      • 생성자 재사용의 유일한 예외: 테스트 대부분에 사용되는 픽스처
        • 기초 클래스를 두고 생성자에서 초기화한 후 개별 테스트 클래스에서 상속해 재사용
        • e.g. 통합 테스트 시 DB 커넥션 초기화
          • CustomerTests가 DB 커넥션이 있는 IntegrationTests를 상속받음
  • 단위 테스트 명명법
    • Best Practice
      • 표현력이 있는 간단하고 쉬운 영어 구문 (엄격하지 않게 표현의 자유 허용)
        • 장황한 표현 지양 e.g. considered X
        • 사실만 서술하고 소망이나 욕구 지양 e.g. should be X -> is O
        • 기초 영문법 지키기 e.g. 관사
      • 도메인에 익숙한 비개발자에게 설명하듯이 이름 짓기 (도메인 전문가, 비즈니스 분석가)
      • 동작으로 이름 짓기
        • 테스트 이름에 메서드 이름 넣지 말기
          • 예외: 유틸리티 코드는 메서드 이름 사용해도 괜찮 (비즈니스 로직 X)
        • 테스트 클래스 이름 지정: [클래스명]Tests
          • 동작 단위 검증의 진입점 역할
          • 해당 클래스만 검증한다는 것이 아님 -> 여러 클래스 걸쳐도 동작을 검증
      • _ 로 단어 구분 (가독성 향상)
      • e.g
        • Sum_of_two_numbers()
        • Delivery_with_a_past_date_is_invalid
    • 안티 패턴: [테스트 대상 메서드]_[시나리오]_[예상 결과]
      • 타인이 읽기 난해하고 구현 세부사항에 묶임
      • e.g.
        • Sum_TwoNumbers_ReturnsSum()
        • IsDeliveryValid_InvalidDate_ReturnsFalse()
  • 매개변수화된 테스트 리팩토링 하기 (Parameterized Test)
    • 유사한 테스트를 묶을 수 있는 기능 제공
      • 하나의 동작여러 테스트가 필요하고 복잡하면 테스트 수가 급증하므로 관리에 용이
    • 사용 지침
      • 입력 매개변수만으로 테스트케이스 판단이 가능하면, 하나의 테스트 메서드 사용
      • 매개변수만으로 판단이 어렵다면 긍정 테스트 케이스와 부정 테스트 케이스 나누기
      • 동작이 너무 복잡하면 매개변수화된 테스트 사용말고 모두 개별 테스트로 두기
    • e.g. 가장 빠른 배송일이 오늘부터 이틀 후가 되도록 작동하는 배송 기능
      • 4가지(어제, 오늘, 내일, 모레) 테스트가 필요하지만 유일한 차이점은 배송 날짜
      • Parameterized Test로 묶기
        • 하나의 테스트 메서드 두기
          • Can_detect_an_invalid_delivery_date()
          • [(-1, false), (0, false), (1, false), (2, true)]
        • 긍정 테스트부정 테스트 나누기 (boolean 매개변수 제거 효과)
          • 긍정: The_soonest_delivery_date_is_two_days_from_now()
            • 2
          • 부정: Detects_an_invalid_delivery_date()
            • [-1, 0, 1]
  • 검증문 라이브러리 사용하기
    • 쉬운 영어로 구성된 이야기 패턴으로 테스트 가독성 향상
    • 유일한 단점은 프로젝트에 의존성 추가하는 것
    • Assert.equal(30, result) -> result.Should().Be(30)

Reference

단위 테스트 (생산성과 품질을 위한 단위 테스트 원칙과 패턴)