028. (Unit Test Principles) 2. 단위 테스트란 무엇인가

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

원제 : What is a unit test?

이전 포스팅에서 언급했듯이 단위 테스트는 아주 미묘한 차이로 수많은 해석이 될 수 있다.

해석의 차이가 생긴만큼, 단위 테스트에 접근하는 방법도 나뉘게 된다.

크게 두 가지 견해로 분류하는데 각각 고전파(classical school)런던파(london school) 로 불린다.

고전파는 모든 사람이 단위 테스트와 테스트 주도 개발에 원론적으로 접근하는 방식이며,

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

단위 테스트의 정의가 고전파와 런던파를 구분짓는 기준이므로 상세히 다루어보자.

고전파의 단위 테스트
고전적 접근법, 고전주의적 접근법이라고 한다.
켄트 백의 “테스트 주도 개발”이 가장 고전적인 서적이라고 할 수 있으며, 이 접근법을 디트로이트(Detroit)라고도 부른다.

런던파의 단위 테스트
Mock의 활용에 중점을 두고 있기에 Mockist 라고도도 부른다.
스티브 프리먼과 냇 프라이스의 “테스트 주도 개발로 배우는 객체 지향 설계와 실천”이 가장 유명한 런던 스타일 서적이다.

2.1. “단위 테스트”의 정의

원제 : The definition of “unit test”

단위 테스트에는 많은 정의가 존재한다.

단위 테스트를 정의하는데 가장 중요한 속성들을 나열해보자면

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

를 의미한다.

위 속성들 중에 마지막 속성이 자동화된 테스트의 “격리”가 논쟁의 대상이다.

이 코드의 격리 문제가 단위 테스트에서의 고전파와 런던파를 구분할 수 있게 해주는 차이점이다.

즉, 두 분파의 차이들은 논쟁의 대상인 격리가 정확히 무엇인지에 대한 의견 차이 하나로 시작되었다.

2.1.1. 런던파가 접근하는 격리 문제

원제 : The isolation issue: The London take

위에서 코드 조각을 “단위”라고 언급하였다.

이 코드 조각을 격리된 방식으로 검증한다는 것은 어떤 의미일까?

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

좀 더 구체적으로는 하나의 클래스가 다른 단일 혹은 여러 클래스에 의존하는 경우, 이 모든 의존성을 테스트 대역으로 대체하는 것을 의미한다.

이를 통해 테스트 대상인 클래스의 동작을 외부 영향을 분리해서 테스트 대상 클래스에만 집중할 수 있다.

Figure 2.1

위 그림은 격리가 어떤 식으로 일어나는 지 간단하게 표현한 그림이다.

테스트 대상 클래스가 두 개의 의존성을 가지고 있는데, 이 의존성을 테스트 대역으로 교체한 뒤 테스트를 수행하는 방식이다.

이 테스트 안에서 문제가 발생한다면 확실하게 테스트 대상 클래스가 문제임을 추론할 수 있게 된다.

또한 프로젝트에 적용할 정책으로 한 번에 하나의 클래스만 테스트하도록 한다면, 더 이상 코드 베이스의 테스트에 대한 고민을 할 필요가 없다.

Figure 2.2

위 그림처럼 하나의 클래스에 대해서 하나의 테스트 코드가 대응하는 방식으로 작업이 가능하기 때문이다.

이번엔 코드로 작성된 예제를 살펴보자.

어떠한 온라인 상점이 있고, 이 상점에서 사용자가 물건을 구매하는 간단한 use case가 존재한다.

상점에 재고가 충분하면 구매 시나리오는 성공하며, 충분하지않다면 구매 시나리오는 실패하게 되고 상점의 상태는 아무런 변화가 없다고 가정한다.

참고 : 아래와 같이 준비(Arrange), 실행(Act), 검증(Assert) 과 같은 3단계의 절차를 AAA 패턴 이라고 한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
[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
}

위 코드는 단위 테스트의 고전 스타일의 한 예시이다.

다만 테스트 코드에서 의존하고 있는 CustomerStore 내부의 안정성에 대해선 검증되지 않았으며, Store의 버그가 Customer에도 영향을 줄 수 있는 구조이다.

즉 이 클래스들은 서로 격리되어있지않다.

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

동일한 테스트 코드를 Store를 테스트 대역 즉, Mock으로 구현하는 방식이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
[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
);
}

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

고전 스타일과 다른 점을 찾아본다면,

준비 단계에서 Store의 실제 인스턴스를 생성하지 않고 Mock을 이용해 대체하였다.

이후 호출되는 각 함쉐의 응답값에 대해서도 Mock에 정의하여 실제 Store의 의존성을 끊어버리는 방식이다.

고전 스타일과는 달리 Store에 대한 검증이 없고 단순히 CustomerStore간의 상호 관계에 대해서만 검증하게 된다.

