036. (Unit Test Principles) 10. 단위 테스트 안티 패턴

10. 단위 테스트 안티 패턴

원제 : Unit testing anti-patterns

이번 포스팅은 단위 테스트 원칙의 마지막 포스팅으로, 안티 패턴에 대해서 다룬다.

안티 패턴(Anti-Pattern) 은 소프트웨어 공학 용어로, 실제로는 많이 사용되는 패턴이지만 비효율적이거나 비생산적인 패턴을 뜻한다.

단위 테스트 측면에서의 안티 패턴은 겉으로는 잘 드러나지않고 그럴싸한 것처럼 보이지만, 나중에 더 큰 문제로 발견되는 반복적인 문제에 대한 해결책을 말한다.

이번 포스팅에서는 테스트에서 어떻게 시간을 다루는 지 살펴보고, 비공대 메서드에 대한 단위 테스트, 코드의 오염, 구현체 클래스의 Mock 처리 등과 같은 안티 패턴을 나열해보고 이를 회피하는 방법에 대해 알아보도록 하자.

10.1. 비공개 메서드에 대한 단위 테스트

원제 : Unit testing private methods

단위 테스트를 작성하다보면 자주 겪는 문제가 있다.

바로 비공개 메서드(private method) 를 어떻게 테스트하는 가에 대한 문제이다.

결론부터 먼저 말하자면 전혀 할 필요가 없다 이다.

좀 더 자세히 알아보자.

10.1.1. 비공개 메서드와 테스트 취약성

원제 : Private methods and test fragility

단위 테스트를 하려고 비공개 메서드드를 노출해도 될까?

이는 식별가능한 동작만 테스트해야 한다는 원칙에 위배된다.

비공개 메서드를 노출하게 되면 테스트가 구현 세부 사항과 결합되게 되고, 이는 리팩토링 내성을 낮추는 결과를 야기한다.

따라서 비공개 메서드를 직접 테스트하는 대신, 이를 포괄하는 식별가능한 동작을 테스트하여 간접적으로 테스트하는 것이 좋다.

10.1.2. 비공개 메서드와 불필요한 커버리지

원제 : Private methods and insufficient coverage

때로는 비공개 메서드가 너무 복잡해서, 식별가능한 동작으로 간접적으로 테스트하기에 충분한 커버리지를 얻을 수 없는 경우가 있다.

만약 식별가능한 동작에 이미 합리적인 테스트 커버리지가 있다고 가정한다면 아래와 같은 두 가지 문제점이 발생할 수 있다.

  • 데드 코드인 경우이다. 테스트에서 벗어난 코드가 어디에도 사용되지않는다면 리팩토링 후에도 관계없는 코드일 수 있다. 이러한 코드는 삭제하는 것이 좋다.
  • 추상화가 누락되어 있는 경우이다. 너무 높은 복잡도를 가진 비공개 메서드는 별도의 클래스로 추출해야하는 추상화가 누락되었다는 징조이다.

추상화가 누락되어 있는 경우에 대해서 예제를 통해 살펴보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class Order {
private Customer _customer;
private List<Product> _products;

public string GenerateDescription() {
// The complex private method is used by a much simpler public method.
return $"Customer name: {_customer.Name}, " +
$"total number of products: {_products.Count}, " +
$"total price: {GetPrice()}";
}

// Complex private method
private decimal GetPrice() {
decimal basePrice = /* Calculate based on _products */;
decimal discounts = /* Calculate based on _customer */;
decimal taxes = /* Calculate based on _products */;
return basePrice - discounts + taxes;
}
}

GenerateDescription() 메서드는 어떤 주문에 대한 일반적인 설명을 반환하는 간단한 메서드이다.

하지만 내부적으로 복잡한 비공개 메서드인 GetPrice()를 사용하고 있다.

GetPrice() 메서드는 중요한 비즈니스 로직이기 때문에 철저한 테스트가 필요하지만, 추상화가 누락되었기 때문에 GenerateDescription() 메서드를 통해 간접적으로 테스트할 수 밖에 없다.

이 경우 GetPriceI()를 노출하는 것보다는 아래와 같이 별도의 클래스로 도출해서 명시적으로 작성하는 것이 좋다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class Order {
private Customer _customer;
private List<Product> _products;

public string GenerateDescription() {
var calc = new PriceCalculator();

return $"Customer name: {_customer.Name}, " +
$"total number of products: {_products.Count}, " +
$"total price: {calc.Calculate(_customer, _products)}";
}
}

