선생님, 개발을 잘하고 싶어요.

단위 테스트: 생산성과 품질을 위한 단위 테스트 원칙과 패턴 - 1부 더 큰 그림 본문

일상/책 리뷰

단위 테스트: 생산성과 품질을 위한 단위 테스트 원칙과 패턴 - 1부 더 큰 그림

알고싶은 승민 2022. 7. 18. 21:40

왜 단위 테스트를 해야할까?

모든 소프트웨어 개발 방법론의 목표는 무엇일까? 확장성 있고 지속 가능한 소프트웨어를 만드는 것이다. 단위 테스트를 해야하는 이유도 다르지 않다.

그렇다면 단위 테스트만 작성하면 지속 가능한 소프트웨어가 되나? 단위 테스트를 운영하는 프로젝트는 새로운 기능을 추가해도 회귀 문제가 발생하지 않나?

당연히 그렇지 않다. 단위 테스트는 지속 가능한 소프트웨어를 만들기 하나의 도구일 뿐이다. 우리는 이 도구를 잘 사용하는 법을 배워야 한다.

단위 테스트의 목적이 지속가능성에 있는 만큼 테스트를 작성할 때 불필요한 코드를 버려야한다. 단순하게 100% 코드 커버리지를 달성한다고 좋은 테스트가 아니라는 뜻이다.

좋은 테스트라고 하면 다음 세가지 속성을 만족해야 한다.

  1. 개발 주기에 통합
  2. 코드베이스에서 중요한 부분만 대상
  3. 최소의 유지비로 최대의 가치 끌어내기

개발 주기에 통합돼 있다는 것은 아무리 사소한 변경이라도 쉽게 모든 테스트를 실행하고 회귀 버그를 방지할 수 있음을 의미한다.

코드베이스에 중요한 부분만 대상으로 한다는 것은 핵심 비즈니스 로직을 테스트 가능한 형태로 분리, 격리한 채 개발하고 주요 비즈니스 로직 테스트에만 집중하라는 말이다.

최소 유지비로 최대 가치를 끌어내라는 것은 테스트의 가치를 측정하고 가치 있는 테스트만 남기는 것을 의미한다. 그를 위해서 좋은 테스트를 식별하는 방법을 알아야하고 좋은 테스트를 작성하는 방법을 알아야한다.

🌟 단위 테스트를 할 수 없는 코드는 품질이 나쁘다. 하지만 단위 테스트를 할 수 있다고 좋은 코드라는 말은 아니다.

무엇이 단위 테스트일까?

단위 테스트를 나타내는 가장 중요한 세 가지 속성이 있다.

  1. 작은 코드 조각(단위)을 검증하고,
  2. 빠르게 수행하고,
  3. 격리된 방식으로 처리하는 자동화된 테스트

특히 마지막 속성인 "격리"의 해석에 따라서 고전파와 런던파로 나눠서 분석해 볼 수 있다. 하나씩 살펴보자.

용어 정리

  • 테스트 대상 시스템 (SUT, System Under Test)
  • 테스트 대역 (Test Double)
  • 목 (Mock): SUT와 협력자 간 **상호 작용을 검사**할 수 있는 특별한 테스트 대역
  • 협력자 (Collaborator): 공유하거나 변경 가능한 의존성
  • 값, 값 객체: 불변 의존성이라고도 할 수 있다.

런던파

런던파에게 격리는 테스트 대상 시스템을 협력자에게서 격리를 일컫는다. 의존성을 포함한 SUT 단위 테스트에서 모든 의존성을 테스트 대역으로 대체해서 의존성과 별개로 단위 테스트를 수행할 수 있다. 다른 의존성이 모두 격리되므로 테스트가 실패한다면 SUT가 고장났다는 사실을 확실하게 알 수 있다. 그리고 테스트 대역을 사용하기 때문에 복잡한 객체 그래프를 만들지 않아도 된다.

다시 말해서, 런던 스타일은 테스트 대역(목)으로 테스트 대상 코드 조각을 분리해서 격리한다.

고전파

고전파에서 격리는 단위 테스트간 격리를 의미한다. SUT 단위 테스트에 필요한 의존성, 협력자로 운영용 인스턴스를 사용한다. 단위 테스트 하나에서 SUT와 협력자 모두를 효과적으로 검증한다. 즉 SUT 내부에는 버그가 없어도 협력자 내부의 버그가 있으면 해당 단위 테스트는 실패할 수 있다.

즉 테스트에서 각 클래스는 서로 격리돼 있지 않다. 하지만 여전히 고전파에게 격리는 각 클래스간 격리가 아니고 단위 테스트 간 격리기 때문에 괜찮다.

