029. (Unit Test Principles) 3. 단위 테스트의 구조

3. 단위 테스트의 구조

원제 : The anatomy of a unit test

이번 포스팅에서는 이전 포스팅에서 다루었던 준비(Arrange), 실행(Act), 검증(Assert) 3단계의 절차인 AAA 패턴 으로 작성된 단위 테스트의 구조를 살펴본다.

추가로 단위 테스트의 명명 방법과 단위 테스트 프로세스를 간소화할 수 있는 프레임워크의 몇 가지 기능에 대해서 알아보도록 하자.

3.1. 단위 테스트의 구조화 방법

원제 : How to structure a unit test

AAA 패턴 기반의 단위 테스트를 구성하는 방법, 피해야하는 방식, 그리고 읽기 쉬운 테스트를 만드는 방법에 대해서 알아보자.

3.1.1. AAA 패턴 사용

원제 : Using the AAA pattern

AAA 패턴은 상술했듯 각 테스트를 준비, 실행, 검증의 세 부분으로 나눌 수 있다.

예를 들어 두 숫자의 합을 계산하는 Calculator 클래스를 가정해보자.

1
2
3
4
5
6
7
public class Calculator
{
public double Sum(double first, double second)
{
return first + second;
}
}

이 클래스를 기반으로 AAA 패턴 기반의 테스트 코드를 작성해보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class CalculatorTests
{
[Fact]
public void Sum_of_two_numbers()
{
// Arrange
double first = 10;
double second = 20;
var calculator = new Calculator();

// Act
double result = calculator.Sum(first, second);

// Assert
Assert.Equal(30, result);
}
}

위의 예시처럼 AAA패턴은 스위트 내 모든 테스트가 단순하고 균일한 구조를 갖는 데 도움을 주며, 이 일관성이 AAA 패턴의 가장 큰 장점 중 하나이다.

준비(Arrange) 에서는 테스트 대상 시스템(TUS)과 해당 의존성을 원하는 상태로 만들고,

실행(Act) 에서는 SUT에서 메서드를 호출하고 준비된 의존성을 전달하며 출력값을 캡쳐하고,

검증(Assert) 에서는 반환 값이나 최종 상태 등의 결과를 검증한다.

이 패턴에 익숙해지면 모든 테스트를 쉽게 읽을 수 있고 이해할 수 있게되며, 이는 결국 테스트 스위트의 유지 보수 비용의 절감으로 이어지게 된다.

Given-When-Then 패턴
AAA와 유사한 Given-When-Then 패턴이 존재한다.
Given - 준비
When - 실행
Then - 검증
테스트 구성 측면에서의 차이는 없으나 일반인에게는 AAA보다 가독성이 더 높은 구조가 될 수 있다.

3.1.2. 여러 개의 준비, 실행, 검증 구절 회피하기

원제 : Avoid multiple arrange, act and assert sections

때로는 아래와 같은 형태의 AAA 구조가 나타날 수 있다.

Figure 3.1

검증 구절로 구분된 여러 개의 실행 구절을 보면, 여러 개의 동작 단위를 검증하는 테스트를 뜻한다.

따라서 이 테스트는 더 이상 “단위 테스트”가 아닌 “통합 테스트”의 범주에 속하게 되었으므로 이러한 구조는 최대한 회피하는 것이 좋다.

단일 AAA 패턴을 가지도록 리팩토링하여 각 동작을 고유의 테스트로 도출하는 것이 맹점이다.

3.1.3. 테스트 내 if문 피하기

원제 : Avoid if statements in tests

준비, 실행, 검증 구절이 여러 차례 나타나는 것과 비슷하게 if 문이 있는 단위 테스트를 만날 수도 있다.

이것은 일종의 안티패턴으로 단위 테스트는 분기가 없는 일련의 AAA 패턴으로만 나타내야 한다.

따라서 if문의 등장은 한 번에 너무 많은 것을 검증한다는 반증이다.

3.1.4. 각 구절은 얼마나 커야 하는가?

원제 : How large should each section be?

AAA 패턴 적용시 각 구절의 크기는 얼마나 커야할까?

그리고 테스트가 끝난 후 종료하는 구절은 어떻게 처리해야할까?

일반적으로 준비 구절이 세 구절 중 가장 크다.

다만 너무 커지게 되면 테스트 클래스 내 비공개 메서드나 별도의 팩토리로 빼놓는 것이 좋다.

실행 구절은 보통 코드 한 줄로 표현된다.

