Study/TDD

TDD - 대역

hou27 2023. 2. 8. 01:00

이전 포스트

https://hou27.tistory.com/entry/TDD-%ED%85%8C%EC%8A%A4%ED%8A%B8-%EC%BD%94%EB%93%9C%EC%9D%98-%EA%B5%AC%EC%84%B1

 

TDD - 테스트 코드의 구성

이전 포스트 https://hou27.tistory.com/entry/TDD-JUnit-5-%EA%B8%B0%EC%B4%88 TDD - JUnit 5 기초 이전 포스트 https://hou27.tistory.com/entry/TDD-TDD-%E2%88%99-%EA%B8%B0%EB%8A%A5-%EB%AA%85%EC%84%B8-%E2%88%99-%EC%84%A4%EA%B3%84 TDD - TDD ∙ 기능

hou27.tistory.com

 

실습 환경

- Java 17

- Spring Boot 3.0.1

- wsl 2

 

 

대역의 필요성

이전 챕터에서 다뤘듯이 테스트를 작성하다 보면

다음과 같이 외부 요인이 테스트에 관여하는 상황이 생긴다.

  • 파일 시스템 사용
  • DB로부터 데이터 조회 혹은 추가
  • 외부의 HTTP 서버와 통신

 

이런 외부 요인들은 테스트 결과의 일관성을 무너뜨리고

나아가 테스트의 신뢰를 잃게 할 수 있다.

 

외부에 의존하는 예시

public class AutoDebitRegisterTest {
  private AutoDebitRegister register;

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

  @Test
  void validCard() {
    // 업체에서 받은 테스트용 유효한 카드번호 사용
    AutoDebitReq req = new AutoDebitReq("user1", "1234123412341234");
    RegisterResult result = register.register(req);

    // 검증
    assertEquals(CardValidity.VALID, result.getValidity());
  }

  @Test
  void theftCard() {
    // 업체에서 받은 도난 테스트용 카드번호
    AutoDebitReq req = new AutoDebitReq("user1", "1234123412341235");
    RegisterResult result = register.register(req);

    // 검증
    assertEquals(CardValidity.THEFT, result.getValidity());
  }
}

위 코드는

자동이체 관련 서비스를 테스트하는 코드이다.

 

위 테스트를 통과시키기 위해선

카드 업체로부터 유효한 테스트용 카드 번호도난당했다고 등록된 카드 번호를 제공받아야 한다.

 

그렇다면 이해를 위해 좀 더 자세히 살펴보도록 하겠다.

 

AutoDebitRegister

public class AutoDebitRegister {
  private CardNumberValidator validator;
  private AutoDebitInfoRepository repository;

  public AutoDebitRegister(
  	CardNumberValidator validator,
  	AutoDebitInfoRepository repository
  ) {
    this.validator = validator;
    this.repository = repository;
  }

  public RegisterResult register(AutoDebitReq req) {
    CardValidity validity = validator.validate(req.getCardNumber());
    if (validity != CardValidity.VALID) {
      return RegisterResult.error(validity);
    }

    AutoDebitInfo info = repository.findOne(req.getUserId());
    if(info != null) {
      info.changeCardNumber(req.getCardNumber());
    } else {
      AutoDebitInfo newInfo = new AutoDebitInfo(
      	req.getUserId(),
      	req.getCardNumber(),
      	LocalDateTime.now()
      );
      repository.save(newInfo);
    }

    return RegisterResult.success();
  }
}

먼저 자동이체 등록을 담당하는 부분이다.

 

자세히 보면

CardNumberValidator 클래스를 제공받아 카드가 유효한지 테스트하고 있다.

그렇다면 유효성을 어떻게 체크하고 있을까?

 

CardNumberValidator 클래스의 코드를 살펴보자

 

CardNumberValidator

public class CardNumberValidator {

