Dev

테스트 주도 개발 실천

October 31, 2017

author:

테스트 주도 개발 실천

테스트 주도 개발 실천

테스트 주도 개발(test-driven development)은 점진적으로 코드를 검증하는 소프트웨어 개발 방법이다. 자신이 작성한 코드의 동작을 직접 확인하는 것은 어쩌면 프로그래머의 기본 미덕이고 테스트 주도 개발은 이 미덕의 실천을 프로그래밍 과정속에 자연스레 녹여낸다. 또한 테스트 주도 개발은 좋은 설계의 강력한 지원자다. 테스트 주도 개발 자체가 좋은 설계를 유도하는 것은 아니지만 프로그래머가 좋은 설계를 위해 노력할 수 있도록 든든히 뒷받침한다.

하지만 테스트 주도 개발은 어렵다. 전통적인 프로그래밍 절차와 상이할 뿐 아니라 긴 시간동안 습관이 되어버린 나쁜 버릇들이 억제되며 답답함이 느껴지기도 한다. 개념을 이해했다 하더라도 현장에서 실천하는 데에는 많은 장벽들이 있다. 이 글은 가상의 예제가 아니라 간단하지만 실제 서비스 개발에 사용된 사례를 통해 현장에서 테스트 주도 개발이 어떻게 적용되는지 보여준다.

사용되는 코드는 계정 관리를 담당하는 도메인 모델의 초기 버전이다. 도메인 모델은 CreateUserWithEmail 명령을 전달받아 UserCreated 도메인 이벤트와 EmailChanged 도메인 이벤트를 발생시키는데 이 과정에 사용되는 일부 코드가 대상이다. 도메인 모델은 이벤트 소싱을 사용하고 코드는 C#으로 작성되지만 이벤트 소싱 패턴과 .NET 기술에 대한 사전 지식이 필수는 아니다. 이 글의 목적은 깊은 기술적 이해가 아니라 현장의 프로젝트에서 테스트 주도 개발이 실천되는 과정과 설계를 개선하는 사례를 소개하는 것이다. 이를 이해하기 위해 필요한 지식은 적당한 수준에서 설명한다.

UserCreated 도메인 이벤트

먼저 UserCreated 도메인 이벤트를 작성한다. 완성된 코드를 미리 확인하면 다음과 같다.

public class UserCreated : DomainEvent, IUniqueIndexedDomainEvent
{
    public string Username { get; set; }

    [JsonIgnore]
    public IReadOnlyDictionary<string, string> UniqueIndexedProperties
        => new Dictionary<string, string>
        {
            [nameof(Username)] = Username
        };
}

UserCreated 클래스는 DomainEvent 클래스를 상속해 이벤트 소싱의 도메인 이벤트 역할을 수행하며 IUniqueIndexedDomainEvent 인터페이스를 구현해 Username 속성이 유일성 제약에 적용받게 한다. 이 클래스는 총 5개의 테스트 케이스를 통해 작성되었다. 그 과정을 살펴보자.

테스트 케이스: DomainEvent 클래스를 상속한다.

테스트 케이스 실패

UserCreated 클래스를 작성하면서 가장 먼저 추가된 테스트 케이스다.

[Fact]
public void sut_inherits_DomainEvent()
{
    typeof(UserCreated).BaseType.Should().Be(typeof(DomainEvent));
}

[Fact]는 테스트 프레임워크 xUnit.net에서 매개변수를 가지지 않는 테스트 케이스 메서드를 의미한다.

SUT는 ‘system under test’의 약자로 테스트 대상을 의미한다. 테스트 대상은 테스트 케이스에 따라 형식, 개체, 속성, 메서드 등 다양한 코드 요소가 될 수 있다.

C#은 강력한 형 체계 기반의 언어다. 따라서 위 테스트 케이스가 컴파일되고 실행되려면 UserCreated 클래스가 존재해야 한다. 테스트 케이스를 실행하기 위한 최소한의 프로덕션 코드를 작성한다.

public class UserCreated
{
}

테스트 케이스를 실행하면 다음 오류 메시지와 함께 실패한다.

Test Name:  sut_inherits_DomainEvent
Test FullName:  Conto.Identity.Events.UserCreated_specs.sut_inherits_DomainEvent
Test Source:    C:\Users\gyuwon\Documents\Projects\Conto\source\Identity\Conto.Identity.Tests.Unit\Identity\Events\UserCreated_specs.cs : line 15
Test Outcome:   Failed
Test Duration:  0:00:00.026