public class PriceCalculator {
public decimal Calculate(Customer customer, List<Product> products) {
decimal basePrice = /* Calculate based on products */;
decimal discounts = /* Calculate based on customer */;
decimal taxes = /* Calculate based on products */;
return basePrice - discounts + taxes;
}
}

PriceCalculator 클래스로 도출하였기 때문에 Order 클래스와는 별개로 테스트를 수행할 수 있게 되었다.

PriceCalculator 클래스에는 숨은 입출력이 따로 없으므로, 출력 기반 스타일의 단위 테스트를 적용하는 것도 방법이다.

10.1.3. 비공개 메서드에 대한 테스트가 필요한 경우

원제 : When testing private methods is acceptable

비공개 메서드를 절대 테스트하지말라는 원칙에도 예외는 있다.

다시 한 번 코드의 공개 여부와 목적의 관계의 표를 가져와 보자.

구분 식별가능한 동작 식별불가능한 동작
공개 좋음 나쁨
비공개 해당 없음 좋음

참고 031. (Unit Test Principles) 5. Mock과 테스트 취약성

앞선 포스팅에서 다룬 내용을 상기해보자.

잘 설계된 Api란 식별가능한 동작을 공개로 하고, 구현 세부 가항을 비공개로 하는 것이라고 하였다.

반면에 구현 세부 사항이 유출되면 코드 캡슐화를 깨뜨리게 된다.

비공개 메서드를 테스트하는 것 자체는 나쁘지 않지만 비공개 메서드가 구현 세부 사항의 프록시에 해당하고, 구현 세부 사항을 테스트하면 테스트가 깨질 가능성이 높아지기때문에 권장되지 않는다.

다행히도 비공개 메서드이면서 식별 가능한 동작인 경우는 드물다.

이번엔 새로운 예제를 살펴보자.

아래 예제는 신용 조회를 관리하는 시스템으로 하루에 한 번 데이터베이스에 직접 대량으로 새로운 조회(Inquiry)가 로드되며, 관리자는 그 신용 조회를 하나씩 검토하고 승인 여부를 결정한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class Inquiry {
public bool IsApproved { get; private set; }
public DateTime? TimeApproved { get; private set; }

// Private constructor
private Inquiry(bool isApproved, DateTime? timeApproved) {
if (isApproved && !timeApproved.HasValue)
throw new Exception();
IsApproved = isApproved;
TimeApproved = timeApproved;
}

public void Approve(DateTime now) {
if (IsApproved)
return;

IsApproved = true;
TimeApproved = now;
}
}

Inquiry 클래스는 ORM 라이브러리의 의해 데이터베이스에서 클래스가 복원되기때문에 비공개 생성자를 가지고 있다.

즉 공개 생성자가 필요없는 클래스이다.

이처럼 객체를 생성할 수 없는 클래스의 경우 어떻게 테스트해야할까?

중요한 비즈니스 로직이므로 테스트는 해야하고, 비공개 생성자를 공개하는 것은 테스트 원칙에 위배되는 딜레마에 빠지게 된다.

이처럼 Inquiry 클래스는 비공개이면서 식별가능한 동작인 메서드의 예시이다.

이때 Inquiry 클래스의 생성자를 공개한다고 해서 테스트가 쉽게 깨지지는 않는다.

다만 생성자가 캡슐화를 지키는 데 필요한 전제 조건이 모두 포함되어있는 지 검증하는 것이 필요하다.

완전히 다른 방법으로는 리플렉션을 이용해 객체를 생성하는 것도 검토해볼 수 있다.

10.2. 비공개 상태 노출하기

원제 : Exposing private state

단위 테스트 목적으로만 비공개 상태를 노출하는 안티 패턴도 존재한다.

이 지침은 비공개로 유지해야하는 상태를 노출하지 말고, 식별가능한 동작만 테스트하라는 비공개 메서드 지침과 같다.

아래 예제를 보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class Customer {
private CustomerStatus _status = CustomerStatus.Regular; // Private state

public void Promote() {
_status = CustomerStatus.Preferred;
}

public decimal GetDiscount() {
return _status == CustomerStatus.Preferred ? 0.05m : 0m;
}
}

public enum CustomerStatus {
Regular,
Preferred
}

Customer 클래스의 _status는 private이므로 비공개인 상태이다.

이 상황에서 Promote() 메서드를 테스트하려면 어떻게 해야할까?

이 메서드의 부작용은 _status 속성의 변경이지만 비공개 속성이므로 테스트할 수가 없다.

