11장, 단위 테스트 안티 패턴

2022.05.02

11.1 비공개 메서드 단위 테스트

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

단위 테스트를 하려고 비공개 메서드를 노출하는 경우에는 기본 원칙 중 하나인 식별할 수 있는 동작만 테스트하는 것을 위반한다. 비공개 메서드를 노출하면 테스트가 구현 세부 사항과 결합되고 결과적으로 리팩터링 내성이 떨어진다. 비공개 메서드를 직접 테스트하는 대신, 포괄적인 식별할 수 있는 동작으로서 간접적으로 테스트하는 것이 좋다.

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

비공개 메서드가 너무 복잡해서 식별할 수 있는 동작으로 테스트하기에 충분히 커버리지를 얻을 수 없는 경우가 있다. 식별할 수 있는 동작에 이미 합리적인 테스트 커버리지가 있다고 가정하면 다음 두 가지 문제가 발생할 수 있다.

  • 죽은 코드. 테스트에서 벗어난 코드가 어디에도 사용되지 않는다면 리팩터링 후에도 남아서 관계없는 코드 일 수 있다.
  • 추상화가 누락돼 있다. 비공개 메서드가 너무 복잡하면 추상화가 누락됐다는 징후다.
public cass Order
{
  public string GenerateDescription()
  {
    return $"Customer name: {_customer.Name}, " +
      $"total number of products: {_products.Count}, " +
      $"total price: {GetPrice()}"; // <- 복잡한 비공개 메서드를 간단한 공개 메서드에서 사용하고 있음
  }

  private decimal GetPrice()  // <- 복잡한 비공개 메서드
  {
    decimal basePrice = /* _producuts에 기반한 계산 */;
    decimal discounts = /* _producuts에 기반한 계산 */;
    decimal taxes = /* _producuts에 기반한 계산 */;
    return basePrice - discounts + taxes;
  }
}

위의 로직은 추상화가 누락됐다. GetPrice 메서드를 노출하기보다는 다음 예제와 같이 추상화를 별도의 클래스로 도출해서 명시적으로 작성하는 것이 좋다.

public class PriceCalculator
{
  public decimal Calculate(Customer customer, List<Product> products)
  {
    decimal basePrice = /* _producuts에 기반한 계산 */;
    decimal discounts = /* _producuts에 기반한 계산 */;
    decimal taxes = /* _producuts에 기반한 계산 */;
    return basePrice - discounts + taxes;
  }
}

이제 Order와 별개로 PriceCalculator를 테스트할 수 있다. PriceCalculator에는 숨은 입출력이 없으므로 출력 기반(함수형) 스타일의 단위 테스트를 사용할 수도 있다.

11.1.3 비공개 메서드 테스트가 타당한 경우

비공개 메서드를 절대 테스트하지 말라는 규칙에도 예외가 있다. 예외를 이해하려면 코드의 공개 여부와 목적 간의 관계를 다시 살펴봐야 한다.

식별할 수 있는 동작구현 세부 사항
공개좋음나쁨
비공개해당 없음좋음

비공개 메서드를 테스트하는 것 자체는 나쁘지 않다. 비공개 메서드가 구현 세부 사항의 프록시에 해당하므로 나쁜 것이다. 구현 세부 사항을 테스트하면 궁극적으로 테스트가 깨지기 쉽다. 그렇기는 해도 메서드가 비공개이면서 식별할 수 있는 동작인 경우는 드물다.

11.2 비공개 상태 노출