Result StackTrace:  
at FluentAssertions.Execution.XUnit2TestFramework.Throw(String message)
   at FluentAssertions.Execution.AssertionScope.FailWith(String message, Object[] args)
   at FluentAssertions.Types.TypeAssertions.Be(Type expected, String because, Object[] becauseArgs)
   at Conto.Identity.Events.UserCreated_specs.sut_inherits_DomainEvent() in C:\Users\gyuwon\Documents\Projects\Conto\source\Identity\Conto.Identity.Tests.Unit\Identity\Events\UserCreated_specs.cs:line 12
Result Message: Expected type to be Khala.EventSourcing.DomainEvent, but found System.Object.

중요한 것은 마지막 줄의 결과 메시지다. 메시지는 형식(UserCreated 클래스의 기반 클래스)이 Khala.EventSourcing.DomainEvent일 것으로 기대했지만 실제로는 System.Object라는 내용을 담는다. 즉 UserCreated 클래스가 DomainEvent를 상속받지 않았기 때문에 테스트 케이스가 실패한 것이다. 기대했던 테스트 케이스 실패 상황이므로 다음 단계로 진행할 수 있다. 이것을 확인하는 것은 매우 중요하다. 엉터리 테스트 케이스를 작성하고서 테스트 케이스가 실패했으니 괜찮다고 착각하는 우를 범하지 말자.

테스트 케이스 성공

테스트 케이스를 성공시키는 가장 쉽고 확실한 방법은 UserCreated 클래스의 기반 클래스를 DomainEvent로 만드는 것이다.

public class UserCreated : DomainEvent
{
}

이제 sut_inherits_DomainEvent 테스트 케이스를 실행하면 성공한다.

테스트 케이스: Username 속성을 가진다.

사용자 엔터티가 생성될 때 사용자 이름을 필수 정보로 설계했기 때문에 UserCreated 도메인 이벤트는 Username 문자열 속성을 가져야 한다.

테스트 케이스 실패

[Fact]
public void sut_has_Username_property()
{
    typeof(UserCreated).Should().HaveProperty<string>("Username");
}

테스트 케이스 성공

UserCreated 클래스에 Username 속성을 추가하면 sut_has_Username_property 테스트 케이스는 성공한다.

public class UserCreated : DomainEvent
{
    public string Username { get; set; }
}

테스트 케이스: IUniqueIndexedDomainEvent 인터페이스를 구현한다.

사용자 이름은 도메인 모델 내에서 유일해야 한다고 설계했다. 일반적으로 이벤트 소싱은 쓰기 모델에서 속성 값의 유일성을 고려하지 않지만 우리가 사용한 이벤트 소싱 프레임워크는 관계형 데이터베이스를 이벤트 저장소로 사용하는 경우 IUniqueIndexedDomainEvent 인터페이스를 통해 문자열 속성의 유일성 제약을 지원한다.

테스트 케이스 실패

[Fact]
public void sut_implements_IUniqueIndexedDomainEvent()
{
    typeof(UserCreated).Should().Implement<IUniqueIndexedDomainEvent>();
}

테스트 케이스 성공

UserCreated 클래스가 IUniqueIndexedDomainEvent 인터페이스를 구현하게 한다. 도메인 모델 설계에 의하면 UniqueIndexedProperties 속성이 사용자 이름 정보를 담아야 하지만 테스트 케이스 sut_implements_IUniqueIndexedDomainEvent는 내부 구현에는 관여하지 않는다. 따라서 이 단계에서 UniqueIndexedProperties 속성의 구현은 컴파일이 실패하지 않을 만큼만 작성해야 한다.

public class UserCreated : DomainEvent, IUniqueIndexedDomainEvent
{
    public string Username { get; set; }

    public IReadOnlyDictionary<string, string> UniqueIndexedProperties
        => new Dictionary<string, string>();
}

이제 sut_implements_IUniqueIndexedDomainEvent 테스트 케이스는 성공한다.

테스트 케이스: UniqueIndexedProperties 속성이 사용자 이름 정보를 포함한다.

UserCreated 도메인 이벤트는 IUniqueIndexedDomainEvent 인터페이스를 구현하지만 UniqueIndexedPropertiesUsername 속성 정보를 포함해야 사용자 이름이 유일하다는 설계가 모두 반영된다.

