박종훈 기술블로그

단위 테스트의 두 분파 (고전파와 런던파)

2장 단위 테스트란 무엇인가
단위테스트 (블라디미르 코리코프)


이 책을 끝까지 읽었음에도 다시 돌아와 정리하는 것은 처음 읽었을 당시에는 테스트에 대한 이해가 부족하였기에, 그냥 단순히 두 가지 분파로 나눠지는 구나 하고 가볍게 넘어갔기 때문이다.

하지만 테스트와 관련된 책, 자료들을 접함에 따라 정말로 테스트 방식이 나눠지는구나 느끼게 되었고, 해당 부분에 대해 다시 한 번 읽으면 좋겠다 싶어서 다시 읽으며 정리를 진행한다.


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

런던파는 런던의 프로그래밍 커뮤니티에서 시작되었다.

2.1 ‘단위 테스트’의 정의

단위테스트의 가장 중요한 세가지 속성은 다음과 같다.

처음 두 속성은 논란의 여지가 없다. 대중의 의견이 크게 다른 것은 세 번째 속성이다. 격리 문제는 단위 테스트의 고전파와 런던파가 나눠지게 하는 근원적 차이이다. 두 분파 간의 모든 차이는 격리가 정확히 무엇인지에 대한 의견 차이로 시작됐다.

단위 테스트의 고전파와 런던파
고전적 접근법은 ‘디트로이트(Detroit)’라고도 하며, 때로는 단위 테스트에 대한 고전주의적(classicist)접근법이라고도 한다. 아마도 고전파의 입장에서 가장 고전적인 책은 켄트 백(Kent Beck)이 지은 “테스트 주도 개발”일 것이다.
런던 스타일은 때때로 ‘목 추종자(mockist)’로 표현된다. 목 추종자라는 용어가 널리 퍼져 있지만, 런던 스타일을 따르는 사람들은 보통 그렇게 부르는 것을 좋아하지 않으므로 이 책에서는 런던 스타일이라고 소개한다. 이 방식의 가장 유명한 지지자는 스티브 프리먼(Steve Freeman)과 냇 프라이스(Nat Pryce)다. 이 주제에 대한 좋은 자료로 이들이 저술한 “Growing Object-Oriented Software, Guided by Tests”를 추천한다.

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

코드 조각(단위)을 격리된 방식으로 검증한다는 것은 무엇을 의미하는가? 런던파에서는 테스트 대상 시스템을 협력자(collaborator)에게서 격리하는 것을 일컫는다.

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

[정의] 테스트 대역은 릴리스 목적의 대응으로 보일 수 있지만, 실제로는 복잡성을 줄이고 테스트를 용이하게 하는 단순화된 버전이다. 제라드 메스자로스가 그의 저서 xUnit 테스트 패턴 에서 이 용어를 처음 소개했다. 이름 자체는 영화 산업의 ‘스턴트 대역’이라는 개념에서 유래됐다.

의존성을 테스트 대역으로 대체하면 동작을 외부 영향과 분리해서 테스트 대상 클래스에만 집중할 수 있다. 단위 테스트를 해당 의존성과 별개로 수행 할 수 있다.

이 방식의 한 가지 이점은 테스트가 실패하면 코드베이스의 어느 부분이 고장 났는지 확실히 알 수 있다는 것이다. 즉, 확실히 테스트 대상 시스템이 고장 난 것이다. 클래스의 모든 의존성은 테스트 대역으로 대체됐기 때문에 의심할 여지가 없다.

replace dependency using test double

또 다른 이점은 객체 그래프(object graph, 같은 문제를 해결하는 클래스의 통신망)를 분리할 수 있는 것이다.

의존성을 가진 코드베이스를 테스트하는 것은 테스트 대역 없이는 어렵다. 테스트 대역을 사용하면 객체 그래프를 다시 만들지 않아도 된다. 그래프를 효과적으로 분해해 단위 테스트에서 준비를 크게 줄일 수 있다.

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

simple-test-suite-structure

코드 예시

먼저 고전적인 스타일로 작성된 샘플 테스트를 살펴본 후 런던 방식을 사용해 다시 작성해본다.

