단위 테스트란 무엇인가
Last updated
Last updated
단위 테스트에 대한 정의에는 뉘앙스 차이로 다르게 해석하는 집단들이 있다. 고전파와 런던파이다. 고전파는 단위 테스트와 테스트 주도 개발에 원론적으로 접근하는 방식이고, 런던파는 목(mock)과 스터빙(stubbing) 등을 사용해서 단위 테스트를 주로 작성한다. 단위 테스트의 정의에 있어 꼭 필요한 3가지 속성이 있다.
위의 속성들 중 위의 두개는 논란의 여지가 없거나 그리 중요하지는 않다. 다만 세번째 속성에 대해서 고전파와 런던파가 서로 다른 접근 방식으로 다르게 해석을 한다.
런던파는 주로 코드 조각을 격리된 방식으로 검증하길 선호한다. 테스트 대상 시스템(sut)을 협력자(collaborator)에게서 격리하는 것을 일컫는다.
즉, 하나의 클래스가 다른 클래스에 의존성을 갖고 있으면 그 의존성을 테스트 대역(test double)으로 대체하여 테스트하는 것을 의미한다.
위의 그림처럼 테스트 대상이 되는 클래스가 갖는 의존성을 대역으로 대체하는 것의 장점은 테스트가 실패하면 코드베이스의 어느 부분이 고장 났는지 명확하게 알 수 있다는 것이다. 또, 객체 그래프를 분할하여 다룰 수 있기 때문에 의존 관계가 너무 복자하여 테스트하기 어려운 관계들도 작게 분해하여 단위 테스트를 할 수 있게된다.
위의 그림처럼 테스트 대상이 되는 클래스가 갖는 의존성을 대역으로 대체하는 것의 장점은 테스트가 실패하면 코드베이스의 어느 부분이 고장 났는지 명확하게 알 수 있다는 것이다.
또, 객체 그래프를 분할하여 다룰 수 있기 때문에 의존 관계가 너무 복자하여 테스트하기 어려운 관계들도 작게 분해하여 단위 테스트를 할 수 있게 된다. 부가적인 이점으로는 테스트 코드 작성시 하나의 클래스에 대응되는 하나의 단위 테스트 클래스를 만들 수 있다. 이는 매우 단순한 구조를 유지할 수 있게 해준다.
다음의 코드는 3단 구성인 준비, 실행, 검증 패턴 (Arrage, Act, Assert 패턴)으로 고전파가 선호하는 스타일로 작성된 코드입니다.
흔히들 잘 알고 있는 Given, When, Then 패턴과 거의 유사하지만 요즘은 AAA 패턴으로 더 많이 불린다고 한다.
준비 단계(Arrange)에서는 테스트 대상 시스템(SUT, System Under Test)와 협력자들(의존성들)을 준비한다. 그후로 실행(Act) 단계를 거쳐 나온 결과를 검증 단계(Assert)를 통해 확인한다.
위의 코드를 보면 알 수 있든 고전 스타일은 테스트 협력자를 대체하지 않고 운영요 인스턴스를 그대로 사용한다. 이렇게 하면 Customer와 Store 둘 다 효과적으로 검증한다. 하지만 Customer가 올바르더라도 Store 내부의 버그가 있다면 단위 테스트가 실패할 수 있다. 테스트에서 두 클래스는 서로 격리되어 있지 않다.
다음은 목 프레임워크 Moq를 사용해서 작성된 런던파 스타일 코드이다.
준비 단계에서 테스트는 Store의 실제 인스턴스를 생성하지 않고 Moq의 내장 클래스인 Mock<T>
를 사용해 대체한다. 사실상 테스트에서 Store는 더 이상 사용되지 않는다.
검증 단계에서 고전파 스타일의 코드에서는 상점의 상태를 검증했지만, 런던파 스타일의 코드에서는 Customer와 Store 간의 상호 작용을 검사한다. x.RemoveInventory와 같은 메소드가 호출 되었는지와 횟수까지 검증한다.
아까 단위 테스트를 정의하는 세가지 속성이 다음과 같다고 하였다.
런던파 스타일 처럼 모든 클래스를 격리해야 한다면 테스트 대상 코드 조각은 단일 클래스나 클래스 내의 메소드여야 한다. 런던파에서는 격리를 코드 단위로 하기 때문에 이 보다 더 클 수가 없다.
고전파에서도 격리는 중요한 내용이다. 단위 테스트를 격리해서 실행해야 테스트를 어떤 순서(별로이나 순차 등등)로든 가장 적합한 방식으로 실행 할 수 있고 서로의 결과에 영향을 미치지 않는다.
하지만 이는 여러 클래스를 모두 메모리에 상주하고 공유 상태에서 도달하지 않는 한 테스트해도 괜챃다는 뜻이다. 실행 컨텍스트에 영향을 줄 수 있는 데이터 베이스, 파일 시스템 등 프로세스 외부 의존성이 이러한 공유 상태의 대표적인 예이다.
공유 의존성 : 테스트간 공유되고 서로 결과에 영향을 미칠 수 있는 의존성. ex) 정적 가변 필드(static mutable field)
비공개 의존성 : 공유하지 않는 의존성
프로세스 외부 의존성 : 애플리케이션 실행 프로세스 외부에서 실행되는 의존성. 프로세스 외부 의존성은 대부분 공유 의존성에 해당하지만 모두 그런 것은 아니다.
공유 의존성은 테스트 대상 클래스 간이 아니라 단위 테스트 간에 공유한다. 그런 의미에서 싱글턴 의존성은 각 테스트에서 새 인스턴스를 만들 수 있으면 공유 되지 않는다. 테스트에서는 싱글톤을 재사용하지 않는다. 따라서 비공개 의존성이다.
설정 클래스는 일반적으로 한 개 뿐이며, 모든 제품 코드에서 이 인스턴스를 재사용한다. 하지만 생성자 등을 통해 다른 모든 의존성이 SUT에 주입되면 각 테스트에서 새 인스턴스를 만들 수 있다. 이 때에 새 파일 시스템이나 데이터베이스는 만들 수 없고 테스트 대역으로 대체 되어야 한다.
공유 의존성을 대체하는 또 다른 이유는 테스트 실행 속도를 높히기 위해서 이다. 공유 의존성은 거의 항상 실행 프로세스 외부에 있는데 반해, 비공개 의존성은 보통 그 경계를 넘지 않는다. 따라서 공유 의존성이 많아지면 빠르게 실행할 수 없기 때문에 단위 테스트에서 통합 테스트 영역으로 넘어가게 된다.
런던파와 고전파로 나눠진 원인은 격리 특성을 바라보는 관점 차이 때문이었다. 런던파에서는 협력자를 격리의 대상으로 보지만, 고전파에서는 단위 테스트끼리 격리해야한다고 보고 있다.
런던파이든 고전파이든 테스트 대역은 어디서나 사용할 수 있다. 하지만 런던파는 테스트에서 일부 의존성을 그대로 사용할 수 있도록 하고 있다.
특히 변하지 않는 객체(불변 객체)는 교체하지 않아도 된다.
아래 사진은 두 분파가 각각 어떻게 의존성의 종류를 다루는지 보여주고 있다. 의존성은 공유(Shared)되거나 비공개(Private)일 수 있다. 다시 비공개는 변경 가능하거나 불변일 수 있다.
데이터베이스는 공유 의존성이고, 테스트 간의 공유 되는 내부 상태 또한 공유 의존성이라고 할 수 있다. Store 인스턴스는 변경 가능한 비공개 의존성이고 Product 인스턴스는 불변인 비공개 의존성(불변 객체, 값 객체)이다.
협력자 vs. 의존성 : 협력자는 공유하거나 변경가능한 의존성이다.
하지만 Product와 숫자 5도 의존성이지만 협력자는 아니다. 값 또는 값 객체로 분류된다. 아래 코드에서 의존성은 3개이고 하나(store)는 협력자이고, 나머지 두개(Product.Shampoo, 5)는 아니다.
의존성에 대해 한 가지만 다시 강조하면, 모든 프로세스 외부 의존성이 공유 의존성의 범주에 속하는 것은 아니다. 공유 의존성은 거의 항상 프로세스 외부에 있지만, 그 반대는 그렇지 않다.
외부에 있는 불변 의존성은 테스트들 실행 컨텍스트에 영향을 주지 않을 것만 생각해봐도 알 수 있다.
외부의 불편 의존성(위의 그림처럼 Read-only API)의 경우에 테스트에 영향을 주지 않느다고 해도 대부분은 테스트 속도를 높이기 위해서 테스트 대역으로 교체 하곤 한다.
하지만 필수는 아니며 연결이 항상 안정적이며 충분히 빠르다면 그대로 사용하는것도 괜찮다.
1) 입자성(granularity)가 좋다. 테스트가 세밀해서(fine-grained) 한 번에 한 클래스만 확인한다.
2) 클래스 그래프의 크기가 커져도 테스트하기 쉽다. 모든 협력자를 쉽게 대역으로 대체할 수 있다.
3) 테스트가 실패하면 어떤 기능이 실패했는지 명확하게 알 수 있다.
- 테스트 주도 개발(TDD)을 통한 시스템 설계 방식
- 과도한 명세(over-specification) 문제
런던파는 단위 테스트는 하향식 TDD로 이어진다. 전체 시스템에 대한 기대치를 설정하는 상위 레벨 테스트 부터 시작하여 모든 클래스를 구현할때까지 클래스 그래프를 다져나간다.
이것의 장점은 SUT의 모든 협력자를 차단해 해당 협력자의 구현을 나중으로 미룰 수 있다. 고전파는 테스트에서 실제 객체를 다뤄야 하기 때문에 일반적으로 상향식으로 한다. 도메인 모델을 시작으로 최종 사용자가 소프트웨어를 사용할 수 있을 때까지 계층을 그 위에 더 둔다.
그러나 고전파와 런던파 간의 가장 중요한 차이점은 과도한 명세 문제, 즉 테스트가 SUT의 구현 세부 사항에 결합되는 것이다. 런던 스타일은 고전 스타일 보다 테스트가 구현에 더 자주 결합되는 편이다.
고전파와 런던파간의 통합 테스트의 정의에도 차이가 있다. 이것 또한 격리 문제에 대한 견해 차이가 그 이유이다. 런던파는 실제 협력자 객체를 사용하는 모든 테스틑를 통합 테스트로 본다.
따라서 런던파 입장에서는 고전파의 대부분의 테스트 코드는 통합 테스트로 느껴질 것이다.
이를 고전파 입장에서 다시 해석하면 다음과 같다.
따라서 고전파 입장에서 통합 테스트는 위의 기준 중 하나를 충족하지 않는 테스트이다. 이 테스트의 결과에 따라 이후에 나오는 테스트들의 결과가 달라질 수 있고, 다른 테스트와 병렬로 처리 할 수 없기 때문이다. 그리고 보통 이런 테스트는 충분히 빠르지도 않다.
통합 테스트는 프로세스 외부 의존성뿐 아니라 조직 내 다른 팀이 개발한 코드 등과 통합해 작동하는지도 검증하는 테스트다. 엔드 투 엔드 테스트는 통합 테스트의 일부다. 엔드 투 엔드 테스트으와 통합 테스트 간의 차이점은 엔드 투 엔드 테스트가 일반적으로 의존성을 더 많이 포함한다는 것이다.
경계가 흐리지만, 일반적으로 통합 테스트는 프로세스 외부 의존성을 한두 개만 갖고 작동하고, 엔드 투 엔트 테스트는 프로세스 외부 의존성을 전부 또는 대다수 갖고 작동한다. 따라서 엔드 투 엔드라는 명칭은 모든 외부 어플리케이션을 포함해 시스템을 최종 사용자의 관점에서 검증하는 것을 의미한다.
엔드 투 엔드 테스트는 유지 보수 측면에서 가장 비용이 많이 들기 때문에 모든 단위 테스트와 통합 테스트를 통과한 후 빌드 프로세스 후반에 실행하는 것이 좋다.