테스트 주도 개발 (Test Driven Development) 제 2편!

평소 궁금했던 테스트 주도 개발(Test-Driven Development)대한 책을 읽고, 배웠던 점을 블로그 포스트로 정리해보고자 한다.

책은 최범균 저 테스트 주도 개발 시작하기를 읽었다!

TDD에서 사용하는 테스트 코드의 구성과 대역에 대해 알아보자!




🔷 테스트 코드의 구성

🔶 기능에서의 상황

우리가 구현하고자 하는 대부분의 기능은 상황에 따라 다르게 동작한다.

예를 들면, 숫자 야구 게임을 들 수 있다.

숫자 야구 게임은 0~9 사이의 서로 다른 숫자 세 개를 고르면 상대방이 그 숫자를 맞추는 게임이다.

이 게임에서의 상황은 정답 숫자이고, 이 숫자에 따라 같은 예측이라도 결과가 달라진다.

예를 들어 예측한 숫자가 456일 때 정답 숫자가 123인 경우와 456인 경우 결과가 다르다.

BaseballGame game1 = new BaseballGame("123"); // 정답이 123일 때    (상황)
Score score1 = game1.guess("456");            // 456으로 예측한 경우 (실행)
assertEquals(O, score1.balls());              // Ball은 0개       (결과)
assertEquals(O, score1.strikes());            // Strike도 0개     (결과)
assertEquals(3, score1.outs());               // Out은 3개        (결과)

BaseballGame game2 = new BaseballGame("456"); // 정답이 456일 때    (상황)
Score score2 = game2.guess("456");            // 456으로 예측한 경우 (실행)
assertEquals(0, score2.balls());              // Ball은 0개       (결과)
assertEquals(3, score2.strkes());             // Strike는 3개     (결과)
assertEquals(0, score1.outs());               // Out은 0개        (결과)

이처럼 주어진 상황에 따라 기능 실행 결과는 달라진다.




🔶 테스트 코드의 구성 요소 : 상황, 실행, 결과 확인

테스트 코드는 크게 3가지로 구성 할 수 있다.

요소 설명 영어
상황 테스트에서 구체화하고자 하는 행동을 시작하기 전에 테스트 상태를 설명하는 부분 given
실행  구체화하고자 하는 그 행동 when
결과 어떤 특정한 행동 때문에 발생할거라고 예상되는 변화에 대한 설명 then

이는 테스트 코드를 작성할 때 주로 사용되는 요소들이며,

너무나 유명하여 Given-When-Then 패턴으로 알려져있다.

구글에 검색해도 쉽게 관련한 여러 문서들을 확인 할 수 있다.

📚 참고 문헌

@Test
public void Given_NoMilkCoffee_When_GetDiscount_Then_Minus100()
{
    // Given
    string name = "americano";
    int defaultPrice = 1000;
    int expectedPrice = 900;

    Coffee coffee = Coffee.Builder().CoffeeName(name)
                                     .Price(defaultPrice)
                                     .IsMilkBase(false)
                                     .Build();
    
    // When
    int actualPrice = coffee.getDiscountedPrice();

    // Then
    assertEquals(expectedPrice, actualPrice);
}

상황이 없는 경우도 존재한다.

이전 포스트에서 사용한 암호 강도 측정 예가 이에 해당한다.

암호 강도 측정의 경우 결과에 영향을 주는 상황이 존재하지 않으므로

테스트는 다음처럼 기능을 실행 하고 결과를 확인하는 코드만 포함하고 있다.

@Test
void When_MeetsAllCriteria_Then_Strong() 
{
    // When
    PasswordStrengthMeter meter = new PasswordStrengthMeter(); 
    Passwordstrength result = meter.meter("abl2!@AB");

    // Then
    assertEquals(PasswordStrength.STRONG, result);
}




🔶 외부 상황과 외부 결과

상황 설정(Given)이 테스트 대상으로 국한된 것은 아니다. 상황에는 외부 요인도 있다.

예를 들면, 다음과 같은 기능을 구현하고 테스트한다고 생각해 보자.

  • 파일에서 숫자를 읽어와 숫자의 합을 구한다.

  • 한 줄마다 한 개의 숫자를포함한다.

위 기능은 다음과 같이 작성됐다고 하자.

