Application

책 <단위 테스트>

팅리엔 2024. 8. 26. 00:35

 

 

단위 테스트

단위 테스트에 대한 오해를 바로잡고, 올바른 단위 테스트에 대한 원칙, 테스트를 작성하는 스타일과 효과적인 테스트를 위한 소프트웨어 아키텍처를 이해할 수 있다. 또한 단위 테스트를 통합

www.aladin.co.kr

 

 

 

 

1. 단위 테스트의 목표

  • 코드 커버리지 지표(code coverage, 라인 수), 분기 커버리지 지표(branch coverage, if 또는 switch문 같은 제어 구조 수)
  • 테스트가 많으면 많을수록 좋은 게 아니다. 코드는 자산이 아니라 책임이고 부채다. 가능한 한 적은 코드로 문제를 해결한다. 가치 있는 테스트만 남기고 나머지는 모두 제거하라.
  • 100% 커버리지라고 해서 양질의 테스트를, 시스템의 모든 가능한 결과를 검증함을 보장하지 않는다. 커버리지 숫자는 쉽게 장난칠 수 있다. 커버리지 숫자가 낮으면 문제 징후라 할 수 있지만, 높은 숫자도 별 의미는 없다.
  • 성공적인 테스트 스위트
    • 개발 주기에 통합돼 있다!
    • 코드베이스에서 가장 중요한 부분(비즈니스 로직 - 도메인 모델)만을 대상으로 한다!
    • 최소한의 유지비로 최대의 가치를 끌어낸다!

 

 

 

3. 단위 테스트 구조

  • AAA 패턴을 따른다. 준비arrange, 실행act, 검증assert (given - when - then)
  • 한 테스트는 하나의 동작만 검증한다. 준비, 실행, 검증 구절이 여러 개라면 테스트가 여러 동작을 한 번에 검증한다는 표시다. 통합 테스트는 느릴 수 있으니 여러 개의 검증을 묶는 게 괜찮을 수도 있다.
  • 테스트 내 if문을 피한다. if문은 한 번에 너무 많은 것을 검증한다는 표시다.
  • 실행 구절이 한 줄 이상이면 API에 문제가 있다는 뜻이다. 클라이언트가 이러한 작업을 같이 수행해야 함을 의미한다. 캡슐화 하자. 
  • 단위 테스트의 단위는 동작의 단위지 코드의 단위가 아니다. 단일 동작 단위는 여러 결과를 낼 수 있으며, 하나의 테스트로 그 모든 결과를 평가하는 것이 좋다. 
  • 일반적으로 준비 구절이 가장 큰데, 너무 크다면 비공개 메서드나 별도의 팩토리 클래스로 도출하는 것이 좋다.
  • 테스트 간에 공유 상태를 두지 말아야 한다. 준비 구절을 공유하지 않고 각각 사용하도록 한다.
  • 준비 구절을 생성자로 추출하는 것은 테스트 가독성을 떨어뜨림을 알고 있어야 한다. 
  • 가장 유명한 명명법인 [테스트 대상 메서드]_[시나리오]_[예상 결과]은 좋지 않다. 무엇을 테스트 하는지 이해하기 위해 에너지를 써야 한다. Sum_TwoNumbers_ReturnsSum보다 Sum_of_two_numbers가 이해하기 쉽다. 
  • 엄격한 테스트 명명 정책을 시행하지 말라. 비개발자에게 시나리오를 설명하듯이 이름을 지정한다. 테스트 이름은 언더스코어로 단어를 구분한다. 테스트 대상 메서드 이름을 넣지 말자.
  • parameterized test로 유사한 테스트 케이스를 묶을 수 있다. 단점은 테스트 이름이 더 포괄적이 되어 읽기가 어려워진다. 긍정적 케이스와 부정적 케이스를 각각의 테스트 메서드로 나누자. 테스트 케이스만으로도 이해할 수 있다면 하나의 메서드로 두어도 좋다.
  • 검증문 라이브러리를 사용하면 쉬운 영어를 읽듯이 가독성을 향상시킬 수 있다. 

 

 

 

