2. 단위 테스트란 무엇인가

Bard·2022년 3월 15일
5

Unit Testing

목록 보기
2/3
post-thumbnail

단위 테스트에 접근하는 방법은 두가지 뚜렷한 견해로 나뉜다.

각각 고전파(classical school)과 런던파(London school)로 알려져 있다.

고전파는 모든 사람이 단위 테스트와 테스트 주도 개발에 원론적으로 접근하는 방식이기 때문에 '고전'이라고 한다.

런던파는 런던의 프로그래밍 커뮤니티에서 시작되어서 '런던'이라고 한다.

우선 단위 테스트 정의로 시작해보자. 이 정의가 고전파런던파를 구분 짓는 일이다.

2.1 '단위 테스트'의 정의

단위 테스트는 다음과 같이 중요한 세 가지 속성이 있다.

  • 작은 코드 조각을 검증하고,
  • 빠르게 수행하고,
  • 격리된 벙식으로 처리하는 자동화된 테스트다.

여기서 처음 두 속성은 논란의 여지가 없다. 무엇이 빠른 건지는 논쟁거리가 될 수도 있지만 그리 중요한 건 아니다.

대중의 의견이 크게 다른 것은 세 번째 속성이다.

격리 문제는 단위 테스트의 고전파와 런던파를 구분할 수 있게 해주는 근원적 차이에 속한다.

저자는 고전적 스타일을 선호한다고 하는데, 그 까닭은 2.3절에서 설명한다.

2.1.1 격리 문제에 대한 런던파의 접근

코드 조각을 격리된 방식으로 검증한다는 것은 무엇을 의미하는가?

런던파에서는 테스트 대상 시스템을 협력자에게서 격리하는 것을 일컫는다.

즉 하나의 클래스가 다른 클래스 또는 여러 클래스에 의존하면 이 모든 의존성을 테스트 대역(Test Double)으로 대체해야 한다.

이런 식으로 동작을 외부 영향과 분리해서 테스트 대상 클래스에만 집중할 수 있다.

테스트 대역(Test Double)이란?

테스트를 작성하다 보면 외부 요인이 필요한 시점이 있는데, 다음은 외부 요인이 테스트에 관여하는 주요 예이다.

  • 테스트 대상에서 파일 시스템 사용
  • 테스트 대상에서 DB로부터 데이터를 조회하거나 데이터를 추가
  • 테스트 대상에서 외부의 HTTP 서버와 통신

이런 시점에서 복잡성을 줄이고 테스트를 용이하게 하는 단순화된 버전이 바로 테스트 대역(Test Double)이다.

다음 그림은 일반적으로 격리가 어떻게 이뤄지는지 보여준다.

이 방법의 한가지 이점은 테스트가 실패하면 코드베이스의 어느 부분이 고장났는지 확실히 알 수 있다는 것이다.

즉, 확실히 테스트 대상 시스템이 고장난 것이다. 클래스의 모든 의존성은 테스트 대역으로 대체됐기 때문에 의심할 여지가 없다.

또한 단위 테스트 격리에는 작지만 유익한 부가적인 이점이 더 있다.

한 번에 한 클래스만 테스트하라는 지침을 도입하면 전체 단위 테스트 스위트를 간단한 구조로 할 수 있다.

다음 그림은 일반적으로 어떤지 보여준다.

온라인 상점을 운영한다고 가정하자. 샘플 애플리케이션에는 고객이 제품을 구매할 수 있다는 간단한 use case가 하나 있다.

상점에 재고가 충분하면 구매는 성공으로 간주되고, 구매 수량만큼 상점의 제품 수량이 줄어든다.

제품이 충분하지 않으면 상점에 아무 일도 일어나지 않는다.

다음 테스트는 고전적인 스타일로 작성됐으며 일반적인 3단 구성인 준비, 실행, 검증 패턴 (AAA 패턴)을 사용한다.