public class MathUtils
{
    public static int Sum(string filePath)
    {
        int totalSum = 0;

        // 파일에서 한 줄씩 읽어서 숫자를 합산합니다.
        using (StreamReader reader = new StreamReader(filePath))
        {
            string line;
            while ((line = reader.ReadLine()) != null)
            {
                if (int.TryParse(line, out int number))
                {
                    totalSum += number;
                }
            }
        }

        return totalSum;
    }
}

MathUtils.sumO 메서드를 테스트하려면 파일이 존재하지 않는 상황에서의 결과도 확인해야 한다.

그렇다면 파일이 존재하지 않는 상황을 어떻게 만들 수 있을까?

가장 쉬운 방법은 존재 하지 않는 파일을 경로를 사용하는 것이다.

@Test
void Given_noDataFile_When_GetSum_Then_Exception() {
    // Gvien
    string fileName = "badpath.txt";

    // When & Then
    assertThrows( IllegalArgumentException, () => MathUtils.sum(dataFile) );
}

이렇게 쉽게 구현할 수 있다.

하지만 이 방식은 큰 문제가 있는데, 그것은 항상 테스트에 성공할 것이라는 보장이 없다는 것이다!

바로 우연이라도 해당 파일이 존재할 수 있기 때문이다.

TDD로 개발을 할 경우, 이런 외부 상황에 대해서도 테스트의 결과가 변하지 않도록

항상 주의해서 테스트 코드를 작성해야 할 것이다.

책에서는 위 예제에서 항상 파일이 없는 상황을 만들기 위해,

해당 위치에 파일이 존재할 경우 테스트 시작 전 그 파일을 삭제하는 코드를 작성하는 것으로 문제를 해결하였다.


다른 예로는 이런 예시들이 있을 수 있다.

  • DB에 아이디를 새로 만들 때, 중복된 아이디가 존재하지 않아야 정상적으로 테스트를 진행 할 수 있다.

  • DB에 아이디 중복을 체크하는 테스트를 할 때, DB에 이미 해당 아이디가 존재해야 정상적으로 테스트를 진행 할 수 있다.

위 두 테스트는 외부 상황인 DB의 상황에따라 테스트가 실패할 수도, 성공할 수도 있다.

이렇게 외부 상태에 따라 테스트의 성공 여부가 바뀌지 않으려면

테스트 실행 전에 외부를 원하는 상태로 만들거나 테스트 실행 후에 외부 상태를 원래대로 되돌려 놓아야 한다.


테스트 코드는 한 번만 실행하고 끝나지 않는다.

영원한 TDD의 굴레

TDD를 진행하는 동안에도 계속 실행하고 개발이 끝난 이후에도 반복적으로 테스트를 실행해서 문제가 없는지 검증한다.

그렇기 때문에 테스트는 언제 실행해도 항상 정상적으로 동작하는 것이 중요하다.

간헐적으로 실패하거나 다른 테스트 다음에 실행해야 성공하면 테스트 결과를 믿을 수 없게 된다.

이렇게 되면 테스트가 실패해도 무감각해지고 더 나아가 테스트를 만들지 않게 된다.




🔶 외부 상태와 테스트 어려움

상황과 결과에 영향을 주는 외부 요인은 파일, DBMS, 외부 서버 등 다양하다.

이들 외부 환경을 테스트에 맞게 구성하는 것이 항상 가능한 것은 아니다.

예를 들어, 자동 이체 등록 기능을 구현한다고 생각해보자.

금융 회사에서 제공하는 REST API를 사용한다면 자동이체 등록 기능에 대한 테스트는

다음 상황에서의 결과를 확인 할 수 있어야 한다!

  • REST API 응답 결과가 유효한 계좌 번호인 상황
  • REST API 응답 결과가 유효하지 않은 계좌 번호인 상황
  • REST API 서버에 연결할 수 없는 상황
  • REST API 서버에서 응답을 N초 이내에 받지 못하는 상황


문제는 금융회사에 이런 REST API의 수정을 부탁할 수 없다는 것이다.

잠깐 릴리즈되고 있는 서버를 꺼달라고 할 수도 없고, 응답을 일부러 N+M초 후에 주도록 수정해 달라고 할 수도 없다.

이처럼 테스트 대상이 아닌 외부 요인은 테스트 코드에서 다루기 힘든 존재이다.

이렇게 테스트 대상의 상황과 결과에 외부 요인이 관여할 경우 대역 (test double)을 사용하면 테스트 작성이 쉬워진다.

대역은 테스트 대상이 의존하는 대상의 실제 구현을 대신하는 구현인데

대역을 통해서 외부 상황이나 결과를 대체할 수 있다.