테스트 간 공유 상태를 일으키는 의존성에 대해서는 어쩔 수 없이 테스트 대역을 활용하기도 한다. 데이터베이스나 파일 시스템과 같이 외부 시스템을 그 예로 들 수 있다. 이러한 공유 의존성을 가진 테스트는 단위 테스트 영역에서 통합 테스트 영역으로 넘어간다.

저자의 의견, 고전파의 장점

런던파는 클래스를 단위로 간주하고 단위 테스트를 작성한다 하지만 오히려 동작 단위, 즉 문제 영역에 의미가 있는 것, 비즈니스 담당자에게 유용한 것을 검증해야한다. 동작 단위를 구현하는 데 클래스가 얼마나 필요한지는 상관없다. 클래스는 그저 기능을 제공하기 위한 세부 구현 사항에 지나지 않는다.

🌟 테스트가 단일 동작 단위를 검증하는 한 좋은 테스트다.

런던파는 객체 그래프를 대체하기 때문에 의존성 그래프가 복잡한 경우에도 쉽게 테스트할 수 있다. 하지만 의존성 그래프가 복잡한 클래스를 갖지 않는 데 집중해야 한다. 대게 이 경우 코드 설계의 문제일 수 있다. 이 경우 런던파의 접근은 코드 설계 문제를 감추기만 할 뿐, 해결하지 못한다.

런던파는 테스트에 버그가 발생하면 정확히 해당 SUT에 버그가 발생했다는 걸 알 수 있다. 하지만 단위 테스트가 개발 주기에 통합되어 작은 단위로 실행되기 때문에 이는 큰 장점이 되지 않는다. 고전파 식 접근에서도 정확히 이전 단계 코드 변경에서 버그가 발생했다는 걸 알 수 있기 때문이다.

🌟 그리고 테스트 스위트 전체에 걸쳐 계단식으로 실패하는 건 가치가 있다. 방금 고장 낸 코드 조각의 가치를 알 수 있기 때문이다. 이는 항상 명심해야 할 유용한 정보다.

런던파는 과도한 명세 문제를 겪을 가능성이 높다. 테스트가 SUT 구현 세부 사항에 결합되는 것이다.

통합테스트

통합 테스트는 단위 테스트의 세 가지 조건 중 하나를 충족하지 않는 테스트다.

둘 이상의 동작 단위를 검증하는 테스트 또한 통합 테스트다.

통합 테스트는 통상적으로 프로세스 외부 의존성을 필요로 하므로 테스트는 하나뿐만 아니라 세 가지 모두를 충족시키지 못한다.

실제로 적용하려고 하는데 전형적인 단위테스트의 구조는?

Given-When-Then 패턴으로 접근해보자.

각 테스트를 준비, 실행, 검증 세 부분으로 구분한다. 준비 구절에서는 SUT와 협력자를 준비하고 SUT를 원하는 상태로 만든다. 실행 구절에서는 SUT에 메서드를 호출하고 준비된 의존성을 전달하며 출력 값을 캡처한다. 검증 구절에선 반환 값 혹은 SUT와 협력자의 최종 상태, SUT가 협럭자에게 호출한 메서드 등으로 표시한다.

테스트는 분기가 없는 간단한 일련의 단계여야 한다. 테스트 내 if 문은 피하자.

준비 구절은 클 수 있다. 하지만 너무 크다면 테스트 클래스 내 비공개 메서드 또는 별도의 팩토리 메서드로 도출하는 것이 좋다. 오브젝트 마더, 테스트 데이터 빌더 패턴이 있다.

실행 구절은 보통 한 줄이다. 실행 구절이 두 줄 이상인 경우 SUT 공개 API에 문제가 있을 수 있다. 이 경우 캡슐화가 깨진 게 아닐 지 의심해보자.

🌟 테스트는 단일 동작을 검증한다. 단일 동작을 수행하기 위해 두 개의 함수를 연속으로 호출해야 하는 게 어색하다.

단일 동작은 여러 결과를 낼 수 있으며, 하나의 테스트로 그 모든 결과를 평가하는 것이 좋다. 하지만 검증 구절이 너무 커진다면 SUT 코드의 추상화가 누락된 게 아닌지 의심해보자.

테스트의 이름은 동작에 대한 고수준의 명세를 나타내자. 하나의 Fact, Scenario로 받아드릴 수 있다.

소감

Mock을 사용한 테스트가 왜 안좋다는거야? 이렇게 복잡한 객체 그래프를 안만들고 빠르게 테스트할 수 있는데? 라고 생각하고 있었는데, 내 테스트력이 부족해서라는 걸 알 수 있었다.

테스트는 동작 단위 검증이라는 점, 테스트도 경제적으로 가치 있는 테스트만 남기라는 점 등에서 머리를 한대 얻어맞은 느낌이 들었다. 실용적인 가치가 없는 테스트를 짜면서 이걸 왜 짜나 싶던 생각이 쏙 들어갈 수 있었다.

나는 앞으로 고전파다.

Comments