  public CardValidity validate(String cardNumber) {
    HttpClient httpClient = HttpClient.newHttpClient();
    HttpRequest request = HttpRequest.newBuilder()
        .uri(URI.create("https://some-external-pg.com/card")) // 여기
        .header("Content-Type", "text/plain")
        .POST(BodyPublishers.ofString(cardNumber))
        .build();

    try {
      HttpResponse<String> response = httpClient.send(request, 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 e) {
      return CardValidity.ERROR;
    }
  }
}

주석을 달아둔 부분을 보면

https://some-external-pg.com/card

(실제 존재하지 않는 주소입니다.)

 

위 주소로 요청을 보내 카드의 유효성 검사를 진행하고 있는 것을 알 수 있다.

즉, 외부 HTTP 서버와 통신하고 있는 부분인 것이다.

 

결국 테스트를 통과시키기 위해선

외부 서버와 통신해서 예측한 결과를 반환받아야 하는 것이기 때문에

 

카드 업체로부터 유효한 테스트용 카드 번호 도난당했다고 등록된 카드 번호를 제공받았다고 해도

카드 번호가 만료되었거나, 도난 카드 정보가 외부 서버에서 사라진다면 테스트는 통과하지 못하게 된다.

 

이렇게 테스트 대상에서 의존하는 요인 때문에 테스트가 어려울 때는 '대역'을 써서 테스트를 진행할 수 있다.

 

즉, 외부 요인을 대신하는 대역을 사용하고, 그 대역을 직접 컨트롤함으로써

테스트에 영향을 미치는 전체를 통제하는 것이다.

 

대역을 이용한 테스트

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

 

CardNumberValidator의 대역

public class StubCardNumberValidator extends CardNumberValidator {
  private String invalidNo;
  private String theftNo;

  public void setInvalidNo(String invalidNo) {
    this.invalidNo = invalidNo;
  }

  public void setTheftNo(String theftNo) {
    this.theftNo = theftNo;
  }

  @Override
  public CardValidity validate(String cardNumber) {
    if(invalidNo != null && invalidNo.equals(cardNumber)) {
      return CardValidity.INVALID;
    }
    if(theftNo != null && theftNo.equals(cardNumber)) {
      return CardValidity.THEFT;
    }
    return CardValidity.VALID;
  }
}

StubCardNumberValidator는 실제 검증을 수행하지 않는다.

setter로 값을 정하고 그 값이 들어오면 정해진 값을 반환하도로 구성하였다.

 

StubCardNumberValidator 적용

public class AutoDebitRegister_Stub_Test {
  private AutoDebitRegister register;
  private StubCardNumberValidator stubValidator;
  private AutoDebitInfoRepository repository;

  @BeforeEach
  void setUp() {
    stubValidator = new StubCardNumberValidator();
    repository = new JpaAutoDebitInfoRepository();
    register = new AutoDebitRegister(stubValidator, repository);
  }

  @Test
  void invalidCard() {
    stubValidator.setInvalidNo("1234123412341234");

    // 설정한 유효하지 않은 카드번호 사용
    AutoDebitReq req = new AutoDebitReq("user1", "1234123412341234");
    RegisterResult result = register.register(req);

    // 검증
    assertEquals(CardValidity.INVALID, result.getValidity());
  }

  @Test
  void theftCard() {
    stubValidator.setTheftNo("1234123412341235");

    // 설정한 도난당한 카드번호 사용
    AutoDebitReq req = new AutoDebitReq("user1", "1234123412341235");
    RegisterResult result = register.register(req);

    // 검증
    assertEquals(CardValidity.THEFT, result.getValidity());
  }
}

BeforEach에서 만들어 둔 대역을 사용하도록 설정하고, 각 테스트마다 특정 값을 setting 하고

그 값을 통해 테스트를 진행하도록 해두었다.

 

이제 카드 번호 검증 단계에선 외부 의존성이 사라졌다.

 

DB 또한 대역을 사용할 수 있다.

 

JpaAutoDebitInfoRepository의 대역

public class MemoryAutoDebitInfoRepository implements AutoDebitInfoRepository {
  private Map<String, AutoDebitInfo> infos = new HashMap<>();

  @Override
  public AutoDebitInfo findOne(String userId) {
    return infos.get(userId);
  }

  @Override
  public void save(AutoDebitInfo info) {
    infos.put(info.getUserId(), info);
  }
}

실제 DB를 사용하지 않고 Map을 통해 메모리를 사용하도록 간단하게 구성한 대역을 구현하였다.

 

MemoryAutoDebitInfoRepository 적용

public class AutoDebitRegister_Fake_Test {
  private AutoDebitRegister register;
  private StubCardNumberValidator stubValidator;
  private AutoDebitInfoRepository repository;

  @BeforeEach
  void setUp() {
    stubValidator = new StubCardNumberValidator();
    repository = new MemoryAutoDebitInfoRepository();
    register = new AutoDebitRegister(stubValidator, repository);
  }

  @Test
  void alreadyRegistered_InfoUpdated() {
    repository.save(new AutoDebitInfo("user1", "123412349876", LocalDateTime.now()));

    AutoDebitReq req = new AutoDebitReq("user1", "123412349999");
    RegisterResult result = register.register(req);

    AutoDebitInfo saved = repository.findOne("user1");
    assertEquals("1234123412341235", saved.getCardNumber());
  }