public void Purchase_succeeds_when_enough_inventory()
{
  // 준비
  var store = new Store();
  store.AddInventory(Product.Shampoo, 10);
  var customer = new Customer();

  // 실행
  bool success = customer.Purchase(store, Product.Shampoo, 5);

  // 검증
  Assert.True(success);
  Assert.Equal(5, store.GetInventory(Product.Shampoo)); // 상점 제품 다섯 개 감소
}

public void Purchase_fails_when_not_enough_inventory()
{
  // 준비
  var store = new Store();
  store.AddInventory(Product.Shampoo, 10);
  var customer = new Customer();

  // 실행
  bool success = customer.Purchase(store, Product.Shampoo, 15);

  // 검증
  Assert.False(success);
  Assert.Equal(10, store.GetInventory(Product.Shampoo)); // 상점 제품 수량 그대로
}

public enum Product
{
  Shampoo,
  Book
}

보다시피 준비 부분은 의존성과 테스트 대상 시스템을 모두 준비하는 부분이다.

이 코드는 단위 테스트의 고전 스타일 예로, 테스트는 협력자(Store 클래스)를 대체하지 않고 운영용 인스턴스를 사용한다.

따라서 Customer만이 아니라 Customer와 Store 둘 다 효과적으로 검증한다.

그리고 두 클래스는 서로 격리돼 있지 않다.

이제 런던 스타일로 예제를 수정해보자.

동일한 테스트에서 Store 인스턴스는 테스트 대역, 구체적으로 목으로 교체해본다.

사람들이 흔히 테스트 대역과 목을 동의어로 사용하지만, 기술적으로는 그렇지 않다.

테스트 대역은 실행과 관련없는 모든 종류의 가짜 의존성을 설명하는 포괄적인 용어다.

목은 그러한 의존성의 한 종류일 뿐이다.

public void Purchase_succeeds_when_enough_inventory()
{
  // 준비
  var storeMock = new Mock<IStore>();
  storeMock
    .SetUp(x => HasEnoughInventory(Product.Shampoo, 5))
    .Returns(true);
  var customer = new Customer();

  // 실행
  bool success = customer.Purchase(storeMock.Object, Product.Shampoo, 5);

  // 검증
  Assert.True(success);
  storeMock.Verify(
    x -> x.RemoveInventory(Product.Shampoo, 5),
    Times.Once
  );
}

public void Purchase_fails_when_not_enough_inventory()
{
  // 준비
  var storeMock = new Mock<IStore>();
  storeMock
    .SetUp(x => HasEnoughInventory(Product.Shampoo, 5))
    .Returns(false);
  var customer = new Customer();

  // 실행
  bool success = customer.Purchase(storeMock.Object, Product.Shampoo, 5);

  // 검증
  Assert.True(success);
  storeMock.Verify(
    x -> x.RemoveInventory(Product.Shampoo, 5),
    Times.Never
  );
}

검증 단계도 바뀌었고 중요한 차이점이 여기에 있다.

여전히 이전과 같이 customer.Purchase 호출 결과를 확인하지만, 고객이 상점에서 올바르게 했는지 확인하는 방법이 다르다.

이전에는 상점 상태를 검증했다. 지금은 Customer와 Store간의 상호 작용을 검사한다.

즉, 고객이 상점에서 호출을 올바르게 했는지 확인한다.

고객은 구매가 성공하면 이 메서드를 한 번만 호출해야 하고(Times.Once), 구매가 실패하면 절대로 호출하면 안 된다(Times.Never).

2.1.2 격리 문제에 대한 고전파의 접근

단위 테스트의 속성을 다시 한번 살펴보자.

  • 단위 테스트는 작은 코드 조각을 검증한다.
  • 신속하게 수행하고,
  • 격리된 벙식으로 진행한다.

세 번째 속성에 대한 해석이 분분한 것 외에, 첫 번째 속성에도 다양한 해석이 가능하다.

각각의 모든 클래스를 격리해야 한다면 테스트 대상 코드 조각은 당연히 단일 클래스이거나 해당 클래스 내의 메서드여야 한다.

때에 따라 한 번에 몇 개의 클래스를 테스트할 수 있기는 하다.