🔷 대역 (Test-Double)

🔶 대역의 필요성

테스트를 작성하다 보면 외부 요인이 필요한 시점이 있다.

외부 요인이 테스트에 관여하는 주요 예는 아래와 같다.

  • 테스트 대상에서 파일 시스템을 사용

  • 테스트 대상에서 DB로부터 데이터를 조회하거나 데이터를 추가

  • 테스트 대상에서 외부의 HTTP 서버와 통신

테스트 대상이 이런 외부 요인에 의존하면 테스트를 작성하고 실행하기 어려워진다.

외부 요인은 테스트 작성을 어렵게 만들 뿐만 아니라 테스트 결과도 예측할 수 없게 만든다.


이쯤에서 예를 하나 들어가며 내용을 살펴보도록 하자.

우리는 자동이체 기능을 만들어야 한다고 치자.

이 기능은 외부 업체가 제공하는 API를 이용해서 카드번호가 유효한지 확인하고

그 결과에 따라 자동이체 정보를 등록하는 기능이다.

외부 REST API 서버를 이용하는 카드 등록 서비스

이 기능을 테스트하려면 정상 카드번호, 도난 카드번호, 만료일이 지난 카드번호가 필요하기 때문에

외부 업체에서 상황별로 테스트할 수 있는 카드번호를 받아와야 한다.

이 때, 카드 정보 검사 대행업체에서 테스트할 때 사용하라고 제공한 카드번호의 유효기간이 한 달 뒤일 수도 있다.

이 카드번호를 사용해서 성공한 자동이체 정보 등록 기능 테스트는 한 달 뒤에 유효 기간 만료로 실패하게 된다.

실제 코드로 살펴보자.

이 중, CardNumberValidator는 아래와 같이 구성되어 있다고 치자.

// 아래 코드(책에 나와있는 코드)는 Java를 기준으로 작성되었다.
// C#에도 HttpClient나 HttpRequest 클래스가 있는 것을 확인했으나,
// 정확한 사용 용례까진 파악하지 않았다. 어디까지나 내용 파악을 위해서만 읽어볼 것!
public class CardNumberValidator {
    public virtual CardValidity Validate(string cardNumber) {

        // HTTP 통신을 위해 준비
        HttpClient httpClient = HttpClient.newHttpClient();
        HttpRequest request = HttpRequest.newBuilder()
                .uri(URI.create("https://some-external-pg.com/card"))
                .header("Content-Type", "text/plain")
                .POST(HttpRequest.BodyPublishers.ofString(cardNumber))
                .build();

        // 전송 후, 전달받은 내용에 따라 enum값 리턴
        try {
            HttpResponse<string> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
            switch (response.body()) {
                case "ok":
                    return CardValidity.VALID;
                case "bad":
                    return CardValidity.INVALID;
                case "expired":
                    return CardValidity.EXPIRED;
                case "theft":
                    return CardValidity.THEFT;
                default:
                    return CardValidity.UNKNOWN;
            }
        } catch (IOException | InterruptedException exception) {
            return CardValidity.ERROR;
        }
    }
}

그리고 이를 이용해 테스트를 하는 코드는 아래와 같다.

public class AutoDebitRegisterTest {
    private AutoDebitRegister register;

    @BeforeEach
    void setUp() {
        CardNumberValidator validator = new CardNumberValidator();
        AutoDebitInfoRepository repository = new JpaAutoDebitInfoRepository();
        register = new AutoDebitRegister(validator, repository);
    }

    @Test
    void Given_VaildCardNum_When_Register_Then_Valid() {
        // Given : 업체에서 받은 테스트용 유효한 카드번호 사용
        AutoDebitReq req = new AutoDebitReq("user1", "CARD_NUMBER_OF_VALID");

        // When
        RegisterResult result = register.Register(req);

        // Then
        assertEquals(VALID, result.GetValidity());
    }
    
    @Test
    void Given_StolenCardNum_When_Register_Then_Theft() {
        // Given : 업체에서 받은 도난 테스트용 카드번호 사용
        AutoDebitReq req = new AutoDebitReq("user1", "CARD_NUMBER_OF_STOLEN");

        // When
        RegisterResult result = register.Register(req);

        // Then
        assertEquals(THEFT, result.GetValidity());
    }
}

Given_VaildCardNum_When_Register_Then_Valid() 테스트를 통과시키려면 외부 업체에서 테스트 목적의 유효한 카드번호를 받아야 한다.

