박종훈 기술블로그

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

11장 단위 테스트 안티 패턴
단위테스트 (블라디미르 코리코프)


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

비공개 메서드는 어떻게 해야할까? 결론 부터 이야기 하면 ‘전혀 하지 말아야 한다’.

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

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

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

때로는 비공개 메서드가 너무 복잡해서 식별할 수 있는 동작을 일부로 이를 테스트하기에는 충분한 커버리지를 얻을 수 없을 경우가 있다. 식별할 수 있는 동작에 이미 합리적인 테스트 커버리지가 있는데도 그렇다면 두 가지 사항을 의심해 보는것이 좋다.

  • 죽은 코드다. 테스트에서 벗어난 코드가 어디에도 사용되지 않는다면 리팩터링 후에도 남아 있는 관계없는 코드일 수 있다. 이러한 코드는 삭제하는 것이 좋다.
  • 추상화가 누락되어 있다. 비공개 메서드가 너무 복잡하면(그래서 클래스의 공개 API를 통해 테스트하기 어렵다면) 별도의 클래스로 도출해야 하는 추상화가 누락됐다는 징후다.

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

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

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

책에서는 orm과 비공개 생성자 에 대한 이야기를 하는데 C# 특수적인 상황인 것 같아서 구체적으로는 적지 않는다.

2 비공개 상태 노출

또 다른 일반적인 안티 패턴으로 단위 테스트 목적으로만 비공개 상태를 노출하는 것이 있다.

테스트는 제품 코드와 정확히 같은 방식으로 테스트 대상 시스템(SUT)과 상호 작용해야 하며, 특별한 권한이 있어서는 안 된다. 그렇다면 어떻게 테스트 해야할까?

방법은 상태를 노출하는 대신 제품 코드가 이 클래스를 어떻게 사용하는지를 살펴보는 것이다. 상태가 어느 시점에는 식별할 수 있는지 확인하고 테스트에서 해당 필드를 결합하라.

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

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

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

예를 들어 다음과 같은 계산 알고리즘이 있다고 가정하자.

public static class Calculator
{
    public static int Add(int value1, int value2)
    {
        return value1 + value2;
    }
}

다음 예제는 도메인 지식을 유출하는 테스트 코드의 예시이다.

public class CalculatorTests
{
    [Fact]
    public void Adding_two_numbers()
    {
        int value1 = 1;
        int value2 = 3;
        int expected = value1 + value2; // 유출

        int actual = Calculator.Add(value1, value2);
        Assert.Equal(expected, actual);
    }
}

이러한 테스트는 구현 세부 사항과 결합되는 또 다른 예이다. 리팩터링 내성 지표에서 0점에 가깝다고 할 수 있다.(결국 가치가 없는 테스트이다.) 이러한 테스트는 타당한 실패와 거짓 양성을 구별할 가능성이 없다.

그러면 어떻게 알고리즘을 올바르게 테스트할 수 있는가? 테스트를 작성할 때 특정 구현을 암시하지 말고 결과를 테스트에 함께 하드코딩하라.

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

4 코드 오염

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

예시는 다음과 같다.

public class Logger
{
    private readonly bool _isTestEnvironment;

    public Logger(bool isTestEnvironment)
    {
        _isTestEnvironment = isTestEnvironment;
    }

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

public class Controller
{
    public void SomeMethod(Logger logger)
    {
        logger.Log("SomeMethod 호출");
    }
}

예제에는 운영 환경에서 실행되고 있는지 여부를 나타내는 메개변수가 있다. 이렇게 하면 테스트 실행 중에는 로거를 비활성화할 수 있다.

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

해결 방법을 설명하자면 ILogger 인터페이스를 도입해 두 가지 구현을 만들어라. 하나는 운영을 위한 진짜 구현체이고, 다른 하나는 테스트를 목적으로 한 가짜 구현체다. 코드는 다음과 같다.

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

Unit Testing: Principles, Practices, and Patterns

// 운영을 위한 제품 코드
public class Logger : ILogger
{
    public void Log(string text)
    {
        /* Log the text */
    }
}

// 테스트를 위한 Fake 객체 테스트 코드
public class FakeLogger : ILogger
{
    public void Log(string text)
    {
        /* Do nothing */
    }
}

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

이렇게 분리하면 더 이상 환경에 대한 정보가 필요없이 단순하게 할 수 있다.

하지만 사실 ILogger를 만든 것도 테스트에만 필요한 코드 오염의 한 형태이다. 하지만 ILogger와 같은 코드 오염은 덜 손상되고 다루기 쉽다. 불 스위치와 달리 인터페이스는 잠재적인 버그에 대한 노출 영역을 늘리지 않는다. (다만 현대 프로그래밍에서는 인터페이스에 구현이 포함될 수 있는 경우도 있으니, 주의를 기울이는 것이 좋다.)

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

지금까지 이 책에서는 인터페이스를 이용해 목을 처리하는 예를 보여줬지만 사실 구체 클래스를 목으로 처리할 수도 있다. 이는 본래 클래스의 기능 일부를 보존할 수 있으며, 이는 때때로 유용할 수 있다. 그러나 이 대안은 단일 책임 원칙을 위배하는 중대한 문제를 발생시킨다.

6 시간 처리하기

많은 애플리케이션 기능에서는 현재 날짜와 시간에 대한 접근이 필요하다. 그러나 시간에 따라 달라지는 기능을 테스트하면 거짓 양성이 발생할 수 있다. 실행 단계의 시간이 검증 단계의 시간과 다를 수 있다. 이 의존성을 안정화하는 데는 세 가지 방법이 있다. 그중 하나는 안티 패턴이고, 나머지 두 가지 중에 바람직한 방법이 있다.

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

프레임워크 내장 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));

이는 안티패턴이다. 제품 코드를 오염시키고 테스트를 더 어렵게 한다. 또 정적 필드는 테스트 간에 공유하는 의존성을 도입해 해당 테스트를 통합 테스트 영역으로 전환한다.

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

더 나은 방법으로 서비스 또는 일반값으로 시간 의존성을 명시적으로 주입하는 방법이 있다.

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) // 시간을 서비스로 주입
    {
        _dateTimeServer = dateTimeServer;
    }

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

        inquiry.Approve(_dateTimeServer.Now); // 시간을 일반 값으로 주입
        SaveInquiry(inquiry);
    }
}

이 두 가지 옵션 중에서 시간을 서비스로 주입하는 것보다는 값으로 주입하는 것이 더 낫다. 제품 코드에서 일반 값으로 작업하는 것이 더 쉽고, 테스트에서 해당 값을 스텁으로 처리하기도 더 쉽다.

아마 시간을 항상 일반 값으로 주입할 수는 없을 것이다. 의존성 주입 프레임워크가 값 객체와 잘 어울리지 않기 때문이다. 비즈니스 연산을 시작할 때는 서비스로 시간을 주입한 다음, 나머지 연산에서 값으로 전달하는 것이 좋다.

7 요약

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