본문 바로가기
Group Study (22-23)/TDD

[TDD] 6. 대역

by 저스티 2023. 2. 24.

작성자: 정창우


대역의 필요성

외부 요인

테스트를 작성하다보면 외부 요인이 필요한 시점이 있다. 테스트에 관여하는 주요 외부 요인은 다음과 같다.

  • 파일 시스템을 이용
  • DB로부터 데이터를 조회하거나 데이터를 추가
  • 외부 HTTP 서버와 통신

외부 요인 문제점

테스트 대상이 이런 외부 요인에 의존하면 테스트를 작성하고 실행하기 어려워진다. 테스트 코드에서 사용하는 외부 API 서버가 일시적으로 장애가 나면 테스트를 원활하게 수행하기 어렵다. DB의 경우도 상황에 맞게 데이터를 구성하는 것이 항상 가능한 것은 아니다. 외부 요인의 문제점을 정리하면 다음과 같다.

  • 테스트 작성을 어렵게 만든다.
  • 테스트의 결과를 예측할 수 없다.

테스트 대상에서 의존하는 요인 때문에 테스트가 어려울 때는 대역을 써서 테스트를 진행할 수 있다. 위험한 액션이 필요할 때 배우를 대신해서 연기하는 스턴트맨처럼 테스트에서는 외부 요인을 대신하여 대역이 참여한다.

대역을 이용한 테스트

테스트를 힘들게 만드는 외부 요인을 대신하여 대역을 사용할 수 있다. 예를 들어 자동이체 기능을 만들려고 하는데 다음과 같은 외부 요인이 있다고 해보자.

  • 외부 카드 정보 API 연동 (카드 정보의 유효함을 검증하기 위함)
  • 자동이체 정보를 저장한 DB

이러한 외부 상황을 흉내낸 대역을 통해 결과를 검증할 수 있다. 

  • 외부 카드 정보 API 연동 대역
public class StubCardNumberValidator extends CardNumberValidator {
    private String invalidNo;
    
    public void setInvalidNo(String invalidNo) {
        this.invalidNo = invalidNo;
    }
    
    @Override
    public CardVality validate(String cardNumber) {
        if (invalidNo != null && invalidNo.equals(cardNumber)) {
            return CardValidy.INVALID;
        }
        return CardValidy.VALID;
    }
}

CardNumberValidator 객체를 통해 외부 API와 연동하여 카드번호가 유효한지 검사해야 하지만 stubCardNumberValidator 대역을 사용해서 상황을 흉내내었다.

  • 자동이체 정보를 저장한 DB 대역
public class MemoryAutoDebitInfoRepository implements AutoDebitInfoRepository{
    private Map<String, AutoDebitInfo> infos = new HashMap<>();
    
    @Override
    public void save(AutoDebitInfo info) {
        infos.put(info.getUserId(), info);
    }
    
    @Override
    public AutoDebitInfo findOne(String userId) {
        return infos.get(userId);
    }
}

AutoDebitInfoRepository 객체를 통해 DB에 접근해야 하지만 MemoryAutoDebitInfoRepository 대역을 사용하여 DB 없이 자동이체 정보를 저장하고 조회하는 상황을 흉내내었다.

대역의 종류

구현 방법에 따라 대역을 구분할 수 있다.

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

스파이 대역과 모의 대역에 대해서 예시를 통해 살펴보겠다.

스파이 대역 테스트

회원가입에 성공하면 이메일로 회원 가입 안내 메일을 발송한다고 하자. 이를 검증하기 위한 테스트 코드의 골격은 다음과 같다.

  1. userRepository.register("id", "pw", "email@domain.com")
  2. email@domain.com으로 이메일 발송을 요청했는지 확인

이메일 발송 여부를 어떻게 확인할 수 있을까? 이를 확인할 수 있는 방법 중 하나는 UserRegister가 EmailNotifier의 메일 발송 기능을 실행할 때 이메일 주소로 "email@domain.com"을 사용했는지 확인하는 것이다. 이런 용도로 사용할 수 있는 것이 바로 스파이 대역이다. 스파이 대역을 이용한 코드는 다음과 같다.

public class UserRegister {
    // 생략
    public void register(String id, String pw, String email) {
        // 생략
        userRepository.save(new User(id, pw, email));
        
        emailNotifier.sendRegisterEmail(email);
    }
}
public class MailTest {
    //생략
    @Test
    void whenRegisterThenSendMail() {
        userRegister.register("id", "pw", "email@domain.com");

        assertTrue(spyEmailNotifier.isCalled());
        assertEquals("email@domain.com", spyEmailNotifier.getEmail());
    }
}
public class SpyEmailNotifier implements EmailNotifier {
    private boolean called;
    private String email;
    