Promote() 메서드의 목적에 맞게 테스트하기 위해 _status를 공개해야할까?

하지만 테스트는 제품 코드와 정확히 같은 방식으로 테스트해야하기 때문에 이는 안티 패턴이다.

그렇다면 어떻게 테스트해야할까?

그 방법은 제품 코드가 Customer 클래스를 어떻게 사용하는지 살펴보는 것이다.

위 예제에서 제품 코드는 _status의 상태를 신경쓰지않는다. 다만 고객이 받은 할인의 비율만이 궁금할 뿐이라는 걸 파악할 수 있다.

10.3. 테스트로 도메인 지식이 유출되는 경우

원제 : Leaking domain knowledge to tests

흔한 안티 패턴으로 도메인 지식이 테스트로 유출되는 경우를 들 수 있다.

보통 복잡한 알고리즘을 다루는 테스트에서 발생한다.

아래 계산 알고리즘 예제를 살펴보자.

1
2
3
4
5
public static class Calculator {
public static int Add(int value1, int value2) {
return value1 + value2;
}
}

아래는 위 예제에 대한 잘못된 테스트 방법을 보여준다.

1
2
3
4
5
6
7
8
9
10
11
12
13
public class CalculatorTests {

[Fact]
public void Adding_two_numbers() {
int value1 = 1;
int value2 = 3;
int expected = value1 + value2; // The leakage

int actual = Calculator.Add(value1, value2);

Assert.Equal(expected, actual);
}
}

Add() 메서드의 비즈니스 로직이 value1 + value2 코드를 통해 유출되었음을 확인할 수 있다.

테스트를 매개변수화하더라도 마찬가지이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
public class CalculatorTests {
[Theory]
[InlineData(1, 3)]
[InlineData(11, 33)]
[InlineData(100, 500)]
public void Adding_two_numbers(int value1, int value2) {
int expected = value1 + value2; // The leakage

int actual = Calculator.Add(value1, value2);

Assert.Equal(expected, actual);
}
}

결국 두 개의 테스트 예제 모두 비즈니스 로직이 유출되었다.

이러한 테스트 방식은 구현 세부 사항과 결합된 거나 마찬가지라서 리팩토링 내성이 거의 없다고 볼 수 있으며,

이러한 테스트는 테스트 실패의 식별과 거짓 양성을 구별할 수 없게 된다.

올바르게 테스트하려면 아래와 같이 결과값까지 넣은 상태로 알고리즘을 검증하는 것이 좋다.

1
2
3
4
5
6
7
8
9
10
public class CalculatorTests {
[Theory]
[InlineData(1, 3, 4)]
[InlineData(11, 33, 44)]
[InlineData(100, 500, 600)]
public void Adding_two_numbers(int value1, int value2, int expected) {
int actual = Calculator.Add(value1, value2);
Assert.Equal(expected, actual);
}
}

위처럼 단위 테스트에서는 예상 결과를 하드코딩해서 처리하는 것이 좋다.

다소 복잡한 알고리즘의 경우, 리팩토링을 하기 전의 레거시 코드에서 입력값과 결과값을 뽑아 테스트 셋으로 넣은 후

리팩토링 후 테스트로 검증하는 것도 좋은 방법이다.

10.4. 코드의 오염

원제 : Code pollution

다음 안티 패턴은 코드의 오염이다.

코드의 오염이란 제품 코드에 테스트에만 필요한 코드를 추가하는 것을ㅇ ㅢ미한다.

코드 오염은 종종 다양한 형태의 스위치 형태를 보여준다.

아래 Logger 예제를 보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class Logger {
private readonly bool _isTestEnvironment;

// The switch
public Logger(bool isTestEnvironment) {
_isTestEnvironment = isTestEnvironment;
}

public void Log(string text) {
if (_isTestEnvironment) // The switch
return;
/* Log the text */
}
}

public class Controller {
public void SomeMethod(Logger logger) {
logger.Log("SomeMethod is called");
}
}

Logger 클래스에는 실제로 운영 환경에서 실행되는 지 여부를 나타내는 _isTestEnvironment 매개변수가 주어진다.

이 매개변수를 이용해 부울 스위치(Boolean switch) 를 사용하는 코드의 경우

아래와 같이 테스트 코드에서 Logger를 비활성화할 수 있다.

1
2
3
4
5
6
7
8
9
[Fact]
public void Some_test() {
var logger = new Logger(true);
var sut = new Controller();

sut.SomeMethod(logger);

/* assert */
}