만약 이 카드번호가 한 달 뒤에 만료되면 Given_VaildCardNum_When_Register_Then_Valid() 테스트는 한 달 뒤부터 실패한다.

비슷하게 테스트 용도의 도난 카드 정보를 외부 업체에서 삭제하면 Given_StolenCardNum_When_Register_Then_Theft()테스트는 실패한다.

물론 외부 서비스가 개발 환경을 제공하지 않으면 테스트는 더욱 힘들어진다.




🔶 대역을 이용한 테스트

대역을 이용해서 테스트하는 코드를 다시 작성해보자.

먼저 CardNumberValidator를 대신할 대역 클래스를 작성하자.

public class StubCardNumberValidator : CardNumberValidator 
{
    private List<string> validNo;
    private List<string> invalidNo;
    private List<string> stolenNo;
    private List<string> expiredNo;

    public override CardValidity Validate(string cardNumber) 
    {
        forech(var valid in validNo)
        {
            if(valid.equals(cardNumber) == true)
            {
                return CardValidity.VALID;
            }
        }

        forech(var invalid in invalidNo)
        {
            if(invalid.equals(cardNumber) == true)
            {
                return CardValidity.INVALID;
            }
        }

        forech(var stolen in stolenNo)
        {
            if(stolen.equals(cardNumber) == true)
            {
                return CardValidity.THEFT;
            }
        }

        forech(var expired in expiredNo)
        {
            if(expired.equals(cardNumber) == true)
            {
                return CardValidity.EXPIRED;
            }
        }

        return CardValidity.UNKNOWN;
    }
}

StubCardNumberValidator는 실제 카드번호 검증 기능을 구현하지 않는다.

대신 단순한 구현으로 실제 구현을 대체한다.

즉, 서버 통신없이도 카드 번호를 체크할 수 있는 대역을 구현하고

그 대역을 이용해서 여러가지 카드번호에 대한 자동이체 기능을 테스트 한 것이다.

다른 예로는, DB 연동 코드나 파일 연동 코드도 대역을 사용하기에 적합하다고 볼 수 있다.




🔶 대역의 종류

그렇다면 대역에는 어떤 종류의 대역들이 있을까?

구현에 따라 아래와 같이 대역을 구분 할 수 있다고 한다.

대역 종류 설명
스텁
Stub
구현을 단순한 것으로 대체한다. 테스트에 맞게 단순히 원하는 동작을 수행한다.
가짜
Fake
제품에는 적합하지 않지만, 실제 동작하는 구현을 제공한다.
스파이
Spy
호출된 내역을 기록한다. 기록한 내용은 테스트 결과를 검증할 때 사용한다. 스텁이기도 하다.
모의
Mock
기대한 대로 상호작용하는지 행위를 검증한다. 기대한 대로 동작하지 않으면 익셉션을 발생할 수 있다. 모의 객체는 스텁이자 스파이도 된다.

예를 이용해서 대역의 종류를 상세하게 살펴보도록 하자.

사용할 예는 회원 가입 기능이다. 회원 가입 기능을 구현할 UserRegister 및 관련 타입이 아래와 같다고 한다.

회원 가입 서비스의 UML

각 타입은 다음 역할을 수행한다.

  • UserRegister : 회원 가입에 대한 핵심 로직을 수행한다.
  • WeakPasswordChecker : 암호가 약한지 검사한다.
  • UserRepository : 회원 정보를 저장하고 조회하는 기능을 제공한다.
  • EmailNotifier : 이메일 발송 기능을 제공한다.

이렇게 UserRegister를 위한 테스트를 만들어 나가는 과정에서,

WeakPasswordChecker, UserRepository, EmailNotifier를 대신할 클래스를 만든다고 하자.

그럼 아래와 같이 각 클래스별 인터페이스를 생성할 수 있을 것이다.

인터페이스 생성

그리고 각 항목별 인터페이스를 상속받는 대역들을 만들 수 있을 것이다.

인터페이스를 구현하고 있는 대역들



💠 약한 암호 확인 기능에 스텁(Stub) 사용

암호가 약한 경우 회원 가입에 실패하는 테스트부터 시작한다.

암호가 약한지를 UserRegister가 직접 구현하지 않고 WeakPasswordChecker를 사용하게 한다.

이렇게 하면 각 타입의 역할을 적절하게 분리할 수 있다.

public class UserRegister 
{
    private IWeakPasswordChecker passwordChecker;