    public boolean isCalled(){
        return called;
    }
    
    public String getEmail() {
        return email;
    }
    
    @Override
    public void sendRegister(String email) {
        this.called = true;
        this.email = email;
    }
}

모의 객체로 스파이 대체

앞서 작성한 테스트 코드를 모의 객체를 이용해서 다시 작성하겠다. 모의 객체를 위한 몇 가지 도구가 존재하는데, Mockito를 사용하겠다. Mockito에 대한 기본 지식이 없으면 코드가 이해 안 될 수 있으니 이에 대한 배경지식을 학습한 뒤 다시 이어서 읽는 것을 추천한다.

// 생략
    private EmailNotifier mockEmailNotifier = Mockito.mock(EmailNotifier.class);
    
    @Test
    void whenRegisterThenSendMail() {
        userRegister.register("id", "pw", "email@domain.com");

        ArgumentCaptor<String> captor = ArgumentCaptor.forClass(String.class);
        BDDMockito.then(mockEmailNotifier)
                .should().sendRegisterEmail(captor.capture());
        
        String realEmail = captor.getValue();
        assertEquals("email@domain.com", realEmail);
    }

Mockito의 ArgumentCaptor는 모의 객체의 메서드를 호출할 때 전달한 객체를 담는 기능을 제공한다. BDDMockito.then().should()로 모의 객체의 매세드가 호출됐는지 확인할 때 capter() 메서드를 사용하면 메서드를 호출할 때 전달한 인자가 ArgumentCaptor에 담긴다.

의존 도출과 대역 사용

하나의 테스트는 특정한 상황에서 기능을 실행하고 그 결과를 확인한다. 그런데 외부 상황이 존재하는 경우에 실제 구현을 이용하면 상황을 제어하기 어렵기 때문에 대역을 사용한다. 이러한 상황에서 대역을 사용할 의존 부분을 도출하는 것이 중요하다. 위 예시의 대역을 사용한 테스트 코드를 잘 살펴보면 다음과 같은 순서로 이루어지는 것을 확인할 수 있다.

  • 제어하기 힘든 외부 상황을 별도 타입으로 분리
  • 테스트 코드는 별도의 분리한 타입의 대역을 생성
  • 생성한 대역을 테스트 대상의 생성자 등을 이용해서 전달
  • 대역을 이용해서 상황 구성

예를 들어 회원 가입에 성공한 경우 이메일을 발송하는 기능을 테스트한다고 하겠다. 테스트 코드는 다음과 같다. 

// 생략
    private SpyEmailNotifier spyEmailNotifier = new SpyEmailNotifier();
    
    @BeforeEach
    void setUp() {
        userRepository = new UserRegister(spyEmailNotifier);
    }

    @Test
    void whenRegisterThenSendMail() {
        userRegister.register("id", "pw", "email@domain.com");

        assertTrue(spyEmailNotifier.isCalled());
        assertEquals("email@domain.com", spyEmailNotifier.getEmail());
    }

위의 테스트 코드에서 의존을 도출하고 대역을 사용하는 과정은 다음과 같다.

  • 이메일 발송 결과 확인과 관련된 기능을 별도의 타입으로 분리 --> SpyEmailNotifier 구현
  • SpyEmailNotifier를 테스트 코드에서 생성
  • UserRepository의 생성자에 대역인 SpyEmailNotifier를 주입
  • 테스트 상황 구성

대역과 개발 속도

TDD 과정에서 대역을 사용하지 않고 실제 구현을 사용하면 다음과 같은 일이 발생한다.

  • 카드 정보 제공 업체에서 도난카드를 받을 때까지 테스트를 기다린다.
  • 카드 정보 제공 API가 비정상 응답을 주는 상황을 테스트하기 위해 업체의 변경 대응을 기다린다.
  • 회원가입 테스트를 한 뒤에 메일이 도착할 때까지 메일함을 확인한다.
  • 약한 암호 검사 기능을 개발할 때까지 회원 가입 테스트를 대기한다. 

