Home > Software Engineering > Test > 단위 테스트 (Unit Testing) - 가치 있는 테스트 식별하기

단위 테스트 (Unit Testing) - 가치 있는 테스트 식별하기
Test

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

  • 좋은 단위 테스트의 4가지 특성
    • 회귀 방지 (=소프트웨어 버그 방지)
      • 중요 지표: 테스트로 실행되는 코드의 양, 코드 복잡도, 코드의 도메인 유의성
      • 복잡도와 도메인 유의성이 높은 코드에 대한 테스트가 많을수록 회귀 방지가 탁월
    • 리팩터링 내성
      • 테스트 실패없이 애플리케이션 코드 리펙토링 가능한지에 대한 척도
      • 중요지표: 거짓 양성 발생량 (적을수록 좋음)
      • 거짓 양성: 리팩토링 후 기능이 의도대로 작동해도 테스트가 실패하는 상황 (허위 경보)
        • 회귀 발생 시 조기 경고를 제공 X (잘못된 것이므로 개발자가 무시)
        • 리팩토링에 대한 능력과 의지 감소 (테스트 스위트에 대한 신뢰가 부족)
      • 거짓 양성의 원인: SUT의 구현 세부 사항과 결합된 테스트 (분리 필요)
      • 해결책: 테스트에서 구현 세부사항이 아닌 최종 결과를검증하기
        • 결합도를 낮추면 리팩토링 내성 상승
        • 거짓 양성 발생량이 크게 감소
      • 거짓 양성에 대한 올바른 대응은 테스트 스위트의 안정성을 높이는 것
    • 빠른 피드백
      • 중요 지표: 테스트 실행 속도
      • 빠른 테스트버그 수정 비용이 대폭 감소 (더 많은 테스트를 자주 실행할 수 있음)
      • 느린 테스트는 버그 수정 비용이 상승 (뒤늦게 버그를 발견, 시간 낭비)
    • 유지 보수성
      • 중요 지표: 유지비 (테스트 이해 난이도, 테스트 실행 난이도)
      • 테스트 이해 난이도: 테스트의 크기를 의미 (코드라인이 적을수록 읽기 쉬움)
      • 테스트 실행 난이도: 테스트가 프로세스 외부 종속성으로 작동하면, 의존성 운영 비용 고려 필요
  • 회귀 방지 & 리팩터링 내성 간 관계
    unit_testing_error_classification
    • 올바른 추론: 올바르게 작동해 테스트가 통과 & 기능이 고장나 테스트가 실패
    • 회귀 방지와 리팩터링 내성은 테스트 스위트의 정확도 극대화를 목표로하는 특성
      • 테스트 정확도 = 신호(발견된 버그 수) / 소음(허위 경보 발생 수)
      • 거짓 양성, 거짓 음성 발생 확률 줄이기 -> 테스트 정확도 상승
        • 회귀 방지가 훌륭한 테스트는 거짓 음성 수를 최소화
        • 리팩터링 내성이 훌륭한 테스트는 거짓 양성 수를 최소화
    • 중대형 프로젝트는 거짓 음성과 거짓 양성에 똑같이 주의를 기울여야 함
      • 프로젝트 초반은 리팩토링이 많지 않아 거짓 양성은 무시할만 함
      • 프로젝트 중후반으로 갈수록 리팩토링이 중요한데, 거짓 양성이 잦으면 문제가 커짐
  • 테스트 전략
    • 테스트의 가치 = 회귀 방지 X 리팩터링 내성 X 빠른 피드백 X 유지 보수성
      • 하나라도 0이면 전체가 0 (모두 1도 불가능)
      • 유지보수성은 다른 특성과 독립적 (엔드 투 엔드 테스트에서만 회귀 방지와 연관됨)
      • 회귀 방지, 리팩토링 내성, 빠른 피드백상호 배타적 -> 하나를 희생해야 둘이 최대 가능
        • 회귀 방지 희생 -> 너무 간단한 테스트
        • 리팩토링 내성 희생 -> 구현에 결합된 깨지기 쉬운 테스트
        • 빠른 피드백 희생 -> 엔드 투 엔드 테스트
      • 각 요소높은 임계치를 두고 이를 충족하는 테스트만 테스트 스위트에 남기기
        • 소수의 매우 가치 있는 테스트가 프로젝트의 지속적 성장에 효과적
    • 전략적 절충
      unit_testing_ideal_strategy
      • 리팩토링 내성최대화 필요 (리팩토링 내성은 대부분 있거나 없거나 둘 중 하나이므로…)
      • 회귀 방지빠른 피드백 사이에서 조절하자
    • 테스트 피라미드 관점 전략
      unit_testing_test_pyramid
      • 테스트 유형 간 비율피라미드 형태를 유지할 것 (팀, 프로젝트 마다 비율 차이 O)
      • 모든 테스트 계층은 가능한 거짓 양성 최소화 목표 (리팩토링 내성 최대화)
      • 피라미드 내 테스트 유형에 따라 회귀 방지와 빠른 피드백 사이에서 선택
        • 엔드 투 엔드 테스트매우 중요한 기능에만 적용
          • 빠른 피드백과 유지보수성 결여 -> 숫자가 가장 적은 이유
      • 예외 케이스
        • 복잡도가 거의 없는 기본 CRUD 프로젝트
          • 통합 테스트 수가 단위 테스트 수와 같거나 많고 엔드 투 엔드 테스트가 없음
          • 단위 테스트는 복잡도 없는 환경에서 유용성 감소
          • 통합 테스트는 여전히 시스템 간 통합 동작 확인에 가치 있음
        • 프로세스 외부 의존성 하나만 연결하는 API (e.g. DB)
          • 엔드 투 엔드 테스트를 더 많이 두는 것이 적합 (환경 상 통합 테스트와 구분 불가)
          • 속도가 상당히 빠를 것이고 유지비도 적음
    • 블랙 박스 테스트 & 화이트 박스 테스트 전략
      • 둘을 조합하되 테스트 작성블랙 박스 테스트 선택하자
        • 화이트 박스 테스트는 구현에 결합 -> 리팩토링 내성 포기할 수는 없음!
      • 테스트 분석화이트 박스 테스트 사용! (e.g. 코드 커버리지 도구)