테스트 케이스 실패

[Fact]
public void UniqueIndexedProperties_contains_Username_property()
{
    var sut = new UserCreated { Username = GenerateUsername() };
    IReadOnlyDictionary<string, string> actual = sut.UniqueIndexedProperties;
    actual.Should().Contain(p => p.Key == "Username" && p.Value == sut.Username);
}

private string GenerateUsername() => $"Username-{Guid.NewGuid()}";

테스트 케이스 UniqueIndexedProperties_contains_Username_property는 임의의 사용자 이름을 사용해 UserCreated 개체를 생성하고 UniqueIndexedProperties 속성이 Username 정보를 포함하는지 검사한다. 테스트 케이스를 실행하면 실패한다.

테스트 케이스 성공

public class UserCreated : DomainEvent, IUniqueIndexedDomainEvent
{
    public string Username { get; set; }

    public IReadOnlyDictionary<string, string> UniqueIndexedProperties
        => new Dictionary<string, string>
        {
            [nameof(Username)] = Username
        };
}

이제 UniqueIndexedProperties 속성이 반환하는 사전은 Username 속성 정보를 포함한다. 테스트 케이스 UniqueIndexedProperties_contains_Username_property를 실행하면 성공한다.

테스트 케이스: UniqueIndexedProperties 속성은 JSON 직렬화 대상이 아니다.

도메인 이벤트는 메시지다. 메시지는 프로세스 범위를 넘어 전달되고 이 과정에 직렬화가 사용된다. 그런데 UniqueIndexedProperties 속성은 다른 속성을 통해 계산되는 속성이기 때문에 직렬화하지 않으면 메시지 전달 및 기록 비용을 줄일 수 있다.

테스트 케이스 실패

우리는 직렬화를 위해 JSON을 사용한다. 속성이 [JsonIgnore] 특성으로 수식되면 JSON 직렬화기는 해당 속성을 직렬화에서 제외시킨다. 다음 테스트 케이스는 UniqueIndexedProperties 속성이 [JsonIgnore] 특성으로 수식되는지 검사한다.

[Fact]
public void UniqueIndexedProperties_is_decorated_with_JsonIgnore()
{
    typeof(UserCreated)
        .GetProperty("UniqueIndexedProperties")
        .Should()
        .BeDecoratedWith<JsonIgnoreAttribute>();
}

아직 UniqueIndexedProperties 속성은 [JsonIgnore] 특성으로 수식되지 않기 때문에 테스트 케이스는 실패한다.

테스트 케이스 성공

UniqueIndexedProperties 속성을 [JsonIgnore] 특성으로 수식한다.

public class UserCreated : DomainEvent, IUniqueIndexedDomainEvent
{
    public string Username { get; set; }

    [JsonIgnore]
    public IReadOnlyDictionary<string, string> UniqueIndexedProperties
        => new Dictionary<string, string>
        {
            [nameof(Username)] = Username
        };
}

테스트 케이스 UniqueIndexedProperties_is_decorated_with_JsonIgnore를 실행하면 성공한다.

CreateUserWithEmail 명령

이벤트 소싱에서 도메인 이벤트는 이미 발생한 돌이킬 수 없는 과거의 사실이기 때문에 검증 대상이 아니지만 명령은 실행되기 전에 반드시 검증되어야 한다. 우리는 명령에 불변성을 부여하고 상태 검증은 생성자 매개변수 방어구문 대신 System.ComponentModel.DataAnnotations.Validator 클래스를 사용하기로 결정했다. 명령에 불변성을 부여하면 명령 상태에 대한 검증은 명령 처리 절차의 진입점에서만 수행하면 된다.

명령 상태 검증에 생성자 매개변수 방어구문을 사용하지 않는 이유가 있지만 테스트 주도 개발과 직접 관련된 문제가 아니라 이 글에서 설명하지 않는다.

CreateUserWithEmail 명령의 Email 속성을 만드는 과정을 소개한다. Email 속성이 추가되기 전의 CreateUserWithEmail 클래스는 다음과 같다.

public class CreateUserWithEmail : IValidatableObject
{
    public CreateUserWithEmail(Guid userId, string username)
    {
        UserId = userId;
        Username = username;
    }

    public Guid UserId { get; }

    [Required]
    [Username]
    public string Username { get; }