아래 예제를 보자.

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

이 테스트의 실행 구절은 단일 메서드의 호출이며, 이 단일 호출이야말로 잘 설계된 API임을 보여준다.

이번엔 두 줄로 작성된 실행 구절을 살펴보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
[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);
store.RemoveInventory(success, Product.Shampoo, 5);

// Assert
Assert.True(success);
Assert.Equal(5, store.GetInventory(Product.Shampoo));
}

두 줄로 작성된 실행 구절은 SUT에 문제가 있다는 신호이며, 캡슐화가 깨졌음을 의미한다.

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

첫 번째 줄에서는 고객이 샴푸를 5개 구매하는 것만 알 수 있고,

1
store.RemoveInventory(success, Product.Shampoo, 5);

두 번째 줄에서는 재고에서 5개의 샴푸를 소거한다는 의미만 알 수 있다.

결국 첫 번째 줄의 Purchase()가 성공해야하만 두 번째 줄이 수행되므로

리팩토링 혹은 추가 개발을 통해 단일 메서드로 작성하여 테스트에 써야함을 알 수 있게 해준다.

3.1.5. 검증 구절에는 검증문이 얼마나 있어야하는가?

원제 : How many assertions should the assert section hold?

마지막으로 검증 구절이다.

검증 구절은 하나의 테스트당 하나의 검증을 가져야 한다.

이는 가장 작은 코드를 목표로 하는 전제를 기반으로 하는데, 이는 단위 테스트 입장에서는 잘못된 전제이다.

단위 테스트의 단위는 동작을 기준으로 하지 코드를 기준으로 하지 않기 때문이다.

단일 동작 단위는 하나가 아닌 여러 결과를 출력할 수 잇으며, 하나의 테스트 그 모든 결과를 검증하는 것이 좋은 테스트이다.

다만 검증 구절이 너무 커지는 것도 경계해야 한다.

3.1.6. 종료 단계는 어떻게 하는가?

원제 : What about the teardown phase?

통상 AAA 패턴은 준비, 실행, 검증만 다루지만 이후의 종료 구절에 대해서 따로 구분하기도 한다.

예를 들면 테스트를 진행하면서 생성된 파일을 지우거나, 데이터베이스와의 커넥션을 종료하는 식의 후처리를 종료 구절헤서 수행한다.

테스트 코드에서는 이 종료 구절의 별도의 teardown으로 분리되기 때문에 AAA 패턴에 포함되지않는 것이다.

단위 테스트는 프로세스 외부에 종속적이지 않으므로 별다른 부작용은 없으나 통합 테스트에서는 반드시 종료 구절을 처리해야한다.

3.1.7. 테스트 대상 시스템 구별하기

원제 : Differentiating the system under test

테스트에서 SUT는 애플리케이션에서 호출하고자하는 동작에 대한 엔트리 포인트를 제공한다.

단위 테스트에서 “동작”은 여러 클래스에 걸쳐 있을 수도 있고, 단일 메서드로 존재할 수도 있지만 진입점은 오직 하나만 존재할 수 있다.

따라서 SUT를 의존성과 구분하는 것은 매우 중요하며, SUT가 많은 경우 테스트 대상을 찾는 데 시간이 꽤 걸릴 수도 있다.

이를 방지하기 위한 간단한 방법으로 SUT의 이름을 sut로 지정하는 방법이 있다.

아래 예제를 보자.

1
2
3
4
5
6
7
8
9
10
11
12
public class CalculatorTests
{
[Fact]
public void Sum_of_two_numbers()
{
double first = 10;
double second = 20;
var sut = new Calculator();
double result = sut.Sum(first, second);
Assert.Equal(30, result);
}
}

위의 예제는 인스턴스의 이름을 sut로 바꾸어 좀 더 명백해진 진입점을 보여준다.

3.1.8. 준비, 실행, 검증 구절의 주석 제거하기

원제 : Dropping the arrange, act, and assert comments from tests

테스트 내에서도 각 코드들이 어떤 구절에 속하는지 파악하는 데 드는 비용을 최소화하는 것이 중요하다.

가장 간단한 방법이 여태까지 예제들이 그러했듯 주석을 다는 것이고, 또 다른 방법은 빈 줄로 영역을 명시적으로 구분하는 것이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class CalculatorTests
{
[Fact]
public void Sum_of_two_numbers()
{
double first = 10;
double second = 20;
var sut = new Calculator();

double result = sut.Sum(first, second);

Assert.Equal(30, result);
}
}