목과 테스트 취약성

  • 테스트 대역(test double)
    • 모든 유형의 비운영용 가짜 의존성
    • e.g. 더미, 스텁, 스파이, 목, 페이크
    • 사용 의도에 따라 스텁으로 나뉨 (Mock 프레임워크로 똑같이 인스턴스를 생성)
      • 목(mock) - 목, 스파이
        • 외부로 나가는 상호 작용을 모방하고 검사
        • 상태 변경을 위해 의존성을 호출하는 것 (사이드 이펙트 O)
          • e.g. SMTP 서버로 이메일 발송 작업
        • CQS 관점에서 명령을 대체 (보통 반환값 X)
        • 구현
          • 목: 목 프레임워크의 도움 받아 생성
          • 스파이: 수동으로 작성한 목
      • 스텁(stub) - 스텁, 더미, 페이크
        • 내부로 들어오는 상호 작용을 모방만 함
        • 입력 데이터를 얻기 위해 의존성을 호출하는 것 (사이드 이펙트 X)
          • e.g. DB로 부터 데이터 검색
        • CQS 관점에서 조회를 대체 (보통 반환값 O)
        • 구현
          • 더미: 단순 하드코딩 값 (null, 가짜 문자열)
          • 스텁: 더 정교하게 시나리오마다 다른 값 반환하는 의존성
          • 페이크: 스텁과 같지만, 아직 존재하지 않는 의존성을 대체하고자 구현
  • 무분별한 목 사용 지양하기 (feat. 리팩토링 내성 감소)
    • API를 잘 설계하면 단위테스트도 자동으로 좋아짐
      • 식별할 수 있는 동작만 공개하고 구현 세부사항을 비공개함으로써 리팩토링 내성 상승
    • 스텁의 상호작용은 검증하지 말자! (안티패턴)
      • 입력을 제공할 뿐이지 SUT의 최종 결과가 아님
      • 스텁의 상호작용 검증은 내부 구현 세부사항과 결합(overspecifiation) -> 리팩토링 내성 감소
        • 목의 상호작용 검증은 최종 결과 검증
      • e.g.
        • mock.Verify(x => x.SendGreetingsEmail("user@email.com")) -> O
        • stub.Verify(x => x.GetNumberOfUsers(), Times.Once) -> X
    • 사이드 이펙트가 있는 시스템 간 통신으로 테스트하자! (외부 애플리케이션 통신)
      • 클래스 간 통신에도 목을 쓰는 것은 런던파의 단점
      • 가치 있는 목 테스트
        • var mock = new Mock<IEmailGateway>()
        • mock.Verify(x => x.SendReceipt("..@x.com","egg",5), Times.Once)
        • 클라이언트 목표 달성에 도움이 되는 연산
      • 잘못된 목 테스트
        • var storeMock = new Mock<IStore>()
        • storeMock.Verify(x => x.RemoveInventory("egg", 5), Time.Once)
        • 시스템 내 통신(도메인 간 통신)은 클라이언트 목표로 가는 중간 단계 (구현 세부 사항)
    • 애플리케이션을 통해서만 접근할 수 있는 프로세스 외부 의존성목 대체 X
      • 모든 공유 의존성을 목으로 대체하는 것은 고전파의 단점
      • 외부 클라이언트 관점에서 접근 불가한 시스템은 구현 세부 사항
      • e.g. 데이터베이스

식별할 수 있는 동작과 공개 API

모든 제품 코드는 2차원으로 분류할 수 있다.

  • 공개 API (public) & 비공개 API (private)
  • 식별할 수 있는 동작과 구현 세부 사항

식별할 수 있는 동작클라이언트가 목표를 달성하는데 도움이 되는 연산(Operation)과 상태(State)를 최소한으로 노출한다. (연산은 계산 수행 혹은 사이드 이펙트를 초래하는 메서드를 의미)
구현 세부사항은 두 가지 중 어떤 것도 하지 않는다.

잘 설계된 API식별할 수 있는 동작은 공개 API와 일치하고, 모든 구현 세부 사항은 비공개 API 뒤에 숨어 있다. 만일, 식별할 수 있는 동작을 달성하고자 할 때 클래스에서 호출해야 하는 연산 수가 1보다 크면 해당 클래스는 구현 세부 사항을 유출했을 가능성이 크다.
또한, API를 잘 설계하면 단위테스트도 자동으로 좋아진다. (리팩토링 내성 상승)

장기적으로 캡슐화증가하는 복잡성에 대응하고 소프트웨어의 지속적 성장을 가능하게 하는 유일한 방법이다.

헥사고날 아키텍처(Hexagonal Architecture, Alistair Cockburn)
unit_testing_hexagonal_architecture
- 애플리케이션 서비스 + 도메인
- 도메인 계층 (도메인 지식)
- 비즈니스 로직 책임
- 애플리케이션 서비스 계층 (유스케이스)
- 외부 환경과의 통신을 조정 (SMTP, 메시지 버스, 서드파티…)
- 잘 설계된 API는 프랙탈 특성 존재
- 서로 다른 계층의 테스트도 동일한 동작을 서로 다른 수준으로 검증하는 프랙탈 특성 존재
- 목표(유스 케이스) - 하위 목표 - …


Reference

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