    IEnumerable<ValidationResult> IValidatableObject.Validate(ValidationContext validationContext)
    {
        if (UserId == Guid.Empty)
        {
            yield return new ValidationResult("Value cannot be empty.", new[] { nameof(UserId) });
        }
    }
}

테스트 케이스: Email 속성을 가진다.

우선 CreateUserWithEmail 명령은 Email 문자열 속성을 정의해야 한다.

테스트 케이스 실패

[Fact]
public void sut_has_Email_property()
{
    typeof(CreateUserWithEmail)
        .Should()
        .HaveProperty<string>("Email")
        .Which
        .Should()
        .NotBeWritable();
}

명령 개체가 불변성을 가져야 하기 때문에 테스트 케이스 sut_has_Email_propertyCreateUserWithEmail 클래스가 Email 속성을 갖는지 여부와 더불어 해당 속성이 쓰기 접근자를 가지지 않는지도 검사한다. CreateUserWithEmail 클래스는 아직 Email 속성을 가지지 않기 때문에 테스트 케이스는 실패한다.

테스트 케이스 성공

CreateUserWithEmail 클래스에 읽기 접근자만 가지는 Email 속성을 추가한다.

public class CreateUserWithEmail : IValidatableObject
{
    public CreateUserWithEmail(Guid userId, string username)
    {
        UserId = userId;
        Username = username;
    }

    public Guid UserId { get; }

    [Required]
    [Username]
    public string Username { get; }

    public string Email { get; }

    IEnumerable<ValidationResult> IValidatableObject.Validate(ValidationContext validationContext)
    {
        if (UserId == Guid.Empty)
        {
            yield return new ValidationResult("Value cannot be empty.", new[] { nameof(UserId) });
        }
    }
}

테스트 케이스 sut_has_Email_property를 실행하면 성공한다.

테스트 케이스: 생성자는 Email 속성 값을 올바르게 설정한다.

CreateUserWithEmail 명령은 불변 개체이기 때문에 추가된 Email 속성 값은 생성자 매개변수로 전달받아 설정되어야 한다.

테스트 케이스 실패

[Fact]
public void constructor_sets_Email_correctly()
{
    string email = GenerateEmail();
    var sut = new CreateUserWithEmail(Guid.NewGuid(), GenerateUsername(), email);
    sut.Email.Should().Be(email);
}

public static string GenerateUsername() => $"Username{Guid.NewGuid().ToString("n").Substring(0, 8)}";

public static string GenerateEmail() => $"email{Guid.NewGuid().ToString("n").Substring(0, 8)}@test.com";

테스트 케이스 constructor_sets_Email_correctly는 임의의 이메일 주소를 사용해 CreateUserWithEmail 명령을 생성하고 Email 속성이 잘 설정되었는지 검사한다. 기존의 CreateUserWithEmail 생성자는 이메일 주소를 매개변수로 받지 않기 때문에 테스트 케이스를 컴파일 하고 실행하려면 생성자를 수정해야 한다.

public CreateUserWithEmail(Guid userId, string username, string email)
{
    UserId = userId;
    Username = username;
}

생성자에 email 매개변수가 추가되었지만 사용되지는 않기 때문에 테스트 케이스는 실패한다.

테스트 케이스 성공

CreateUserWithEmail 생성자가 매개변수 email 값을 Email 속성에 대입하면 테스트 케이스 constructor_sets_Email_correctly는 성공한다.

public CreateUserWithEmail(Guid userId, string username, string email)
{
    UserId = userId;
    Username = username;
    Email = email;
}

테스트 케이스: 개체 검증은 이메일 주소 유효성을 판단한다.

이 글의 메인 이벤트 격인 테스트 케이스다. 이메일 주소를 사용한 사용자 생성 절차는 이메일 주소 필드가 올바른 형식을 따르는지 검사해야 한다. 제품이 출시될 때까지 이메일 주소 형식 검증 규칙은 점진적으로 개선되겠지만 우선 이 글에 있는 사례들을 이용해 초기화한다.

테스트 케이스 실패

[Theory] 특성은 매개변수를 가진(parameterized) 테스트 메서드를 의미한다. 테스트 케이스에 필요한 매개변수 인자는 xUnit.net에 내장된 데이터 제공 특성을 통해 입력한다. 여기서는 [MemberData] 특성을 사용한다.

