박종훈 기술블로그

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

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


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

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

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

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

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

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

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 요약