이렇게 구절 간 영역을 구분하면 간결성과 가독성 두 개를 모두 획득할 수 있다.

아래 원칙대로 작성하는 것을 습관화하자.

  • AAA 패턴을 따르고 준비 및 검증 구절에 빈 줄을 추가하지않아도 되는 테스트라면 주석들을 제거하라.
  • 그렇지 않은 경우 주석을 유지하라.

3.4. 단위 테스트의 명명법

원제 : Naming a unit test

테스트에 표현력이 있는 네이밍을 부여하는 것은 매우 중요한 일이다.

테스트의 이름이 올바를 수록 테스트를 통해 검증하려는 내용과 기본 시스템의 동작을 이해하는 데 도움이 되기 때문이다.

아래의 명명규칙은 가장 유명한 명명법이긴 하지만 가장 도움이 되지않는 명명법이다.

[테스트 대상 메서드]_[시나리오]_[예상결과]

  • 테스트 대상 메서드 : 테스트 중인 메서드의 이름
  • 시나리오 : 메서드를 테스트하는 조건
  • 예상 결과 : 현재 시나리오에서 테스트 대상 메서드에 기대하는 것

이러한 명명법은 동작 대신 테스트 코드를 세밀하게 작성하는 것에 집중하게 하기때문에 도움이 되지않는다.

단위 테스트는 간단하고 쉬운 구문이 더욱 효과적이며, 엄격한 명명법에 얽매일 필요가 없다.

아래 예제를 보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class CalculatorTests
{
[Fact]
public void Sum_of_two_numbers()
{
double first = 10;
double second = 20;
var sut = new Calculator();

double result = sut.Sum(first, second);

Assert.Equal(30, result);
}
}

위 예제의 메서드만 보면 두 개의 숫자를 합치는 동작을 기대하는 테스트 코드임을 확인할 수 있다.

만약 이 테스트를 위의 명명법으로 작성한다면 아래와 같디 될 것이다.

1
public void Sum_TwoNumbers_returnsSum()

Sum은 왜 두번이나 출현했는가? 합계는 어디로 반환되는가? 등의 의문이 들기때문에 좋은 명명법이라고 볼 수 없다.

3.4.1. 단위 테스트 명명에 대한 가이드라인

원제 : Unit test naming guidelines

표현력있고 읽기 쉬운 테스트를 명명하기 위해 아래의 가이드라인을 추천한다.

  • 엄격한 명명 정책을 따르지 않는다. 복잡한 동작에 대한 높은 수준의 설명을 좁은 사이즈의 정책에 가두어선 안된다.
  • 문제 도메인에 익숙한 비개발자들에게 시나리오를 설명하는 것처럼 명명한다.
  • 단어를 밑줄 표시로 구분하여, 긴 이름에서의 가독성을 확보하라.

3.4.2. 예제 : 단위 테스트 명명에 대한 가이드라인

원제 : Example: Renaming a test toward the guidelines

위의 지침에 맞추어서 개선 과정을 살펴볼 수 있는 예제를 살펴보자.

1
2
3
4
5
6
7
8
9
10
11
12
[Fact]
public void IsDeliveryValid_InvalidDate_ReturnsFalse()
{
DeliveryService sut = new DeliveryService();
DateTime pastDate = DateTime.Now.AddDays(-1);
Delivery delivery = new Delivery
{
Date = pastDate
};
bool isValid = sut.IsDeliveryValid(delivery);
Assert.False(isValid);
}

이 테스트는 DeliveryService가 잘못된 날짜의 배송을 올바르게 식별하는지 검증하는 코드이다.

테스트 이름을 좀 더 쉬운 영어로 작성해보면 아래와 같다.

1
2
3
4
// AS-IS
public void IsDeliveryValid_InvalidDate_ReturnsFalse()
// TO-BE
public void Delivery_with_invalid_date_should_be_considered_invalid()

개선한 이름은 개발자도, 비개발자도 이해할 수 있게 되었고 무엇보다 메서드 이름이 포함되지않게 되었다.

그럼 이제 검증 대상에 대해서도 고민해보자.

코드를 보면 배송 날짜의 무효를 검증한다는 것은 미래의 배송날짜가 아님을 뜻하는 것을 알 수 있다.

바꿔말해, 유효한 배송 날짜는 결국 오늘보다 미래의 날짜여야 한다는 것이다.

이를 테스트 이름에 반영해보자.