public class Customer
{
  private CustomerStatus _status = CustomerStatus.Regular;

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

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

public enum CustomerStatus
{
  Regular,
  Preferred
}

이 예제에서 Promote() 메서드를 어떻게 테스트할 것인가? 이 메서드의 부작용은 _status 필드의 변경이지만, 필드는 비공개이므로 테스트할 수 없다. 솔깃한 해결책은 이 필드를 공개하는 것이다.

이는 안티패턴이 된다. 테스트는 제품 코드와 정확히 같은 방식으로 테스트 대상 시스템(SUT)과 상호 작용해야 하며, 특별한 권한이 따로 있어서는 안 된다.

Promote()를 테스트하는 방법은 제품 코드가 이 클래스를 어떻게 사용하는지를 대신 살펴보는 것이다.
이 예제에서 제품 코드는 고객의 상태를 신경 쓰지 않는다. 그렇지 않으면 해당 필드를 공개해야 한다. 제품 코드가 관심을 갖는 정보는 승격 후 고객이 받는 할인뿐이다. 이것이 테스트에서 확인해야 할 사항이다.

  • 새로 생성된 고객은 할인이 없음
  • 업그레이드 시 5% 할인율 적용

테스트 유의성을 위해 공개 API 노출 영역을 넓히는 것은 좋지 않은 관습이다.

11.3 테스트로 유출된 도메인 지식

도메인 지식을 테스트로 유출하는 것은 또 하나의 흔한 안티 패턴이며, 보통 복잡한 알고리즘을 다루는 테스트에서 일어난다.

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; // <- 유출

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

    Assert.Eqaul(expected, actual);
  }
}

이러한 테스트는 구현 세부 사항과 결합되는 또 다른 예다. 알고리즘 변경으로 인해 테스트가 실패하면 개발 팀은 원인을 파악하려고 노력하지 않으며, 해당 알고리즘의 새 버전을 테스트에 복사할 가능성이 높다.

테스트를 작성할 때 특정 구현을 암시하지 말라. 알고리즘을 복제하는 대신 다음 예제와 같이 결과를 테스트에 하드코딩한다.

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.Eqaul(expected, actual);
  }
}

직관적이지 않아 보일 수 있지만, 단위 테스트에서는 예상 결과를 하드코딩하는 것이 좋다.

11.4 코드 오염

코드 오염은 테스트에만 필요한 제품 코드를 추가하는 것이다.

코드 오염은 종종 다양한 유형의 스위치 형태를 취한다.

public class Logger
{
  public Logger(bool isTestEnvironment)  // <- 스위치
  {
    _isTestEnvironment = isTestEnvironment;
  }

  public void Log(string text)
  {

    if (_isTestEnvironment)   // <- 스위치
      return;
    /* text에 대한 로깅 */
  }
}

코드 오염의 문제는 테스트 코드와 제품 코드가 혼재돼 유지비가 증가하는 것이다. 이러한 안티 패턴을 방지하려면 테스트 코드를 제품 코드베이스와 분리해야 한다.

public interface ILogger
{
  void Log(string text);
}

public class Logger : ILogger
{
  public void Log(string text)
  {
    /* text에 대한 로깅 */
  }
}

public class FakeLogger : ILogger
{
  public void Log(string text)
  {
    /* 아무것도 하지 않음 */
  }
}

이렇게 분리하면 더 이상 다른 환경을 설명할 필요가 없으므로 단순하게 할 수 있다.

11.5 구체 클래스를 목으로 처리하기

인터페이스를 이용해 목을 처리하는 예를 보여줬지만, 다른 방식도 있다. 구체 클래스를 대신 목으로 처리해서 본래 클래스의 기능 일부를 보존할 수 있으며, 때때로 유용할 수 있다. 그러나 이 대안은 단일 책임 원칙을 위배하는 중대한 단점이 있다.

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)
  {
    /* 프로세스 외부 의존성을 호출해 배달 목록 조회 */
  }
}

여기서 StatisticsCalculator를 사용하는 컨트롤러를 테스트 할 경우, StatisticsCalculator 클래스를 목으로 처리하고 GetDeliveries() 메서드만 재정의할 수 있다. 앞서 언급했듯이 이는 안티 패턴이다.

일부 기능을 지키려고 구체 클래스를 목으로 처리해야 하면, 이는 단일 책임 원칙을 위반하는 결과다.

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);
  }
}