네 경우 모두 대기 시간이 발생한다. 각 상황에 대한 설명은 다음과 같다.

  • 도난 카드에 대한 테스트를 진행하기 위해 업체로부터 도난 카드번호를 받아야 한다. 이는 1~2일 이상 소요되는 경우도 있다.
  • 비정상 응답 상황을 만들기 위해 업체가 변경할 때까지 기다려야 한다.
  • 회원 가입할 때 발송되는지 확인하려면 실제 이메일 주소를 이용해서 테스트를 진행해야 한다. 또한, 메일이 도착할 때까지 메일함을 확인해야 하는데 이는 몇 분 이상이 걸릴 수 있다.
  • 약한 암호 검사 기능을 다른 개발자가 구현하고 있다면 그것이 끝날 때까지 약한 암호에 대한 회원가입 테스트를 진행 할 수 없다.
대역을 사용하면 실제 구현이 없어도 실행 결과를 확인할 수 있다. 즉, 대역은 의존하는 대상을 구현하지 않아도 테스트 대상을 완성할 수 있게 만들어주며 이는 대기 시간을 줄여주어 개발 속도를 올리는 데 도움이 된다.

모의 객체를 과하게 사용하지 않기

모의 객체는 스텁과 스파이를 지원하므로 대역으로 모의 객체를 많이 사용한다. 하지만 모의 객체를 과하게 사용하면 테스트 코드가 오히려 복잡해질 수 있다. 간단한 회원 가입 성공 테스트를 모의 객체를 이용한 테스트 코드는 다음과 같다.

public class UserRepositoryMockOvercaseTest {
    private UserRegister userRegister;
    private UserRepository mockRepository = Mockito.mock(UserRepository.class);
    
    @BeforeEach
    void setUp() {
        userRegister = new UserRegister(mockRepository);
    }
    
    @Test
    void registerSuccess() {
        userRegister.register("id", "pw", "email");

        ArgumentCaptor<String> captor = ArgumentCaptor.forClass(User.class);
        BDDMockito.then(mockRepository).should().save(captor.capture());

        User savedUser = captor.getValue();
        assertEquals("id", savedUser.getId())
        assertEquals("email", savedUser.getEmail());
    }
}

성공 여부를 확인하기 위해 mockRepository의 save() 메서드가 호출되었는지 여부를 검증하여 호출할 때 전달한 인자를 저장했다. 이렇게 저장한 객체를 이용해서 값이 유효한지 검증했다. 

 

이 코드는 결과를 확인하기 위해 테스트 코드가 복잡해졌다. save() 메서드 호출 여부를 확인해야하고 ArgumentCaptor를 이용해서 전달한 인자를 저장해야 한다.  메모리를 이용한  가짜 구현을 사용하면 다음과 같이 코드가 단순해진다. 

@Test
    void register() {
        userRegister.register("id", "pw", "email");

        User savedUser = fakeRepository.findById("id");
        assertEquals("id", savedUser.getId())
        assertEquals("email", savedUser.getEmail());
    }

결과를 확인하는 코드가 단순해질 뿐만 아니라 코드의 의미도 더 명확하다.

"리포지토리의 save() 메서드를 호출해야 하고 이때 전달한 객체의 값이 어때야 한다" -> "리포지토리에 저장된 객체의 값이 어때야 한다"는 식으로 실제 검증할 내용에 더 가까워졌다.

 

모의 객체를 이용하면 대역 클래스를 만들지 않아도 되므로 편할 수 있다. 하지만 다음과 같은 단점이 있다.

  • 결과를 확인하는 수단으로 모의 객체를 사용하면 결과 검증 코드가 길어지고 복잡해진다.
  • 하나의 테스트를 위해 여러 모이 객체를 사용하면 결과 검증 코드의 복잡도가 배로 증가한다.
  • 기본적으로 모의 객체는 메서드 호출 여부를 검증하기 위한 수단이기 때문에 테스트 대상과 모의 객체 간의 상호 작용이 조금만 바뀌어도 테스트가 깨지기 쉽다.

이러한 이유로 모의 객체의 호출 여부를 결과 검증 수단으로 사용하는 것을 주의해야 한다.

특히 DAO나 리포지토리와 같이 저장소에 대한 대역은 모의 객체를 사용하는 것보다 메모리를 이용한 가짜 구현을 사용하는 것이 테스트 코드 관리에 유리하다. 물론 가짜 대역을 구현하는 것이 귀찮을 수 있지만 테스트 코드가 간결해지고 관리하기 쉬워진다.

참고용 도서

http://www.yes24.com/Product/Goods/89145195

 

테스트 주도 개발 시작하기 - YES24

TDD(Test-Driven Development)는 테스트부터 시작한다. 구현을 먼저 하고 나중에 테스트하는 것이 아니라 먼저 테스트를 하고 그다음에 구현한다. 구현 코드가 없는데 어떻게 테스트할 수 있을까? 여기

www.yes24.com