2.1.2. 고전파가 접근하는 격리 문제

원제 : The isolation issue: The classical take

위의 런던파가 접근하는 격리 문제에서 이미 고전파 스타일의 테스트 코드도 살펴보았다.

다시 정리해본다면 런던파는 테스트 대역(Mock)으로 테스트 대상 코드를 조각조각 분리해서 격리를 달성하고 있음을 확인하였다.

고전파는 꼭 코드를 논리적 혹은 물리적으로 격리하는 방식으로 테스트하진 않는다.

대신 테스트를 서로 격리된 상태에서 실행하는 것을 추종하며, 격리된 상태이기에 어떤 순서로 테스트하더라도 서로 영향을 주지 않는다.

이렇게 테스트를 각각 격리한다는 것은 여러 클래스의 테스트를 동시에 실행해도 괜찮다는 뜻이며,

이를 통해 테스트가 다른 테스트와 서로 소통하고 실제 실행 컨텍스트에 영향을 줄 수도 있다.

예를 들어 데이터 삭제 테스트는 데이터 생성 테스트가 먼저 선행되어야 정상 동작하는 것을 들 수 있다.

아래는 테스트간 의존성에 대한 정의이다.

공유의존성(shared dependency)
테스트간에 공유되고 서로의 결과에 영향을 미칠수 있는 수단을 제공하는 의존성을 뜻한다.
가장 대표적인 예시는 정적 가변 필드(static mutable field)로, 동일한 프로세스 내에 위치하는 모든 단위 테스트에서 볼 수 있다.
비슷한 사례로 데이터베이스나 파일시스템도 공유 의존성의 예시가 될 수 있다.

비공개 의존성(private dependency)
말 그대로 테스트간에 공유되지않는 의존성을 뜻한다.

프로세스 외부 의존성(out-of-process dependency)
애플리케이션의 실행 프로세스 외부에서 실행되는 의존성을 뜻한다.
프로세스 외부 의존성은 아직 메모리에 없는 데이터에 대한 프록시로, 대부분 공유 의존성에 해당된다.
환경에 따라서 데이터베이스는 프로세스 외부 의존성이면서 비공개 의존성의 특징을 가지기도 한다.

Figure 2.3

위 그림은 각각의 의존성에 대해 나타낸 그림이다.

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

원제 : The classical and London schools of unit testing

상술했듯, 런던파와 고전파로 나누어지는 차이는 격리의 특성에 있다.

런단파는 테스트 대상 시스템에서 협력자를 격리하는 것으로 보는 반면, 고전파는 단위 테스트끼리 격리하는 것으로 본다.

이 차이로 인해 단위 테스트에 접근하는 방법을 두고 의견이 크게 갈리게 되었으며 표로 나타내면 아래와 같다.

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

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

원제 : How the classical and London schools handle dependencies

테스트 대역은 어디서나 흔하게 사용할 수 있지만, 런던파의 경우 일부 의존성을 그대로 사용할 수 있도록 하고 있다.

절대 변하지 않음이 보장되는 불변 객체를 교체하지않아도 되는 것이 대표적인 사례이다.

다시 런던파의 테스트 코드 예제를 가져와보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
[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
);
}

위의 코드에서 Customer가 가진 두 가지 의존성 StoreProduct를 살펴보자.

Store는 내부의 상태에 변할 수 있지만, Product는 변하지않음을 확인할 수 있다.

즉, Product는 불변이므로 Store만 mock으로 대체해도 상관없는 것이다.

이러한 불변 객체를 값 객체(VO : Value Object) 혹은 그대로 값(Value) 라고도 한다.

아래 그림은 의존성의 종류와 단위 테스트의 두 분파가 어떻게 의존성을 처리하는 지 나타낸 그림이다.

Figure 2.3

2.3. 고전파와 런던파의 비교

원제 : Contrasting the classical and London schools of unit testing

고전파와 런던파를 가르는 격리 관련 차이점은 결국 테스트해야할 단위의 처리와 의존성 취급에 대한 방법으로 이어지게 된다.

먼저 고전파는 고품질의 테스트를 만들고 단위 테스트의 목표인 프로젝트에 지속 가능한 성장을 달성하는 데 적합하다.

또한 런던파의 Mock을 사용하는 테스트에는 취약성이 존재하여 불안정한 경향이 존재하기도 한다.

그렇다면 고전파만이 살아남아야할텐데, 런던파도 살아남은 것을 보면 분명히 런던파의 장점도 존재하는 것을 추론할 수 있다.