public class DeliveryGateway : IDeliveryGateway
{
  public List<DeliveryRecord> GetDeliveries(int customerId)
  {
    /* 프로세스 외부 의존성을 호출해 배달 목록 조회 */
  }
}

StatisticsCalculator를 목으로 처리하는 대신 클래스를 둘로 나눠야 한다.
DeliveryGateway라는 클래스를 생성하고 비관리 의존성과 통신하는 책임을 넘긴다. 그러하면 구체 클래스 대신 인터페이스를 목에 사용할 수 있다.

11.6 시간 처리하기

많은 애플리케이션 기능에서는 현재 날짜와 시간에 대한 접근이 필요하다. 시간에 따라 달라지는 기능을 테스트하면 거짓 양성이 발생할 수 있다. 실행 단계의 시간이 검증 단계의 시간과 다를 수 있다.

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

첫 번째 방법은 앰비언트 컨텍스트(ambient context)패턴을 사용하는 것이다. 시간 컨텍스트에서 앰비언트 컨텍스트는 프레임워크의 내장 DateTime.Now 대신 다음 예제와 같이 코드에서 사용할 수 있는 사용자 정의 클래스에 해당한다.

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); // <- 단위 테스트 환경 초기화 코드
DateTimeServer.Init(() => new DateTime(2020, 1, 1)); // <- 단위 테스트 환경 초기화 코드

로거 기능과 마찬가지로 시간을 앰비언트 컨텍스트로 사용하는 것도 안티 패턴이다. 앰비언트 컨텍스트는 제품 코드를 오염시키고 테스트를 더 어렵게 한다.

11.6.2 명시적 의존성으로서의 시간

더 나은 방법으로 서비스 또는 일반 값으로 시간 의존성을 명시적으로 주입하는 것이 있다.
시간을 서비스로 주입하는 것보다는 값으로 주입하는 것이 더 낫다. 제품 코드에서 일반 값으로 작업하는 것이 더 쉽고, 테스트에서 해당 값을 스텁으로 처리하기도 더 쉽다.

요약

  • 단위 테스트를 가능하게 하고자 비공개 메서드를 노출하게 되면 테스트가 구현에 결합되고, 결국 리팩터링 내성이 떨어진다. 비공개 메서드를 직접 테스트하는 대신, 식별할 수 있는 동작으로서 간접적으로 테스트하라.
  • 비공개 메서드가 너무 복잡해서 공개 API로 테스트할 수 없다면, 추상화가 누락됐다는 뜻이다.
  • 드물지만, 비공개 메서드가 클래스의 식별할 수 있는 동작에 속한 경우가 있다. 보통 클래스와 ORM 또는 팩토리 간의 비공개 계약을 구현하는 것이 여기에 해당한다.
  • 비공개였던 상태를 단위테스트만을 위해 노출하지 말라.
  • 테스트를 작성할 때 특정 구현을 암시하지 말라. 블랙박스 관점에서 제품 코드를 검증하라. 또한, 도메인 지식을 테스트에 유출하지 않도록 하라.
  • 코드 오염은 테스트에만 필요한 제품 코드를 추가하는 것이다. 이는 테스트 코드와 제품 코드가 혼재되게 하고 제품 코드의 유지비를 증가시키기 때문에 안티 패턴이다.
  • 기능을 지키려고 구체 클래스를 목으로 처리해야 하면, 단일 책임 원칙을 위반하게 된다. 해당 클래스를 도메인 로직이 있는 클래스와 프로세스 외부 의존성과 통신하는 클래스로 분리하라.
  • 현재 시간을 엠비언트 컨텍스트로 하면 제품 코드가 오염되고 테스트하기가 더 어려워진다. 서비스나 일반 값의 명시적인 의존성으로 시간을 주입하라. 가능하면 항상 일반 값이 좋다.