코드 오염의 문제는 테스트 코드와 제품 코드가 혼재되어 유지보수 비용이 증가하는 데 있다.

코드 오염에 해당하는 안티 패턴을 방지하려면 테스트 코드를 명시적으로 제품 코드와 분리해야할 필요가 있다.

Logger의 예제에서는 ILogger 인터페이스를 활용해 두 가지 구현체를 작성하면 된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public interface ILogger {
void Log(string text);
}

// Belongs in the production code
public class Logger : ILogger {
public void Log(string text) {
/* Log the text */
}
}

// Belongs in the test code
public class FakeLogger : ILogger {
public void Log(string text) {
/* Do nothing */
}
}

public class Controller {
public void SomeMethod(ILogger logger) {
logger.Log("SomeMethod is called");
}
}

위와 같이 분리하면 더 이상 다른 환경을 설명할 필요가 없어진다.

다만 코드의 오염은 조금 줄었어도 완전히 소거되진 않았다. 하지만 최초의 Logger와는 달리 인터페이스 내의 코드가 없기에 버그가 생기지 않는 장점이 있다.

10.5. 구현 클래스에 대한 Mock 처리

원제 : Mocking concrete classes

지금까지는 인터페이스에 대해서 Mocking하는 것이 대부분이었다.

반면 구현 클래스(concrete class) 도 Mocking하여 본래 클래스의 일부 기능을 보전할 수 있다.

유용해 보이는 이 방법은 때때로 단일 책임 원칙을 위배하는 큰 단점이 되기도 한다.

이번에도 예제를 보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
public class StatisticsCalculator {
public (double totalWeight, double totalCost) Calculate(int customerId) {
List<DeliveryRecord> records = GetDeliveries(customerId);
double totalWeight = records.Sum(x => x.Weight);
double totalCost = records.Sum(x => x.Cost);

return (totalWeight, totalCost);
}

public List<DeliveryRecord> GetDeliveries(int customerId) {
/* Call an out-of-process dependency to get the list of deliveries */
}
}

StatisticsCalculator 클래스는 특정 고객에게 배달된 모든 배송물의 무게와 비용같은 정보를 수집하고 계산한다.

이 클래스는 GetDeliveries() 메서드를 통해 외부 서비스에서 배달 목록을 받아온다.

이때 StatisticsCalculator 클래스를 사용하는 CustomerController 클래스가 있다고 가정하자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class CustomerController {
private readonly StatisticsCalculator _calculator;

public CustomerController(StatisticsCalculator calculator) {
_calculator = calculator;
}

public string GetStatistics(int customerId) {
(double totalWeight, double totalCost) = _calculator.Calculate(customerId);

return
$"Total weight delivered: {totalWeight}. " +
$"Total cost: {totalCost}";
}
}

이때 CustomerController 클래스를 테스트하려면 어떻게 해야할까?

StatisticsCalculator 클래스는 비관리 프로세스 외부 의존성을 참조하기에 실제 객체를 주입하는 것은 잘못된 방법이다.

따라서 비관리 의존성을 Stub으로 대체해야 한다.

다만 StatisticsCalculator 클래스의 Calculate() 메서드의 기능은 그대로 쓰는 것이 유리할 때가 있다.

이 케이스를 해결하는 방법은 구현 클래스인 StatisticsCalculator 클래스를 Mocking하고, GetDeliveries() 메서드만 재정의하는 것이다.

아래와 같이 GetDeliveries() 메서드를 가상 메서드로 대치하면 된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
[Fact]
public void Customer_with_no_deliveries() {
// Arrange
var stub = new Mock<StatisticsCalculator> { CallBase = true };
stub.Setup(x => x.GetDeliveries(1)) // GetDeliveries() must be made virtual.
.Returns(new List<DeliveryRecord>());
var sut = new CustomerController(stub.Object);

// Act
string result = sut.GetStatistics(1);

// Assert
Assert.Equal("Total weight delivered: 0. Total cost: 0", result);
}

Mock에서 CallBase 설정은 재정의한 메서드가 아닌 나머지 메서드의 동작을 유지해주는 역할을 한다.

그래도 아직 StatisticsCalculator 클래스에는 비관리 의존성과의 통신, 통계를 계산하는 두 개의 책임이 남아있다.

StatisticsCalculator 클래스를 Mocking하는 대신 아래와 같이 클래스를 둘로 나누어 책임을 분리한다.