4. 좋은 단위 테스트의 4대 요소

  • 회귀(버그) 방지
    • "얼마나 버그를 잘 찾아내는가?"
    • 일반적으로 테스트 중에 실행되는 코드의 양이 많을수록 버그가 나타날 가능성이 높다.
    • 복잡한 비즈니스 로직을 나타내는 코드가 보일러플레이트 코드보다 훨씬 더 중요하다.
    • 당신의 코드 외(라이브러리, 프레임워크, 외부 시스템)도 테스트 범주에 포함시켜 검증한다.
  • 리팩터링 내성
    • 리팩터링 했더니 테스트가 깨진다? 좋은 테스트가 아니다. 이런 테스트를 거짓 양성(테스트는 실패했지만 기능은 의도한대로 작동)이라고 한다. 거짓 양성 테스트는 신뢰가 부족해지고, 비활성화 하게 되고, 신경을 쓰지 않게 된다.
    • 테스트 대상의 구현 세부 사항을 테스트 하지 말라. 사용자의 관점에서 테스트 대상의 구현이 아닌 최종 결과를 검증해라. '어떻게'가 아니라 '무엇'에 중점을 둬야 한다.
  • 빠른 피드백
    • 테스트 속도가 빠를수록 더 많은 테스트를 더 자주 실행할 수 있다.
  • 유지 보수성
    • 테스트가 얼마나 이해하기 어려운가? 테스트는 코드 라인이 적을수록 더 읽기 쉽고 변경하기 쉽다. 
    • 테스트가 얼마나 실행하기 어려운가? 테스트가 외부 종속되어 있다면 외부 의존성을 상시 운영하는 데 시간을 들여야 한다.
  • 네 가지를 모두 만족하는 테스트는 없다. 회귀 방지, 리팩터링 내성, 빠른 피드백 셋 중 하나는 희생해야 한다. 그렇다고 어느 하나 0점이라면 그 테스트는 가치가 없다. 무엇을 희생할 것인가? 실제로는 리팩터링 내성을 최대한 많이 갖는 것을 목표로 해야 한다.
  • 엔드 투 엔드 테스트는 회귀 방지를 선호하는 데 반해, 단위 테스트는 빠른 피드백을 선호한다.

 

 

 

5. 목과 테스트 취약성

  • 목 (목, 스파이): 외부로 나가는 상호작용을 모방하고 검사 (e.g. 이메일 서버)
  • 스텁 (스텁, 더미, 페이크): 내부로 들어오는 상호작용을 모방 (e.g. 데이터베이스)
  • 스텁으로 상호작용을 검증하지 말라. SUT(테스트 대상 시스템)에서 스텁으로의 호출은 최종 결과를 산출하기 위한 수단일 뿐이다. 우리가 테스트해야 할 건 구현 세부 사항이 아니고 최종 결과이다. 다음 테스트는 안티 패턴이다. 
public void create_a_report() {
    var stub = new Mock<IDatabase>();
    stub.Setup(x => x.GetNumberOfUsers()).Returns(10);
    
    var sut = new Controller(stub.Object);
    Report report = sut.CreateReport();
    
    Assert.Equal(10, report.NumberOfUsers);
    stub.Verify( // No! 스텁과의 상호 작용을 검증하고 있다.
    	x => x.GetNumberOfUsers();
        Times.Once);
}
  • 목과 스텁의 개념은 CQRS와 관련이 있다. CQRS에 따르면 모든 메서드는 명령이거나 조회여야 하며, 둘을 혼용해서는 안 된다. 항상 따를 수 있는 건 아니지만 그래도 가능하면 따르는 게 좋다. 명령을 대체하는 테스트 대역이 목이고, 조회를 대체하는 테스트 대역이 스텁이다.
  • 모든 제품 코드는 2차원으로 분류할 수 있다.
    • 메서드는 공개 API이거나, 비공개 API이다.
    • 코드는 내부 구현 세부 사항이거나, 시스템의 식별할 수 있는 동작이다.
      시스템의 식별할 수 있는 동작이려면 연산(operation)이나 상태(state)를 노출한다.
    • 이상적으로 시스템의 공개 API는 식별할 수 있는 동작과 일치해야 하며, 모든 구현 세부 사항은 클라이언트 눈에 보이지 않아야 한다. 그러나 종종 시스템의 공개 API가 구현 세부 사항을 노출하기 시작한다. (단일 목표를 달성하고자 호출해야 하는 연산의 수가 1보다 크면 구현 세부 사항을 유출하고 있을 가능성이 있다.)
  • 육각형 아키텍처 hexagonal architecture
    • 도메인 계층과 애플리케이션 서비스 계층 간의 관심사 분리: 비즈니스 로직 / 비즈니스 유즈케이스
    • 애플리케이션 내부 통신: 애플리케이션 서비스 계층에서 도메인 계층으로 단방향 의존성 흐름
    • 애플리케이션 간의 통신: 외부 애플리케이션은 애플리케이션 서비스 계층의 인터페이스를 통해 연결된다. 
    • 육각형의 각 계층은 식별할 수 있는 동작을 나타내며 각각의 구현 세부 사항이 있다. 
    • 서로 다른 계층의 테스트는 동일한 동작을 서로 다른 수준으로 검증한다. 
      식별할 수 있는 동작은 바깥 계층에서 안쪽으로 흐른다. 

  • 시스템 내부 통신(애플리케이션 내 클래스 간의 통신)은 구현 세부 사항이고, 시스템 간 통신(다른 애플리케이션과의 통신)은 그렇지 않다.
    • 목을 사용하면 시스템과 외부 애플리케이션 간의 통신 패턴을 확인할 때 좋다. 반대로 시스템 내 클래스 간의 통신을 검증하는 데 사용하면 테스트가 구현 세부 사항과 결합된다.
  • 고전파와 런던파
    • 런던파는 불변 의존성을 제외한 모든 의존성에 목 사용을 권장하며 시스템 내 통신과 시스템 간 통신을 구분하지 않는다. (그래서 저자는 런던파보다 고전파를 더 선호한다. 그러나 고전파도 목 사용을 지나치게 장려한다고 한다.)
    • 모든 프로세스 외부 의존성(e.g. 데이터베이스, 메시지 버스, SMTP 서비스 등)을 목으로 해야하는 것은 아니다.
      프로세스 외부 의존성이 애플리케이션을 통해서만 접근할 수 있으면 이러한 의존성과의 통신은 시스템에서 식별할 수 있는 동작이 아니라 애플리케이션의 일부로 작용한다.
    • 시스템 간 통신의 부작용이 외부 환경에서 보일 때만 목을 사용하는 것이 타당하다. 

 

 

 