우선 테스트 케이스 데이터를 제공하는 클래스를 만든다.

// Test cases from https://blogs.msdn.microsoft.com/testing123/2009/02/06/email-address-test-cases/
public static class EmailTestCases
{
    public static IEnumerable<object[]> InvalidEmails()
    {
        return new[]
        {
            new[] { "plainaddress", "Missing @ sign and domain" },
            new[] { "#@%^%#[email protected]#[email protected]#.com", "Garbage" },
            new[] { "@domain.com", "Missing username" },
            new[] { "Joe Smith <[email protected]>", "Encoded html within email is invalid" },
            new[] { "email.domain.com", "Missing @" },
            new[] { "[email protected]@domain.com", "Two @ sign" },
            new[] { "[email protected]", "Leading dot in address is not allowed" },
            new[] { "[email protected]", "Trailing dot in address is not allowed" },
            new[] { "[email protected]", "Multiple dots" },
            new[] { "あいうえお@domain.com", "Unicode char as address" },
            new[] { "[email protected] (Joe Smith)", "Text followed email is not allowed" },
            new[] { "[email protected]", "Missing top level domain (.com/.net/.org/etc)" },
            new[] { "[email protected]", "Leading dash in front of domain is invalid" },
            new[] { "[email protected]", ".web is not a valid top level domain" },
            new[] { "[email protected]", "Invalid IP format" },
            new[] { "[email protected]", "Multiple dot in the domain portion is invalid" },
        };
    }
}

InvalidEmails() 메서드는 올바르지 않은 형식의 이메일 주소와 올바르지 않은 이유의 집합 컬렉션 반환한다.

[Theory]
[MemberData("InvalidEmails", MemberType = typeof(EmailTestCases))]
public void given_invalid_email_then_validation_fails(string email, string reason)
{
    var sut = new CreateUserWithEmail(Guid.NewGuid(), GenerateUsername(), email);
    Action action = () => Validate(sut);
    action.ShouldThrow<ValidationException>();
}

private static void Validate(object instance)
{
    Validator.ValidateObject(
        instance,
        new ValidationContext(instance),
        validateAllProperties: true);
}

테스트 프레임워크는 EmailTestCases.InvalidEmails() 메서드가 반환한 데이터 집합 컬렉션을 반복하며 하나의 데이터 집합 당 테스트 케이스 given_invalid_email_then_validation_fails를 한 번씩 실행한다. 하나의 데이터 집합은 이메일 주소와 이메일 주소가 올바르지 않은 이유로 구성되는데 이 정보는 각각 테스트 케이스의 email 매개변수와 reason 매개변수로 전달된다. 그리고 테스팅 도구에 의해 표현되는 테스트 케이스 이름은 다음과 같다.

given_invalid_email_then_validation_fails(email: "[email protected]", reason: "Leading dash in front of domain is invalid")

아직 Email 속성을 검증하는 어떠한 규칙도 적용되지 않았기 때문에 ValidatorCreateUserWithEmail 개체를 검증할 때 ValidationException을 던지지 않고 테스트 케이스는 실패한다.

테스트 케이스 성공

테스트 케이스 given_invalid_email_then_validation_fails는 테스트 데이터에 의해 정의되는 테스트 케이스의 집합으로 볼 수 있다. 이런 경우 테스트 주도 개발 주기를 여러 번 반복해 구현할 수도 있다. 이 글에서는 편의상 한 번의 주기로 구현한다.

테스트 케이스 given_invalid_email_then_validation_fails의 사례들이 모두 통과하도록 점진적으로 규칙들을 추가했고 결과 코드는 다음과 같다.

public class CreateUserWithEmail : IValidatableObject
{
    public CreateUserWithEmail(Guid userId, string username, string email)
    {
        UserId = userId;
        Username = username;
        Email = email;
    }

    public Guid UserId { get; }

    [Required]
    [Username]
    public string Username { get; }

    [EmailAddress]
    public string Email { get; }