온라인 상점을 운영한다고 가정하자. 샘플 애플리케이션에는 고객이 제품을 구매할 수 있다는 간단한 유스케이스가 하나 있다. 상점에 재고가 충분하면 구매는 성공으로 간주되고, 구매 수량만큼 상점의 제품 수량이 줄어든다. 제품이 충분하지 않으면 구매는 성공하지 못하여 상점에 아무 일도 일어나지 않는다.

고전 스타일

상점에 재고가 충분히 있을 때만 구매가 성공하는지 검증하는 두 가지 테스트를 아래와 같이 작성하였다.

[Fact]
public void Purchase_succeeds_when_enough_inventory()
{
    // Arrange
    var store = new Store();
    store.AddInventory(Product.Shampoo, 10);
    var customer = new Customer();

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

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

[Fact]
public void Purchase_fails_when_not_enough_inventory()
{
    // Arrange
    var store = new Store();
    store.AddInventory(Product.Shampoo, 10);
    var customer = new Customer();

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

    // Assert
    Assert.False(success);
    Assert.Equal(10, store.GetInventory(Product.Shampoo)); // 상점 제품 수량 변화 없음
}

public enum Product
{
    Shampoo,
    Book
}

준비 단계에서는 테스트 대상 시스템(SUT, System Under Test)과 하나의 협력자를 준비한다. Customer가 SUT에, Store가 협력자에 해당한다.

Store라는 협력자가 필요한 이유는 다음과 같다.

[정의] 테스트 대상 메서드(MUT, Method Under Test)는 테스트에서 호출한 SUT의 메서드다. MUT와 SUT는 흔히 동의어로 사용하지만, 일반적으로 MUT는 메서드를 가리키는 데 반해 SUT는 클래스 전체를 가리킨다.

위 테스트는 단위 테스트의 고전 스타일 예로, 테스트는 협력자(Store 클래스)를 대체하지 않고 운영용 인스턴스를 사용한다. Customer만이 아니라 Store 도 효과적으로 검증한다.

그러나 Customer가 올바르게 작동하더라도 Customer에 영향을 미치는 Store 내부에 버그가 있다면 단위 테스트에 실패할 수 있다. 테스트에서 두 클래스는 서로 격리돼 있지 않다.

런던 스타일

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

[정의] 목은 테스트 대상 시스템과 협력자 간의 상호 작용을 검사할 수 있는 특별한 테스트 대역이다.

다음 예제는 Customer 가 협력자인 Store에서 격리된 후 어떻게 테스트가 수행되는지 보여준다.

[Fact]
public void Purchase_succeeds_when_enough_inventory()
{
    // Arrange
    var storeMock = new Mock<IStore>();
    storeMock
        .Setup(x => x.HasEnoughInventory(Product.Shampoo, 5))
        .Returns(true);
    var customer = new Customer();

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

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

[Fact]
public void Purchase_fails_when_not_enough_inventory()
{
    // Arrange
    var storeMock = new Mock<IStore>();
    storeMock
        .Setup(x => x.HasEnoughInventory(Product.Shampoo, 5))
        .Returns(false);
    var customer = new Customer();

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

    // Assert
    Assert.False(success);
    storeMock.Verify(
        x => x.RemoveInventory(Product.Shampoo, 5),
        Times.Never);
}

어떤 부분이 다른지 살펴보자.

준비 단계에서 Store의 실제 인스턴스를 생성하지 않고 목 프레임워크의 내장 클래스인 Mock를 사용해 대체한다. 또한 샴푸 재고를 추가해 Store 상태를 수정하는 대신 HasEnoughInventory() 메서드 호출에 어떻게 응답하는지 목에 직접 정의한다. Store의 실제 상태와 관계없이 테스트가 요구하는 방식으로 요청에 응답한다.

테스트는 더 이상 Store를 사용하지 않는다. Store 클래스 대신 IStore 인터페이스로 목을 만들어 사용했다. 목을 통해서 격리된 테스트 대상 시스템을 만드려면 인터페이스가 필요하다.

검증 단계도 바뀌었다. 이전과 같이 customer.Purchase 호출 결과를 확인하지만, 고객이 상점에서 올바르게 했는지 확인하는 방법이 다르다.

(이 부분이 중요하다.) 이전에는 상점 상태를 검증했다. 하지만 지금은 Customer와 Store 간의 상호 작용을 검사한다.

고객이 상점으로 호출해야 하는 메서드뿐만 아니라 호출 횟수까지 검증할 수 있다.

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

런던 스타일은 테스트 대역(목)으로 테스트 대상 코드 조각을 분리해서 격리 요구 사항에 다가간다.

단위 테스트의 속성을 다시 한 번 살펴보면 다음과 같다.

첫 번째 속성에도 다양한 해석이 가능하다. 작은 코드 조각은 얼마나 작아야 하는가?

고전적인 방법에서 단위 테스트는 서로 격리해서 실행해야 한다. 이렇게 하면 테스트를 어떤 순서로든 가장 적합한 방식으로 실행할 수 있으며 서로의 결과에 영향을 미치지 않는다.

그러나 여러 클래스가 공유 상태에 있는 경우에는 테스트가 서로 소통(간섭)하고 실행 컨텍스트에 영향을 줄 수 있다. 데이터베이스, 파일 시스템 등 프로세스 외부 의존성이 이러한 공유 상태의 대표적인 예이다.

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

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

공유 의존성(shared dependency) 은 테스트 간에 공유되고 서로의 결과에 영향을 미칠 수 있는 수단을 제공하는 의존성이다. 공유 의존성의 전형적인 예는 정적 가변 필드(static mutable field)다. 데이터베이스도 공유 의존성의 전형적인 예가 될 수 있다.

비공개 의존성(private dependency) 은 공유하지 않는 의존성이다.

프로세스 외부 의존성(out-of-process dependency) 은 애플리케이션 실행 프로세스 외부에서 실행되는 의존성이며, 아직 메모리에 없는 데이터에 대한 프록시(proxy)다. 예를 들어 데이터베이스는 프로세스 외부이면서 공유 의존성이다. 그러나 각 테스트 실행 전에 도커 컨테이너로 데이터베이스를 시작하면 테스트가 더 이상 동일한 인스턴스로 작동하지 않기 때문에 프로세스 외부이면서 공유하지 않는 의존성이 된다. (서로 영향을 미칠수 없게 됨)

shared-dependency-and-private-dependency

테스트 대상 클래스에서 공유 의존성만 격리한다. 비공개 의존성은 그대로 둘 수 있다.

테스트 스위트 전체에서 단일 인스턴스를 유지할 필요는 없다. 그러나 새 파일 시스템이나 데이터베이스를 만들 수는 없으며, 테스트 간에 공유되거나 테스트 대역으로 대체돼야 한다.

공유 의존성과 휘발성 의존성 비슷하지만 동일하지는 않은 또 다른 용어로 휘발성 의존성(volatile dependency)이 있다.

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

  • 개발자 머신에 기본 설치된 환경 외에 런타임 환경의 설정 및 구성을 요구한다. 데이터베이스와 API 서비스가 좋은 예다. 추가 설정이 필요하며 시스템에 기본으로 설치돼 있지 않다.
  • 비결정적 동작(nondeterministic behavior)를 포함한다. 예를 들어 난수 생성기 또는 현재 날짜와 시간을 반환하는 클래스 등이 있다. 이런 의존성은 각 호출에 대해 다른 겨로가를 제공하기 때문에 비결정적이라고 한다.

공유 의존성과 휘발성 의존성은 겹치는 부분이 있다. 예를 들어 데이터베이스에 대한 의존성은 공유 의존성이자 휘발성 의존성이다. 파일 시스템은 모든 개발자 머신에 설치되고 대부분 결정적으로 작동하므로 휘발성이 아니다. 파일 시스템은 단위 테스트가 실행 컨텍스트를 서로 방해할 수 있는 수단이 될 수 있으므로 공유 의존성이다. 난수 생성기는 휘발성이지만, 각 테스트에 별도의 인스턴스를 제공할 수 있으므로 공유 의존성이 아니다.

Dependency Injection: Principles, Practices, Pattern

공유 의존성을 대체하는 또 다른 이유는 테스트 실행 속도를 높이는 데 있다. 공유 의존성은 거의 항상 실행 프로세스 외부에 있는 데 반해, 비공개 의존성은 보통 그 경계를 넘지 않는다. 따라서 데이터베이스나 파일 시스템 등의 공유 의존성에 대한 호출은 비공개 의존성에 대한 호출보다 더 오래 걸린다. 따라서 이러한 호출을 포함하는 공유 의존성을 가진 테스트는 단위 테스트 영역에서 통합 테스트 영역으로 넘어간다.

단위가 반드시 클래스에 국한될 필요는 없다. 공유 의존성이 없는 한 여러 클래스를 묶어서 단위 테스트할 수도 있다.

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

런던파와 고전파로 나눠진 원인은 격리 특성에 있다. 런던파는 테스트 대상 시스템에서 협력자를 격리하는 것으로 보는 반면, 고전파는 단위 테스트끼리 격리하는 것으로 본다.

요약하면 다음과 같다.

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

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

테스트 대역을 어디에서나 흔히 사용할 수 있지만, 런던파는 테스트에서 일부 의존성을 그대로 사용할 수 있도록 하고 있다. 여기서 리트머스 시험은 의존성의 변경 가능 여부다. 절대 변하지 않는 객체, 즉 불변 객체는 교체하지 않아도 된다.

불변 객체는 값 객체(value object) 또는 값(value) 이라고 한다. 주요 특징은 각각의 정체성이 없다는 것이다. 즉, 내용에 의해서만 식별된다. 값이 같다면 다른 객체로 대체해도 된다.

hierarchy-of-dependencies

위 그림은 의존성의 종류와 단위 테스트의 두 분파가 각각 어떻게 의존성을 처리하는지 보여준다.

classic school : 고전파
london school : 런던파

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

협력자 대 의존성

협력자는 공유하거나 변경 가능한 의존성이다. 예를 들어, 데이터베이스는 공유 의존성 이므로 데이터베이스 접근 권한을 제공하는 클래스는 협력자다. Store도 시간에 따라 상태가 변할 수 있기 때문에 협력자다.

Product와 숫자 5도 역시 의존성이지만 협력자는 아니다. 값 또는 값 객체로 분류된다.

일반적인 클래스는 두 가지 유형의 의존성으로 동작한다. 협력자와 값이다.

모든 프로세스 외부 의존성이 공유 의존성의 범주에 속하는 것은 아니다. 공유 의존성은 거의 항상 프로세스 외부에 있지만, 그 반대는 그렇지 않다.

프로세스 외부 의존성을 공유하려면 단위 테스트가 서로 통신할 수 있는 수단이 있어야 한다. 의존성 내부 상태를 수정하면 통신이 이뤄진다. 프로세스 외부의 불변 의존성은 그런 수단을 제공하지 않는다. 테스트는 불변 의존성 내부의 어던 것도 수정할 수 없기 때문에 서로 실행 컨텍스트에 영향을 줄 수 없다.

relation-between-shared-and-out-of-process-dependency

2.3 고전파와 런던파의 비교

다시 한 번 말하자면, 고전파와 런던파 간의 주요 차이는 단위 테스트의 정의에서 격리 문제를 어떻게 다루는지에 있다.

고전파는 고품질의 테스트를 만들고 단위 테스트의 궁극적인 목표인 프로젝트의 지속 가능한 성장을 달성하는 데 더 적합하다. 그 이유는 목을 사용하는 테스트는 고전적인 테스트보다 불안정한 경향이 있기 때문이다.

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

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

런던파는 클래스를 단위로 간주한다.

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

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

따라서 개별 클래스를 목표로 테스트를 하면 너무 세부적인 부분까지 테스트를 하게 될 수 있다. (이 기능이 왜 필요한지에 대한 것을 설명하지 못함.)

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

목을 사용하면 테스트를 쉽게 테스트할 수 있다. 테스트 대역을 쓰면 클래스의 직접적인 의존성을 대체해 그래프를 나눌 수 있다. 모두 사실이지만, 이 추리 과정은 잘못된 문제에 초점을 맞추고 있다. 상호 연결된 클래스의 크고 복잡한 그래프를 테스트할 방법을 찾는 대신, 먼저 이러한 클래스 그래프를 갖지 않는 데 집중해야 한다. 대개 클래스 그래프가 커진 것은 코드 설계 문제의 결과다. 목을 사용하는 것은 이 문제를 감추기만 할 뿐, 원인을 해결하지 못한다.

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

런던 스타일 테스트가 있는 시스템에 버그가 생기면, 보통 SUT에 버그가 포함된 테스트만 실패한다. 하지만 고전적인 방식이면, 오작동하는 클래스를 참조하는 클라이언트를 대상으로 하는 테스트도 실패할 수 있다. 즉, 하나의 버그가 전체 시스템에 걸쳐 테스트 실패를 야기하는 파급 효과를 초래한다. 결국 문제의 원인을 찾기가 더 어려워진다. 문제를 파악하고자 테스트를 디버깅하는 데 시간이 걸릴 수 있다.

이는 우려할 만 하지만, 큰 문제는 아니다. 테스트를 정기적으로 실행하면 버그의 원인을 알아낼 수 있다. 마지막으로 한 수정이 무엇인지 알기 때문에 문제를 찾는 것은 그리 어렵지 않다. 실패한 테스트를 모두 볼 필요는 없다. 하나를 고치면 다른 것들도 자동으로 고쳐진다.

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

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

테스트 주도 개발

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

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

관련 추천 도서 : Test Driven Development: By Example, Growing Object Oriented Software, Guided by Tests

런던 스타일의 단위테스트는 하향식 TDD로 이루어진다. 전체 시스템에 대한 기대치를 설정하는 상위 테스트 테스트부터 시작한다. 테스트할 때 SUT의 모든 협력자를 차단해 해당 협력자의 구현을 나중으로 미룰 수 있다. 고전파는 실제 객체를 다뤄야 하기 때문에 일반적으로 상향식으로 한다. 고전적 스타일에서는 도메인 모델을 시작으로 최종 사용자가 소프트웨어를 사용할 수 있을 때까지 계층을 그 위에 더 둔다.

또 다른 중요한 자이점은 런던 스타일은 고전 스타일보다 테스트가 구현에 더 자주 결합되는 편이다. 이로 인해 런던 스타일과 목을 전반적으로 아무 데나 쓰는 것에 대해 주로 이의가 제기된다.

2.4 두 분파의 통합 테스트

런던파는 실제 협력자 객체를 사용하는 모든 테스트를 통합 테스트로 간주한다. 고전 스타일로 작성된 대부분의 테스트는 런던파 지지자들에게는 통합 테스트로 느껴질 것이다.

고전파의 관점에서의 단위 테스트는

통합 테스트는 이러한 기준 중 하나를 충족하지 않는 테스트다. 각각의 속성에 대해 반대의 예시를 들면 다음과 같다.

2.4.1 통합 테스트의 일부인 엔드 투 엔드 테스트

엔드 투 엔드 테스트는 통합 테스트의 일부다. 엔드 투 엔드 테스트도 코드가 프로세스 외부 종속성과 함께 어떻게 작동하는지 검증한다. 엔드 투 엔드 테스트와 통합 테스트 간의 차이점은 엔드 투 엔드 테스트가 일반적으로 의존성을 더 많이 포함한다는 것이다.

end-to-end-test-and-integration-test

엔드 투 엔드 테스트는 프로세스 외부 의존성을 전부 또는 대다수 갖고 작동한다. 따라서 엔드 투 엔드라는 명칭은 모든 외부 애플리케이션을 포함해 시스템을 최종 사용자의 관점에서 검증하는 것을 의미한다.

통합테스트는 관리 의존성은 포함하고 비관리 의존성을 테스트 대역으로 대체한다.

관리 의존성과 비관리 의존성
8장 통합 테스트를 하는 이유 (2) - 언제 목을 써야할까? + 예시

  • 관리 의존성 (전체를 제어할 수 있는 프로세스 외부 의존성)
    이러한 의존성은 애플리케이션을 통해서만 접근할 수 있으며, 해당 의존성과의 상호 작용은 외부 환경에서 볼 수 없다. 대표적인 예로 데이터베이스가 있다. 외부 시스템은 보통 데이터베이스에 직접 접근하지 않고 애플리케이션에서 제공하는 API를 통해 접근한다.
  • 비관리 의존성 (전체를 제어할 수 없는 프로세스 외부 의존성)
    해당 의존성과의 상호 작용을 외부에서 볼 수 있다. 예를 들면 SMTP 서버와 메시지 버스 등이 있다. 둘 다 다른 애플리케이션에서 볼 수 있는 사이드 이펙트를 발생시킨다.

엔드 투 엔드 테스트는 유지 보수 측면에서 가장 비용이 많이 들기 때문에 모든 단위 테스트와 통합 테스트를 통과한 후 빌드 프로세스 후반에 실행하는 것이 좋다. 빌드 서버에서만 실행할 수도 있다.

엔드 투 엔드 테스트 환경에서도 테스트 대역이 필요할 수도 있다.

요약