  @Test
  void notYetRegistered_newInfoRegistered() {
    AutoDebitReq req = new AutoDebitReq("user1", "1234123412341234");
    RegisterResult result = register.register(req);

    AutoDebitInfo saved = repository.findOne("user1");
    assertEquals("1234123412341234", saved.getCardNumber());
  }
}

이전과 같이 setUp 메서드에서 대역을 사용하도록 설정하고, 테스트를 완성해 주었다.

 

모두 가정한 것들이지만 실제 서비스에서 사용하는 DB에 영향을 받지도, 미치지도 않게 하여

이제 DB에도 의존하지 않게 되었다.

 

 

지금까지를 정리하자면 대역을 사용하여 다음 두 가지 없이 테스트를 수행할 수 있게 되었다.

  • 외부 카드 정보 API
  • 자동이체 정보를 저장하는 DB

이렇게 외부를 흉내 낸 대역을 사용하면 외부에 대한 결과를 검증할 수 있고, 테스트의 결과를 일관되게 할 수 있다.

 

대역의 종류

대역을 구현하고 사용하면서 stub, fake 등의 단어가 등장한 것을 눈치챘을 수도 있다.

대역은 여러 가지로 구분된다.

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

 

이메일 발송 여부 확인을 위해 스파이를 사용

위에서 Stub과 Fake에 대한 예시는 확인했으니 나머지를 살펴보도록 하겠다.

 

먼저 Spy이다.

 

회원 가입 성공 시 이메일로 회원 가입 안내 메일을 발송한다고 하면,

이를 검증하기 위한 테스트의 골격은 다음과 같다.

// 실행
userRegister.register("id", "pw", "email@somedomain.com");

// 결과
email@somedomain.com로 메일 발송을 요청했는지 확인

이런 경우 메일 발송 기능 실행을 확인하기 위해

Spy 대역을 사용한다.

 

먼저 UserRegister class이다.

 

UserRegister

public class UserRegister {
  private WeakPasswordChecker passwordChecker;
  private UserRepository userRepository;
  private EmailNotifier emailNotifier;

  public UserRegister(WeakPasswordChecker passwordChecker,
      EmailNotifier emailNotifier,
      UserRepository userRepository) {
    this.passwordChecker = passwordChecker;
    this.emailNotifier = emailNotifier;
    this.userRepository = userRepository;
  }

  public void register(String id, String pw, String email) {
    if(passwordChecker.checkPasswordWeak(pw)) {
      throw new WeakPasswordException();
    }

    User user = new User(id, pw, email);
    userRepository.save(user);

    emailNotifier.sendRegisterEmail(email); // 여기 메일 발송 호출 코드
  }

}

가장 마지막 코드가 바로 메일 발송을 호출하는 코드임을 알 수 있다.

 

그렇다면 Spy 대역을 통해 저 부분이 호출됐는지 기록해야 한다.

 

Spy 대역

public class SpyEmailNotifier implements EmailNotifier {
  private boolean called;
  private String email;

  public boolean isCalled() {
    return called;
  }

  public String getEmail() {
    return email;
  }

  @Override
  public void sendRegisterEmail(String email) {
    called = true;
    this.email = email;
  }
}

EmailNotifier를 대체할 Spy 대역이다.

메일을 호출하는 메서드였던 sendRegisterEmail을 위와 같이 호출될 때 기록되도록 구현해 두었다.

 

테스트 코드

public class UserRegisterTest {

  private UserRegister userRegister;
  private StubWeakPasswordChecker stubWeakPasswordChecker
      = new StubWeakPasswordChecker();
  private SpyEmailNotifier spyEmailNotifier
      = new SpyEmailNotifier();
  private MemoryUserRepository fakeRepository
      = new MemoryUserRepository();

  @BeforeEach
  void setUp() {
    userRegister = new UserRegister(
        stubWeakPasswordChecker,
        spyEmailNotifier,
        fakeRepository
    );
  }