1
2
3
4
5
public class DeliveryGateway : IDeliveryGateway {
public List<DeliveryRecord> GetDeliveries(int customerId) {
/* Call an out-of-process dependency to get the list of deliveries */
}
}

IDeliveryGateway 인터페이스와 DeliveryGateway 클래스를 통해 GetDeliveries() 메서드를 분리하였다.

1
2
3
4
5
6
7
public class StatisticsCalculator {
public (double totalWeight, double totalCost) Calculate(List<DeliveryRecord> records) {
double totalWeight = records.Sum(x => x.Weight);
double totalCost = records.Sum(x => x.Cost);
return (totalWeight, totalCost);
}
}

이제 StatisticsCalculator 클래스에는 계산의 책임만이 남게 된다.

리팩토링 결과는 컨트롤러에도 반영해보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class CustomerController {
private readonly StatisticsCalculator _calculator;
private readonly IDeliveryGateway _gateway;

public CustomerController(
StatisticsCalculator calculator, IDeliveryGateway gateway) { // Two separate dependencies
_calculator = calculator;
_gateway = gateway;
}

public string GetStatistics(int customerId) {
var records = _gateway.GetDeliveries(customerId);
(double totalWeight, double totalCost) = _calculator.Calculate(records);

return
$"Total weight delivered: {totalWeight}. " +
$"Total cost: {totalCost}";
}
}

코드에서 확인할 수 있듯, 비관리 의존성과 통신하는 책임은 DeliveryGateway 클래스로 이전되었다.

이제 구현 클래스 대신 인터페이스로 Mocking을 수행할 수 있게 되었다.

10.6. 시간 처리

원제 : Working with time

많은 애플리케이션들이 현재 날짜과 시간에 대한 기능을 가지고 있다.

이때 시간에 따라 달라지는 기능을 테스트하게 되면 거짓 양성이 발생할 여지가 있다.

이러한 의존성을 해결하는 방법으로 하나의 안티 패턴과 두 가지 의존성 주입 방법을 소개한다.

10.6.1. 앰비언트 컨텍스트로서의 시간

원제 : Time as an ambient context

날짜 및 시간에 대한 의존성을 해결하는 방법으로 앰비언트 컨텍스트 패턴(Abient context pattern) 이 있다.

프레임워크에 내장된 DateTime.Now 등과 같은 것을 아래와 같이 대치하는 것이다.

1
2
3
4
5
6
7
8
9
10
11
12

public static class DateTimeServer {
private static Func<DateTime> _func;
public static DateTime Now => _func();

public static void Init(Func<DateTime> func) {
_func = func;
}
}

DateTimeServer.Init(() => DateTime.Now); // Initialization code for production
DateTimeServer.Init(() => new DateTime(2020, 1, 1)); // Initialization code for unit tests

Logger와 마찬가지로 제품 코드와 테스트를 위한 코드를 분리하였다.

Logger를 언급했듯이 결국 이 방법은 안티 패턴이다.

앰비언트 컨텍스트는 제품 코드를 오염시키고 테스트를 더 어렵게 만든다.

10.6.2. 명시적 의존성으로서의 시간

원제 : Time as an explicit dependency

앰비턴트 컨텍스트 패턴보다 더 나은 방법으로 서비스 또는 값으로 시간 의존성을 명시적으로 주입하는 방법들이 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public interface IDateTimeServer {
DateTime Now { get; }
}

public class DateTimeServer : IDateTimeServer {
public DateTime Now => DateTime.Now;
}

public class InquiryController {
private readonly DateTimeServer _dateTimeServer;

public InquiryController(DateTimeServer dateTimeServer) { // Injects time as a service
_dateTimeServer = dateTimeServer;
}

public void ApproveInquiry(int id) {
Inquiry inquiry = GetById(id);

inquiry.Approve(_dateTimeServer.Now); // Injects time as a plain value
SaveInquiry(inquiry);
}
}

시간을 주입하는 경우 서비스 보다는 값으로 주입하는 것이 더 좋은 방법이다.

제품 코드 측면에서는 값으로 작업하는 것이 더 쉽고 테스트에서도 Stub으로 처리하기 용이하기 때문이다.

다만 의존성 주입을 위한 프레임워크 값을 주입하기엔 어려운 점이 있기에 모든 경우에 값으로 시간을 주입할 수는 없을 것이다.

결론적으로 위 예제처럼 비즈니스 연산을 시작할 때는 서비스로 시간을 주입한 다음, 나머지 연산에서 값으로 전달하는 것이 좋다.