6. 단위 테스트 스타일

  • 세 가지 테스트 스타일: 출력 기반, 상태 기반, 통신 기반 (왼쪽으로 갈수록 좋은 품질의 테스트다)
  • 출력 기반 테스트
    • SUT에 입력을 넣고 반환값을 검증한다. 
    • 순수 함수 방식의 코드에만 적용된다. 
  • 상태 기반 테스트
    • 작업이 완료된 후 시스템의 상태를 확인한다. 
    • 상태라는 것은 SUT나 협력자 중 하나, 또는 데이터 베이스나 파일 시스템 등과 같은 외부 의존성의 상태를 의미할 수 있다. 
  • 통신 기반 테스트
    • 목을 사용해 SUT과 협력자 간의 통신을 검증한다.
  • 함수형 프로그래밍이란? 호출 횟수에 상관없이 주어진 입력에 대해 동일한 출력을 생성한다.
    • 함수형 프로그래밍의 목표는 부작용을 완전히 제거하는 것이 아니라 비즈니스 로직을 처리하는 코드와 부작용을 일으키는 코드를 분리하는 것이다.
    • 부작용을 비즈니스 연산 끝으로 몰아서 비즈니스 로직을 부작용과 분리한다. (결정을 내리는 코드 vs 해당 결정에 따라 작용하는 코드 like 데이터베이스 변경, 메시지 전송)
    • 함수형 아키텍처는 부작용을 다루는 코드를 최소화하면서 순수 함수(불변) 방식으로 작성한 코드의 양을 극대화한다. 일단 객체가 생성되면 그 상태는 바꿀 수 없다.
    • 출력 기반 테스트로 결정을 내리는 코드를 두루 테스트 하고, 해당 결정에 따라 작용하는 코드는 훨씬 더 적은 수의 통합 테스트에 맡긴다.
    • 모든 코드베이스를 함수형으로 전환할 수는 없다. 성능과 코드 유지보수성 사이의 절충이다. 코드베이스가 단순하거나 그렇게 중요하지 않다면 함수형 아키텍처에 필요한 초기 투자가 낭비일 수 있다.

 

 

 