  @Test
  @DisplayName("가입 시 메일 전송")
  void whenRegister_thenSendMail() {
    userRegister.register("id", "pw", "email@mail.com");

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

이제 테스트 코드에선 Spy 대역을 사용하도록 하고,

검증부에서 준비해 둔 메서드를 통해 호출 여부를 확인할 수 있다.

 

모의 객체(Mock)로 Stub과 Spy 대체

이번엔 Mock이다.

 

Spy 대역 예시를 살펴볼 때와 같은 코드를 기반으로 진행하도록 하겠다.

 

이번 상황은 약한 암호인 경우 가입에 실패하는 테스트이다.

이 테스트를 모의 객체를 통해 작성할 것이다.

 

모의 객체는 Mockito를 사용하여 생성할 예정이다.

 

Stub을 대체

public class UserRegisterMockTest {

  private UserRegister userRegister;
  private WeakPasswordChecker mockPasswordChecker
      = Mockito.mock(WeakPasswordChecker.class); // 모의 객체로 Stub 대체
  private EmailNotifier mockEmailNotifier
      = Mockito.mock(EmailNotifier.class); // 모의 객체로 설정
  private UserRepository fakeRepository
      = new MemoryUserRepository();

  @BeforeEach
  void setUp() {
    userRegister = new UserRegister(
        mockPasswordChecker,
        mockEmailNotifier,
        fakeRepository
    );
  }

  ...
}

Mockito.mock() 메서드는 전달받은 인자의 타입의 모의 객체를 생성한다.

 

모의 객체를 통해 테스트

@Test
@DisplayName("약한 암호면 가입 실패")
void whenWeakPassword_thenFailRegister() {
  BDDMockito.given(mockPasswordChecker.checkPasswordWeak("pw"))
        .willReturn(true);

  assertThrows(WeakPasswordException.class, () -> {
    userRegister.register("id", "pw", "email");
  });
}

모의 객체를 통해 테스트하는 코드를 자세히 살펴보면, BDDMockito라는 것을 사용했다.

 

BDDMockito Mockito을 상속한 클래스로, BDD(Behavior-Driven Development)

를 사용하여 테스트코드를 작성할 때, 시나리오에 맞게 테스트 코드가 보다 자연스럽게

읽힐 수 있도록 변경한 프레임워크이다.

동작 방식이나 사용법은 거의 차이가 없다.

 

BDDMockito를 사용한 라인은 모의 객체를 아래와 같이 설정한다.

BDDMockito
// "pw"인자를 사용해서 모의 객체의 checkPasswordWeak 메서드 호출 시
.given(mockPasswordChecker.checkPasswordWeak("pw"))
// true를 반환하도록 설정
.willReturn(true);

결국 테스트 코드가 실행되며 checkPasswordWeak 메서드가 "pw" 인자를 사용하여 실행되면,

true가 반환되고 테스트는 의도한 대로 동작하게 되는 것이다.

 

이렇게 모의 객체를 설정하고, 이 대역 객체를 통해 의도한 대로 상호작용했는지 테스트하는 것이 "Mock"의 주요 기능이다.

추가 예시를 통해 BDDMockito의 기능을 살펴보자

 

모의 객체가 기대한 대로 불렸는지 검증

@Test
@DisplayName("회원 가입 시 암호 검사 수행")
void whenRegister_thenCheckPassword() {
  userRegister.register("id", "pw", "email");

  BDDMockito.then(mockPasswordChecker)
      .should()
      .checkPasswordWeak("pw");
}

위 테스트에선 순서대로

  1. 회원가입을 진행한 후,
  2. BDDMockito.then()에 인자로 전달한 모의 객체의
  3. should() --> 특정 메서드가 호출됐는지 검증
  4. checkPasswordWeak("pw") --> "pw" 인자를 통해 checkPasswordWeak 메서드가 호출됐는지 확인

위와 같이 진행된다.

 

Spy를 대체

@Test
@DisplayName("가입 시 메일 전송")
void whenRegister_thenSendMail() {
  userRegister.register("id", "pw", "email@mail.com");

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

모의 객체를 통해 Spy 또한 대체가 가능하다.

 

Mockito의 ArgumentCaptor는 모의 객체의 메서드가 호출될 때 전달한 객체를 담는 기능을 제공한다.

그래서 위와 같이

captor.capture()

를 통해 sendRegisterEmail 메서드 실행 시 전달된 인자를 ArgumentCaptor에 담고,

getValue 메서드를 사용해서 검증할 수 있다.

 

대역과 개발 속도

대역을 사용하면 실제 구현 없이도 실행 결과를 확인할 수 있다.

또한 불확실한 외부 요인에 의존하지 않아도 테스트를 성공적으로 진행할 수 있도록 도와준다.

즉, 대역은 의존하는 대상을 구현하지 않아도 테스트 대상을 완성할 수 있게 해 주고

이는 대기 시간을 줄여주어 개발 속도를 높일 수 있게 해 준다.

 


참고자료

 

테스트 주도 개발 시작하기

'Study > TDD' 카테고리의 다른 글

TDD - 테스트 범위와 종류  (0) 2023.02.21
TDD - 테스트가 가능한 설계  (0) 2023.02.14
TDD - 테스트 코드의 구성  (0) 2023.02.03
TDD - JUnit 5 기초  (0) 2023.01.25
TDD - TDD ∙ 기능 명세 ∙ 설계  (2) 2023.01.16