일반적으로 한 번에 한 클래스로 테스트하는 지침을 따르려고 노력해야 한다.

고전적인 방법에선 코드를 꼭 격리하는 방식으로 테스트해야 하는 것은 아니다.

대신 단위 테스트는 서로 격리해서 실행해야 한다.

이렇게 하면 테스트를 어떤 순서든 가장 적합한 방식으로 실행할 수 있으며, 서로의 결과에 영향을 미치지 않는다.

예를 들어 어떤 테스트가 준비단계에서 데이터베이스에서 고객을 생성할 수 있고, 이 테스트가 실행되기 전에 다른 테스트의 준비 단계에서 고객을 삭제할 수도 있다.

이 두 가지 테스트를 병렬로 실행하면 첫 번째 테스트가 실패하는데, 이는 두 번째 테스트의 간섭 때문이다.

격리 문제에 대한 이러한 해석에는 목과 기타 테스트 대역을 적당히 쓰려는 견해가 있다.

테스트 대역을 사용할 수 있지만, 보통 테스트 간에 공유 상태를 일으키는 의존성에 대해서만 사용하는 것이다.

공유 의존성, 비공개 의존성, 프로세스 외부 의존성, 휘발성 의존성

공유 의존성: 테스트 간에 공유되고 서로의 결과에 영향을 미칠 수 있는 수단을 제공하는 의존성

비공개 의존성: 공유하지 않는 의존성

프로세스 외부 의존성: 애플리케이션 실행 프로세스 외부에서 실행되는 의존성

휘발성 의존성은 다음 속성 중 하나를 나타내는 의존성이다.

  • 개발자 머신에 기본 설치된 환경 외에 런타임 환경의 설정 및 구성을 요구. (DB, API ...)
  • 비결정적 동작을 포함. (난수 생성기, ...)

2.2 단위 테스트의 런던파와 고전파

종합하면 세 가지 주요 주제에 대해 의견 차이가 있다.

격리 주체단위의 크기테스트 대역 사용 대상
런던파단위단일 클래스불변 의존성 외 모든 의존성
고전파단위 테스트단일 클래스 또는 클래스 세트공유 의존성

2.2.1 고전파와 런던파가 의존성을 다루는 방법

고전파에서는 공유 의존성을 테스트 대역으로 교체한다. 런던파에서는 변경 가능한 한 비공개 의존성도 테스트 대역으로 교체할 수 있다.

그리고 의존성에 대해 한 가지만 다시 강조해본다.

모든 프로세스 외부 의존성이 공유 의존성의 범주에 속하는 것은 아니다.

공유 의존성은 거의 항상 프로세스 외부에 있지만, 그 반대는 그렇지 않다.

하지만 이 책에서 달리 명시하지 않는 한, 공유 의존성과 프로세스 외부 의존성이라는 용어는 서로 바꿀 수 있게 사용한다.

실제 프로젝트에서 프로세스 외부가 아닌 공유 의존성은 거의 없다.

의존성이 프로세스 내부에 있으면 각 테스트에서 별도의 인스턴스를 쉽게 공급할 수 있으므로 테스트 간에 공유할 필요가 없다.

마찬가지로 공유되지 않는 프로세스 외부 의존성은 일반적으로 접할일이 없다.

이 정의를 토대로 두 분파의 장점을 비교해보자.

2.3 고전파와 런던파의 비교

런던파의 접근 방식은 다음과 같은 이점을 제공한다.

  • 입자성이 좋다. 테스트가 세밀해서 한 번에 한 클래스만 확인한다.
  • 서로 연결된 클래스의 그래프가 커져도 테스트하기 쉽다. 모든 협력자는 테스트 대역으로 대체되기 때문에 테스트 작성 시 걱정할 필요가 없다.
  • 테스트가 실패하면 어떤 기능이 실패했는지 확실히 알 수 있다.

2.3.1 한 번에 한 클래스만 테스트하기

런던파는 클래스를 단위로 간주한다. 이로인해 자연스럽게 클래스를 테스트에서 검증할 최소 단위로도 취급하게 된다.