런던파는 아래와 같은 이점을 가지고 있다.

  1. 입자성(granularity)이 좋다. 세분화된 테스트로 한 번에 하나의 클래스만 확인한다.
  2. 모든 협력자를 테스트 대역으로 대체하여 테스트 작성이 용이하다.
  3. 테스트 실패시 어떤 기능이 실패했는지 확실하게 알 수 있다.

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

원제 : Unit testing one class at a time

런던파는 클래스를 단위로 간주하므로, 객체지향 프로그래밍을 기반으로하는 개발자들은 자연스럽게 클래스를 테스트에서 검증할 단위로 취급하게 된다.

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

원제 : Unit testing a large graph of interconnected classes

협력자를 대신해 Mock을 사용하면 클래스를 쉽게 테스트할 수 있다.

특히 의존하는 클래스에 또 다른 의존성이 존재하는 경우 그래프가 매우 복잡해지는데, 이때 테스트 대역을 써서 복잡도를 낮출 수 있다.

대체로 테스트할 클래스의 그래프가 커진 것은 잘못된 코드 설계의 결과로 좋은 부정 지표로 해석할 수 있다.

2.3.3. 정확한 버그 발생 위치 찾기

원제 : Revealing the precise bug location

런던 스타일 테스트는 시스템에 버그가 생기면 SUT 에서 버그가 포함된 테스트만 실패한다.

반면 고전 스타일 테스트는 오작동하는 클래스를 참조하는 테스트도 실패할 수도 있다.

즉 하나의 버그가 시스템 전반에 걸쳐 테스트 실패를 야기할 수도 있다는 뜻이며, 이는 버그를 찾는데 더 많은 시간을 할애해야함을 방증한다.

다만 테스트를 정기적으로 수행하고 있었다면 최근 변경사항을 통해 파악 범위를 대폭 줄일 수 있다.

참고 XUnit Pattern에서 다루는 용어는 아래와 같다.

  • SUT : System Under Test
  • AUT : Application Under Test
  • MUT : Method Under Test
  • CUT : Class Under Test
  • OUT : Object Under Test

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

원제 : Other differences between the classical and London schools

이제 고전파와 런던파 사이에 언급할 차이점은 두 가지가 남았다.

하나는 테스트 주도 개발(TDD : Test-Driven Development) 이고 하나는 과도한 명세(over-specification) 이다.

런던 스타일의 단위 테스트는 하향식 TDD로 이루어지며, 전체 시스템에 대한 기채리를 설정하는 상위 레벨 테스트부터 시작한다.

Mock을 사용해 예상된 결과를 달성하고자, 시스템이 통신해야하는 협력자를 지정하며 모든 클래슬르 구현할때까지 클래스 그래프를 구체화한다.

Mock은 한 번에 하나의 클래스에만 집중하므로 이 프로세스를 가능케한다.

간단하게 정리하면 테스트시 SUT의 모든 협력자를 차단해 해당 협력자의 구현체없이도 테스트를 가능케한다.

반대로 고전 스타일의 테스트는 실제 객체를 다뤄야한다.

따라서 상향식 테스트가 주로 이루어지며, 도메인 모델을 시작으로 사용자가 소프트웨어를 사용할 수 있을 때까지 계정을 그 위에 둔다.

하지만 가장 중요한 차이점은 TDD가 아니라 과도한 명세 문제이다.

과도한 명세 문제란, 테스트가 SUT의 구현 세부사항에 결합되는 것으로 고전 스타일보다 런던 스타일이 좀 더 결합도가 높은 경향을 보인다.

2.4. 고전파와 런던파의 통합 테스트

원제 : Integration tests in the two schools

고전파와 런던파는 통합 테스트의 정의에도 차이점이 존재한다.

런던파는 실제 협력자 객체를 사용하는 모든 테스트를 통합 테스트로 간주한다.

따라서 고전 스타일로 작성된 대부분의 테스트는 런던파 지지자들에게 통합테스트처럼 느껴진다.

이쯤에서 자동화 기반의 단위 테스트의 속성들을 다시 정리해보자.

  • 작은 코드 조각을 검증하고
  • 빠르게 수행하고
  • 격리된 방식으로 처리한다.

이 속성들을 고전파의 관점에서 재작성해보면 아래와 같다.

  • 단일 동작 단위를 검증하고
  • 빠르게 수행하고
  • 다른 테스트와 별도로 처리한다.

이때 마지막 속성으로 인해, 통합테스트는 단위 테스트의 기준을 충족하지 않게 된다.

공유 의존성과 선행 조건을 가진 테스트는 단위 테스트의 조건에 위배되기 때문이다.

마찬가지로 프로세스 외부 의존성에 접근하는 순간 “빠르게” 테스트를 수행하는 것은 점진적으로 어려워지게 된다.

통상 테스트에 1초이상이 허비되면 빠르다고 보지 않기 때문이다.

이 통합 테스트는 추후 자세히 다루어보도록 하겠다.