    IEnumerable<ValidationResult> IValidatableObject.Validate(ValidationContext validationContext)
    {
        if (UserId == Guid.Empty)
        {
            yield return new ValidationResult("Value cannot be empty.", new[] { nameof(UserId) });
        }

        string email = Email;

        if (ContainsWhiteSpaceCharacter(email))
        {
            yield return new ValidationResult("Value cannot contain white space characters.", new[] { nameof(Email) });
        }

        if (EmailContainsMultipleDots(email))
        {
            yield return new ValidationResult("Multiple dots found.", new[] { nameof(Email) });
        }

        if (EmailLocalPartHasLeadingDot(email))
        {
            yield return new ValidationResult("Leading dot in local part is not allowed.", new[] { nameof(Email) });
        }

        if (EmailLocalPartHasTrailingDot(email))
        {
            yield return new ValidationResult("Trailing dot in local part is not allowed.", new[] { nameof(Email) });
        }

        if (EmailDomainPartHasLeadingDash(email))
        {
            yield return new ValidationResult("Leading dash in domain part is not allowed.", new[] { nameof(Email) });
        }

        if (EmailHasTopLevelDomain(email) == false)
        {
            yield return new ValidationResult("Top level domain is missing.", new[] { nameof(Email) });
        }

        if (EmailHasInvalidTopLevelDomain(email))
        {
            yield return new ValidationResult("Top level domain is invalid.", new[] { nameof(Email) });
        }

        if (EmailIPFormatIsInvalid(email))
        {
            yield return new ValidationResult("IP format is invalid.", new[] { nameof(Email) });
        }

        if (EmailLocalPartContainsUnicodeCharacter(email))
        {
            yield return new ValidationResult("Local part contains a unicode character.", new[] { nameof(Email) });
        }
    }

    private static bool ContainsWhiteSpaceCharacter(string s)
    {
        for (int i = 0; i < s.Length; i++)
        {
            char c = s[i];
            if (char.IsWhiteSpace(c))
            {
                return true;
            }
        }

        return false;
    }

    private static bool EmailContainsMultipleDots(string email)
    {
        return email.Contains("..");
    }

    private static bool EmailLocalPartHasLeadingDot(string email)
    {
        return email.StartsWith(".");
    }

    private static bool EmailLocalPartHasTrailingDot(string email)
    {
        int atIndex = email.IndexOf('@');
        if (atIndex < 0)
        {
            return false;
        }

        string localPart = email.Substring(0, atIndex);
        return localPart.EndsWith(".");
    }

    private static bool EmailDomainPartHasLeadingDash(string email)
    {
        int atIndex = email.IndexOf('@');
        if (atIndex < 0)
        {
            return false;
        }

        string domainPart = email.Substring(atIndex + 1);
        return domainPart.StartsWith("-");
    }

    private static bool EmailHasTopLevelDomain(string email)
    {
        int atIndex = email.IndexOf('@');
        if (atIndex < 0)
        {
            return false;
        }

        string domainPart = email.Substring(atIndex + 1);
        string[] fragments = domainPart.Split('.');
        return fragments.Length >= 2;
    }

    private static bool EmailHasInvalidTopLevelDomain(string email)
    {
        int atIndex = email.IndexOf('@');
        if (atIndex < 0)
        {
            return false;
        }

        string domainPart = email.Substring(atIndex + 1);
        string[] fragments = domainPart.Split('.');
        string topLevelDomain = fragments.Skip(1).LastOrDefault();
        return topLevelDomain?.Equals("web", StringComparison.OrdinalIgnoreCase) ?? false;
    }

    private static bool EmailIPFormatIsInvalid(string email)
    {
        int atIndex = email.IndexOf('@');
        if (atIndex < 0)
        {
            return false;
        }

        string domainPart = email.Substring(atIndex + 1);
        string[] fragments = domainPart.Split('.');
        if (fragments.Length == 4)
        {
            int?[] ipv4 = fragments
                .Select(f => int.TryParse(f, out int value) ? value : (int?)null)
                .ToArray();
            if (ipv4.All(e => e.HasValue))
            {
#pragma warning disable SA1131 // Use readable conditions
                return ipv4.Any(e => e < 0 || 255 < e);
#pragma warning restore SA1131 // Use readable conditions
            }
        }

        return false;
    }

    private static bool EmailLocalPartContainsUnicodeCharacter(string email)
    {
        int atIndex = email.IndexOf('@');
        if (atIndex < 0)
        {
            return false;
        }

        string localPart = email.Substring(0, atIndex);
        return localPart.Any(c => c > 127);
    }
}