이런 경향은 이해되기는 하지만 오해의 소지가 있다.

테스트는 코드의 단위를 검증해서는 안된다. 오히려 동작의 단위, 즉 문제 영역에 의미가 있는것, 이상적으로는 비즈니스 담당자가 유용하다고 인식할 수 있는 것을 검증해야 한다.

테스트는 해결하는 데 도움이 되는 문제에 대한 이야기를 들려줘야 하며 이 이야기는 프로그래머가 아닌 일반 사람들에게 응집도가 높고 의미가 있어야 한다.

예를 들어 다음은 응집도가 높은 이야기의 예다.

우리집 강아지를 부르면, 바로 나에게 온다.

이제 다음을 비교해보자.

우리집 강아지를 부르면 먼저 왼쪽 앞다리를 움직이고, 이어서 오른쪽 앞다리를 움직이고, 머리를 돌리고, 꼬리를 흔들기 시작한다...

두 번째 이야기는 말이 안된다.

실제 동작 대신에 개별 클래스 (다리, 머리, 꼬리)를 목표로 할 때 테스트가 이렇게 보이기 시작한다.

2.3.2 상호 연결된 클래스의 큰 그래프를 단위 테스트하기

테스트 대역을 쓰면 클래스의 직접적인 의존성을 대체해 그래프를 나눌 수 있으며, 이는 단위 테스트에서 준비해야 할 작업량을 크게 줄일 수 있다.

모두 사실이지만, 이 추리는 잘못된 문제에 초점을 맞추고 있다.

상호 연결된 클래스의 크고 복잡한 그래프를 테스트할 방법을 찾는 대신, 먼저 이러한 클래스 그래프를 갖지 않는 데 집중해야 한다.

이는 틀림없이 문제의 징후가 맞으며, 목을 사용하는 것은 이 문제를 감추기만 할 뿐 원인을 해결하지 못한다.

2.3.3 버그 위치 정확히 찾아내기

우려할만 하지만 큰 문제는 아니다. 테스트를 정기적으로 실행하면 버그의 원인을 알아낼 수 있다.

게다가 테스트 스위트 전체에 걸쳐 계단식으로 실패하는 데 가치가 있다.

버그가 테스트 하나뿐만 아니라 많은 테스트에서 결함으로 이어진다면, 방금 고장 낸 코드조각이 큰 가치가 있다는 것을 보여준다.

이는 코드 작업시 명심해야 할 유용한 정보다.

2.3.4 고전파와 런던파 사이의 다른 차이점

고전파와 런던파 사이에 남아있는 두 가지 차이점은 다음과 같다.

  • 테스트 주도 개발을 통한 시스템 설계 방식
  • 과도한 명세 문제

테스트 주도 개발

테스트 주도 개발은 테스트에 의존해 프로젝트 개발을 추진하는 소프트웨어 개발 프로세스다. 이 프로세스는 세 단계로 구성되며, 각 테스트 케이스마다 반복해서 적용한다.

  1. 추가해야 할 기능과 어떻게 동작해야 하는지를 나타내는 실패 테스트를 작성한다.
  2. 테스트가 통과할 만큼 충분히 코드를 작성한다. 이 단계에서 코드가 꺠끗하거나 명쾌할 필요는 없다.
  3. 코드를 리팩터링한다. 통과 테스트 보호하에서 코드를 안전하게 정리해 좀 더 읽기 쉽고 유지하기 쉽도록 만들 수 있다.

런던 스타일의 단위 테스트는 하향식 TDD로 이어지며, 고전파는 일반적으로 상향식으로 한다.

그러나 고전파와 런던파 간의 가장 중요한 차이점은 과도한 명세 문제, 즉 테스트가 SUT의 구현 세부 사항에 결합되는 것이다.

런던 스타일은 고전 스타일보다 테스트가 구현에 더 자주 결합되는 편이다.

이로 인해 런던 스타일과 목을 전반적으로 아무 데나 쓰는 것에 대해 주로 이의가 제기된다.

profile
The Wandering Caretaker

0개의 댓글