    public void Register(string id, string pw, string email) 
    {
        if (passwordChecker.CheckPasswordWeak(pw) == true) 
        {
            throw new WeakPasswordException();
        }
    }
}


테스트 대상이 UserRegister이므로 WeakPasswordChecker는 대역을 사용한다.

실제 동작하는 구현은 필요하지 않고 약한 암호인지 여부를 알려주기만 하면 되므로 스텁 대역이면 충분하다.

public class UserRegisterTest 
{
    private UserRegister userRegister;
    private StubWeakPasswordChecker stubPasswordChecker = new StubWeakPasswordChecker();

    @BeforeEach
    private void SetUp() 
    {
        userRegister = new UserRegister(stubPasswordChecker);
    }

    @Test
    private void Given_WeakPassword_Then_Exception() 
    {
        // Given
        stubPasswordChecker.SetWeak(true); // 암호가 약하다고 응답하도록 설정
        
        // When & Then
        assertThrows(WeakPasswordException, () => 
        {
            userRegister.register("id", "pw", "email");
        });
    }
}

실제로 구현될 StubWeakPasswordChecker는 아래와 같을 것이다.

public class StubWeakPasswordChecker : IWeakPasswordChecker 
{
    private bool weak;

    public void SetWeak(boolean weak) {
        this.weak = weak;
    }

    public override bool CheckPasswordWeak(string pw) {
        return weak;
    }
}

StubWeakPasswordCheckerCheckPasswordWeak() 메서드는 단순히 weak 필드 값을 리턴한다.


Stubs provide canned answers to calls made during the test, usually not responding at all to anything outside what’s programmed in for the test.

  • 테스트 동안 설정된 응답을 반환하는 객체로, 일반적으로 테스트를 위해 설정된 것 이외에는 응답을 반환하지 않음.

  • 그래서 정해진 응답을 반환하도록 만드는 행위를stubbing 이라고 한다.



💠 리포지토리를 가짜(Fake)구현으로 사용

다음 테스트로 동일 ID를 가진 회원이 존재할 경우 익셉션을 발생하는 테스트를 작성하자.

public class UserRegisterTest 
{
    private UserRegister userRegister;
    private StubWeakPasswordChecker stubPasswordChecker = new StubWeakPasswordChecker();
    private FakeUserRepository fakeRepository = new FakeUserRepository();

    @BeforeEach
    private void SetUp() 
    {
        userRegister = new UserRegister(stubPasswordChecker, fakeRepository);
    }

    // ... 중략 ...

    @Test
    private void Given_DupIdExists_Then_Exception() 
    {
        // Given
        fakeRepository.Save(new User("id", "pw1", "email@email.com"));

        // When & Then
        assertThrows(DupIdException, () => 
        {
            userRegister.Register("id", "pw2", "email");
        });
    }
}

테스트 코드를 추가했으니, 기존 UserRegister 클래스도 수정하자.

public class UserRegister 
{
    private IWeakPasswordChecker passwordChecker;
    private IUserRepository userRepository;        // 이 부분 추가됨

    public void Register(string id, string pw, string email) 
    {
        if (passwordChecker.CheckPasswordWeak(pw) == true) 
        {
            throw new WeakPasswordException();
        }

        // 이 부분 추가됨
        User user = userRepository.indById(id);
        if (user != null) 
        {
            throw new DupIdException();
        }
    }
}

회원 가입에 성공했는지 여부를 확인하려면 리포지토리에 새로운 사용자 정보가 올바르게 저장되었는지 확인하면 된다.

따라서 가짜 대역을 이용해서 저장된 User 객체를 구하고 이 객체가 원하는 값을 갖는지 검증한다.

public class MemoryUserRepository : IUserRepository 
{ 
    private Dictionary<string, User> users = new Dictionary<string, User>();

    public override void Save(User user) 
    {
        users.Add(user.GetId(), user);
    }

    public override User FindById(string id) 
    {
        if(users.ContainsKey(id) == true)
        {
            return null;
        }
        return users.Get[id];
    }
}

Fake objects actually have working implementations, but usually take some shortcut which makes them not suitable for production (anInMemoryTestDatabase is a good example).

  • 실제 프로덕션에 사용하는 구현체를 테스트에 사용하기에는 적합하지 않을 때(구동 속도 등), 이에 대한 기능 축소판의 구현체를 대신 사용하는 것.

  • e.g., DB 대신 HashMap container같은 in memory database를 사용하는 경우





🔶 다음 편에 계속…!

🚩 다음편 : (작성 예정)