7. 테스트를 위한 리팩터링

  • 기반 코드를 리팩터링하지 않고서는 테스트 스위트를 크게 개선할 수 없다. 
  • 복잡한 코드와 도메인 유의성을 갖는 코드가 단위 테스트에서 가장 이롭다. 두 요소는 독립적이다.
    • 코드 복잡도는 코드 내 의사 결정(분기) 지점 수로 정의한다. 
    • 도메인 유의성은 코드가 프로젝트의 문제 도메인에 대해 얼마나 의미 있는지를 나타낸다.
  • 간단한 코드는 테스트할 필요가 전혀 없다.
  • 컨트롤러의 경우, 포괄적인 통합 테스트의 일부로서 간단히 테스트해야 한다.
  • 지나치게 복잡한 코드는 단위 테스트가 어렵겠지만 테스트 없이 내버려두는 것은 너무 위험하다. 이런 코드를 알고리즘과 컨트롤러라는 두 부분으로 나누게 리팩터링한다.
    1. 암시적 의존성을 명시적으로 만든다. 인터페이스를 두고 주입 받는다.
    2. 서비스 계층을 도입해서 도메인 모델이 외부 시스템과 직접 통신하는 문제를 해결한다. 
    3. 서비스의 복잡도를 낮춘다. 도메인 모델을 인스턴스화 하는 팩토리 클래스를 사용한다. 

 

 

 

8. 통합 테스트를 하는 이유

  • 단위 테스트에만 전적으로 의존하면 시스템이 전체적으로 잘 작동하는지 확신할 수 없다. 
  • 프로세스 외부 의존성 운영이 필요하고 관련된 협력자가 많아서 테스트가 비대해지므로 통합 테스트는 유지 비용이 많이 들어간다. 
  • 단위 테스트로 가능한 한 많이 비즈니스 시나리오의 예외 상황을 확인하고, 통합 테스트는 주요 흐름(happy path)과 단위 테스트가 다루지 못하는 예외 상황(edge case)를 다룬다.
  • 중요한 통합 테스트가 비즈니스 시나리오당 하나 또는 두 개 있으면 시스템 전체의 정확도를 보장할 수 있다.
  • 단위 테스트 > 통합 테스트 > 엔드투엔드 테스트 순으로 많아야 한다. 간단한 프로젝트라면 단위 테스트와 통합 테스트 비율이 1:1까지 될 수 있다.
  • 통합 테스트에서 프로세스 외부 의존성과의 상호 작용을 모두 확인하려면 가장 긴 주요 흐름을 선택한다. 
  • 어떤 외부 의존성을 직접 테스트해야 하는가? 
    • 관리 의존성 (전체를 제어할 수 있는, 외부에서 볼 수 없는) e.g. 데이터베이스 : 실제 인스턴스를 사용한다.
    • 비관리 의존성 (전체를 제어할 수 없는) e.g. SMTP 서버 : 목으로 대체한다.
    • 때로는 관리 의존성과 비관리 의존성 모두의 특성을 나타내는 외부 의존성(e.g. 다른 서버가 접근할 수 있는 데이터베이스)는 비관리 의존성으로 간주하고 해당 부분을 목으로 대체한다.
    • 관리 의존성을 목으로 대체하면 통합 테스트는 기존 단위 테스트 세트와 다를 바 없다. 관리 의존성을 실제로 사용할 수 없는 경우에는 통합 테스트를 하지 말고 단위 테스트에만 집중한다.
  • 구현이 하나뿐인 인터페이스는 추상화가 아니다. YAGNI 원칙을 위배한다. 구현이 하나뿐인 인터페이스를 사용하기에 타탕한 이유는 목을 사용하기 위한 것뿐이다. 비관리 의존성에만 사용하고, 관리 의존성은 구체 클래스를 사용한다.
  • 간접 계층이 너무 많으면 코드를 추론하기가 어려워진다. 대부분의 백엔드 시스템은 도메인 모델, 애플리케이션 서비스 계층(컨트롤러), 인프라 계층, 이 세 가지 계층만 있다. 
  • 순환 의존성이 있으면 코드를 이해하기 힘들다. 값 객체를 도입해 순환을 없앤다.
  • 진행 작업 이해를 위한 로깅을 가끔 사용하라. 이상적으로는 처리되지 않은 예외에 대해서만 사용해야 한다.
  • 항상 모든 의존성을 생성자 또는 메서드 인수를 통해 명시적으로 주입한다.

 

 

 

9. 목 처리에 대한 모범 사례

  • 비관리 의존성에만 목 적용하기
  • 시스템 끝에서 의존성에 대해 상호 작용 검증하기 
  • 통합 테스트에서만(컨트롤러를 테스트할 때만) 목을 사용하고 단위 테스트에서는 하지 않기 
  • 테스트당 목이 하나일 필요는 없음 (하나의 동작을 테스트하는 것)
  • 호출 횟수 검증하기
    • 예상하는 호출이 있는가?
    • 예상치 못한 호출은 없는가? 
  • 보유 타입만 목으로 처리하기 
    서드파티 라이브러리 위에 항상 어댑터를 작성하고 기본 타입 대신 해당 어댑터를 목으로 처리해야 한다. 

 

 

 