우선 Email 속성을 System.ComponentModel.DataAnnotations 네임스페이스가 제공하는 [EmailAddress] 특성으로 수식한다. [EmailAddress] 특성은 Validator에 의해 사용되어 Email 속성 값이 이메일 주소 형식에 적합한지 검사한다. 하지만 우리의 이메일 주소 형식 규칙은 [EmailAddress] 특성의 규칙보다 엄격하기 때문에 일부 테스트 케이스는 성공시키지만 나머지는 그러지 못한다. 그래서 모든 테스트 케이스들을 성공시키기 위해 [EmailAddress] 특성이 검출하지 못하는 규칙들이 Validate() 메서드에 포함된다.

리팩터링

테스트 주도 개발에서 리팩터링은 매우 중요한 단계다. 리팩터링은 테스트 우선 개발(test first development)과 테스트 주도 개발이 구분되는 기준이기도 하다. 지금까지의 테스트 케이스들은 프로덕션 코드 구현이 간단했기 때문에 리팩터링이 필요하지 않았지만 테스트 케이스 given_invalid_email_then_validation_fails는 상황이 다르다.

테스트 케이스 성공 단계에서 코드는 오로지 모든 테스트 케이스에 녹색 불이 들어오는 것을 목표로 작성된다. 설계는 고려사항이 아니다. 그 결과 CreateUserWithEmail 명령은 메시지임에도 불구하고 아주 많은 논리를 포함한다. 문제는 그 뿐만이 아니다. 사용자의 이메일 주소는 초기에 설정된 후 변경될 수 있도록 설계했다. 이 설계를 반영하려면 앞으로 ChangeEmail 명령이 필요할 것이다. 그런데 ChangeEmail 명령 역시 Email 속성을 가지며 이 속성 값을 CreateUserWithEmail 명령의 경우와 동일한 규칙으로 검증해야 하는데 이 과정에서 많은 코드 중복이 발생한다. 이메일 주소 형식 규칙은 최종 결정된 것이 아니라 제품이 출시될 때가지 점진적으로 개선될 계획이기 때문에 유지 비용이 큰 코드 중복은 가급적 피해야 한다.

Kent Beck은 테스트 주도 개발을 위한 단순한 규칙 두 개를 정의했다.
– First, you should write new business code only when an automated test has failed.
– Second, you should eliminate any duplication that you find.

이런 문제를 가진 설계를 개선하기 위해 [EmailAddress] 특성에 포함된 규칙과 그렇지 않는 규칙을 모두 반영한 새 특성([Email])을 만들어 적용하는 리팩터링을 수행한다.

public class CreateUserWithEmail : IValidatableObject
{
    public CreateUserWithEmail(Guid userId, string username, string email)
    {
        UserId = userId;
        Username = username;
        Email = email;
    }

    public Guid UserId { get; }

    [Required]
    [Username]
    public string Username { get; }

    [Email]
    public string Email { get; }

    IEnumerable<ValidationResult> IValidatableObject.Validate(ValidationContext validationContext)
    {
        if (UserId == Guid.Empty)
        {
            yield return new ValidationResult("Value cannot be empty.", new[] { nameof(UserId) });
        }
}
[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field | AttributeTargets.Parameter, AllowMultiple = false)]
public sealed class EmailAttribute : DataTypeAttribute
{
    private static readonly EmailAddressAttribute StandardAttribute = new EmailAddressAttribute();
    private static readonly IEnumerable<Func<string, bool>> Rules = new Func<string, bool>[]
    {
        SatisfiesStandardRules,
        DoesNotContainWhiteSpaceCharacter,
        DoesNotContainMultipleDots,
        DomainPartDoesNotHaveLeadingDash,
        HasValidTopLevelDomain,
        IfIPv4DomainIsUsedThenValid,
        LocalPartDoesNotHaveLeadingDot,
        LocalPartDoesNotHaveTrailingDot,
        LocalPartDoesNotContainUnicodeCharacter
    };

    public EmailAttribute()
        : base(DataType.EmailAddress)
    {
    }

    public override bool IsValid(object value)
    {
        string email = value as string;
        return Rules.All(rule => rule.Invoke(email));
    }

    private static bool SatisfiesStandardRules(string email)
    {
        return StandardAttribute.IsValid(email);
    }

    private static bool DoesNotContainWhiteSpaceCharacter(string email)
    {
        for (int i = 0; i < email.Length; i++)
        {
            char c = email[i];
            if (char.IsWhiteSpace(c))
            {
                return false;
            }
        }

        return true;
    }