1
2
3
4
// AS-IS
public void Delivery_with_invalid_date_should_be_considered_invalid()
// TO-BE
public void Delivery_with_past_date_should_be_considered_invalid()

조금 더 나아졌지만 장황한 면이 있다.

considered를 제거해도 의미가 변하지않으므로 제거해보자.

1
2
3
4
// AS-IS
public void Delivery_with_past_date_should_be_considered_invalid()
// TO-BE
public void Delivery_with_past_date_should_be_invalid()

should be 또한 안티패턴의 일종으로 원자적인 테스트 단위를 보장하기 위해 is로 치환할 수 있다.

1
2
3
4
// AS-IS
public void Delivery_with_past_date_should_be_invalid()
// TO-BE
public void Delivery_with_past_date_is_invalid()

마지막으로 관사를 붙여서 완벽한 테스트명을 부여해보자.

1
2
3
4
// AS-IS
public void Delivery_with_past_date_is_invalid()
// TO-BE
public void Delivery_with_a_past_date_is_invalid()

3.5. 매개변수화된 테스트를 리팩토링하기

원제 : Refactoring to parameterized tests

보통 테스트 하나로는 동작의 단위를 설명하기 어렵다.

동작의 단위란 일반적으로 여러 구성 요소를 포함하며, 각 구성 요소는 자체 테스트로 캡쳐되어야 한다.

동작의 복잡도가 올라갈수록, 이를 서명하는 테스트도 비례해서 증가할 수 있으며 이는 곧 관리의 어려움으로 이어지게 된다.

대부분의 단위 테스트 프레임워크는 매개변수화된 테스트(parameterized test) 를 사용해 유사한 테스트를 묶을 수 있는 기능을 제공한다.

Figure 3.2

위의 예제를 좀 더 발전시켜서, 가장 빠른 배송일이 오늘로부터 이틀 후가 되도록 작동하는 배송 기능이 있다고 가정해보자.

이 기능의 검증을 위해서는 여러 테스트가 필요하다.

지난 배송일에 대한 검증 외에 오늘 날짜, 내일 날짜, 그 이후에 날짜까지 확인하는 테스트까지 총 4개가 필요해진다.

1
2
3
4
public void Delivery_with_a_past_date_is_invalid()
public void Delivery_for_today_is_invalid()
public void Delivery_for_tomorrow_is_invalid()
public void The_soonest_delivery_date_is_two_days_from_now()

테스트 메서드는 4가지인데, 유일한 차이점은 배송 날짜뿐이다.

공통 부분이 있으므로 테스트를 매개변수화된 테스트 기능을 이용해 하나로 묶어 코드의 양을 줄일 수도 있다.

아래 예제를 보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class DeliveryServiceTests
{
[InlineData(-1, false)]
[InlineData(0, false)]
[InlineData(1, false)]
[InlineData(2, true)]
[Theory]
public void Can_detect_an_invalid_delivery_date(int daysFromNow, bool expected)
{
DeliveryService sut = new DeliveryService();
DateTime deliveryDate = DateTime.Now.AddDays(daysFromNow);
Delivery delivery = new Delivery
{
Date = deliveryDate
};

bool isValid = sut.IsDeliveryValid(delivery);

Assert.Equal(expected, isValid);
}
}

이제 4가지 테스트는 별도의 테스트가 아니라 [InlineData] 라인으로 표현하였다.

매개변수를 통해 판정하므로 더 이상 주어진 날짜가 유효한가 무효한가에 대해서는 언급할 필요없이 일반적인 테스트가 되었다.

코드의 양은 줄었어도, 테스트 메서드가 표현하는 것이 무엇인지 파악하는 것은 더욱 어려워졌다.

이를 더욱 개선하기 위해 긍정적인 테스트 케이스를 별도로 분리하는 것도 좋은 방법이다.

아래의 예제는 유효한 배송 날짜와 유효하지 않은 배송 날짜를 구별하는 테스트를 분리한 것이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class DeliveryServiceTests
{
[InlineData(-1)]
[InlineData(0)]
[InlineData(1)]
[Theory]
public void Detects_an_invalid_delivery_date(int daysFromNow)
{
/* ... */
}
[Fact]
public void The_soonest_delivery_date_is_two_days_from_now()
{
/* ... */
}
}

테스트 코드의 양과 그 코드의 가독성은 서로 상충되므로 긍정적인 테스트 케이스와 부정적인 테스트 케이스 모두 하나의 메서드로 두는 것이 좋다.