10. 데이터베이스 테스트

  • 데이터베이스 스키마를 소스코드와 같이 형상관리시스템에 저장하라. 테이블, 뷰, 인덱스, 저장 프로시저와 데이터베이스 구성 방식에 대한 청사진이 되는 기타 모든 항목 등이 데이터베이스 스키마에 해당한다. 
  • 참조 데이터도 데이터베이스 스키마에 해당한다. 이는 애플리케이션이 제대로 작동하도록 미리 채워져야 하는 데이터다. 애플리케이션에서 해당 데이터를 수정할 수 있으면 일반 데이터이고, 그렇지 않으면 참조 데이터다.
  • 개발자마다 데이터베이스 인스턴스를 별도로 두게 하라. 더 좋은 방법은 개발자 장비에 인스턴스를 호스팅하는 것인데, 이러면 테스트 실행 속도를 극대화할 수 있다.
  • 준비, 실행, 검증 구절에 각각 고유의 트랜잭션이 있어야 한다. 
  • 공유 데이터베이스를 사용하면 통합 테스트끼리 서로 분리할 수가 없다. 이를 해결하려면, 통합 테스트는 순차적으로 실행하라. 테스트는 데이터베이스 상태를 원하는 조건으로 만들어야 한다. 테스트를 병렬 실행할 필요가 없다.
  • 테스트 시작 시점에 남은 데이터를 정리하라. 일관성 있는 동작을 위함이며, 이러면 별도의 종료 단계를 둘 필요가 없다. (그 외 방법: 데이터베이스 백업 복원하기 - 오래 걸린다 / 테스트 종료 시점에 데이터 정리하기 - 건너뛸 위험이 있다 / 커밋하지 않기 - 운영 환경과 다른 설정이다)
  • 인메모리 데이터베이스는 사용하지 말라. 테스트에서도 운영 환경과 같이 동일한 데이터베이스를 사용하라. 버전은 달라도 괜찮지만 공급업체는 같아야 한다. 
  • 읽기 테스트를 해야 하는가? 쓰기를 철처히 테스트하는 것이 중요하다. 가장 복잡하거나 중요한 읽기 작업만 테스트하고 나머지는 무시하라.
  • 리포지터리는 직접 테스트하지 말고 포괄적인 통합 테스트 스위트로 취급하라. 리포지터리 테스트는 이득이 적은 데 반해 유지비가 너무 높다.

 

 

 

11. 단위 테스트 안티 패턴

  • 단위 테스트를 가능하게 하고자 비공개 메서드를 노출하게 되면 테스트가 구현에 결합된다. 비공개 메서드를 직접 테스트하는 대신, 식별할 수 있는 동작으로서 간접적으로 테스트하라.
  • 비공개 메서드가 너무 복잡해서 공개 API로 테스트할 수 없다면, 추상화가 덜 되었다는 뜻이다. 해당 추상화를 별도 클래스로 추출하라.
  • 단위 테스트를 가능하게 하고자 비공개 상태를 노출하지 말라. 테스트는 제품 코드와 같은 방식으로 테스트 대상 시스템과 상호 작용해야 한다. 
  • 테스트를 작성할 때 특정 구현을 암시하지 말라. 도메인 지식을 테스트에 유출하지 않도록 하라. (e.g. sum을 테스트 하려고 (1, 2)를 전달하고 기대값으로 '1+2'을 주는 것이다. 이건 구현을 그대로 복붙한 것이다.)
  • 테스트에만 필요한 제품 코드를 추가해 코드를 오염시키지 말라. 
  • 기능을 지키기 위해 구체 클래스를 목으로 처리해야 한다면 단일책임원칙을 위반한 결과다. 해당 클래스를 도메인 로직이 있는 클래스와 프로세스 외부 의존성과 통신하는 클래스로 분리하라.

'Application' 카테고리의 다른 글

Validation  (0) 2024.10.16
Http client  (0) 2024.09.24
스프링 @TransactionalEventListener  (0) 2024.03.02
Electron 일렉트론 Main, Renderer Process 이해하기  (0) 2023.03.08
동시성 이슈 해결하기  (0) 2023.02.26