    private static bool DoesNotContainMultipleDots(string email)
    {
        return email.Contains("..") == false;
    }

    private static bool DomainPartDoesNotHaveLeadingDash(string email)
    {
        int atIndex = email.IndexOf('@');
        string domainPart = email.Substring(atIndex + 1);
        return domainPart.StartsWith("-") == false;
    }

    private static bool HasValidTopLevelDomain(string email)
    {
        int atIndex = email.IndexOf('@');
        string domainPart = email.Substring(atIndex + 1);
        string[] fragments = domainPart.Split('.');
        string topLevelDomain = fragments.Skip(1).LastOrDefault();
        return topLevelDomain != null
            && topLevelDomain.Equals("web", StringComparison.OrdinalIgnoreCase) == false;
    }

    private static bool IfIPv4DomainIsUsedThenValid(string email)
    {
        int atIndex = email.IndexOf('@');
        string domainPart = email.Substring(atIndex + 1);
        string[] fragments = domainPart.Split('.');
        if (fragments.Length == 4)
        {
            int?[] ipv4 = fragments
                .Select(f => int.TryParse(f, out int value) ? value : (int?)null)
                .ToArray();
#pragma warning disable SA1131 // Use readable conditions
            return ipv4.Any(e => e.HasValue == false)
                || ipv4.All(e => 0 <= e && e <= 255);
#pragma warning restore SA1131 // Use readable conditions
        }

        return true;
    }

    private static bool LocalPartDoesNotHaveLeadingDot(string email)
    {
        return email.StartsWith(".") == false;
    }

    private static bool LocalPartDoesNotHaveTrailingDot(string email)
    {
        int atIndex = email.IndexOf('@');
        string localPart = email.Substring(0, atIndex);
        return localPart.EndsWith(".") == false;
    }

    private static bool LocalPartDoesNotContainUnicodeCharacter(string email)
    {
        int atIndex = email.IndexOf('@');
        string localPart = email.Substring(0, atIndex);
        return localPart.All(c => c <= 127);
    }
}

리팩터링은 단순히 코드를 옮기는 것 뿐 아니라 일관된 설계를 위한 내부 변경이 포함되었다. 비교식의 부등호 방향이 바뀌었고 어떤 if 블럭은 삭제되었다. 과감하게 이런 변경을 적용할 수 있는 이유는 테스트 케이스를 통해 새 코드가 리팩터링 전의 기능을 온전히 수행함을 보장받기 때문이다. 실제로 리팩터링을 하면서 민감한 부분의 실수가 있었지만 테스트 케이스가 깨졌기 때문에 오류를 즉시 인지하고 올바르게 수정할 수 있었다.

설계 변경으로 인해 CreateUserWithEmail 클래스는 원래 목적에 맞게 메시지로서의 기능에 충실하게 되었다. 이메일 주소 검사 규칙은 재활용하기 쉬워졌다. 테스트 케이스는 설계 변경이 기능 변경을 일으키지 않음을 확인했다.

마무리

테스트 주도 개발을 통해 현장에서 사용되는 코드를 작성하는 과정을 살펴봤다. 속성 정의 같은 아주 간단한 사례부터 매개변수를 사용한 테스팅이 적용되고 리팩터링이 수행된 사례도 있었다. 각 테스트 케이스 제목을 나열해 보면 그것들이 요구사항이나 설계 내역의 세부 요소라는 것을 알 수 있을 것이다. 그런 의미에서 테스트 주도 개발을 통해 작성된 테스트 케이스는 자가 검증되는 요구사항 및 설계다.

테스트 주도 개발의 실천 사례를 소개한다는 목적으로 작성된 코드와 글이기 때문에 어떤 테스트 케이스는 과도하게 촘촘하다고 느껴질 수도 있다. 이것들이 최고의 실천 사례라고 할 수는 없으며 검증 수준은 상황에 따라 적절히 조절하는 것이 현명하다. 하지만 테스트 주도 개발을 처음 연습하려 한다면 아주 간단하고 작은 코드를 대상으로도 실천해 보는 것을 권장한다.

테스트 주도 개발이 유용할 것이라 생각하지만 막상 실전에 적용하려면 어디서 어떻게 시작해야 하는지 감을 잡기 어렵다는 얘기를 자주 접한다. 이런 어려움을 느끼는 프로그래머들에게 이 글이 도움이 되기를 바란다.