외부 서비스를 테스트하는 방법에 대해서는 아주 고민이 많았다.
사실 우리가 테스트 코드를 짜는 이유는,
우리가 생성해낸 비즈니스 로직이 본인이 설계한 의도대로 정상적으로 작동하는지,
혹은 예외사항이 발생했을 때 정상적으로 예외가 발생하는지에 대해서 알기 위해서 테스트코드를 짠다.
그렇다면, 외부 서비스는 어떤식으로 테스트 할 것인가?
실제로 교보문고 주문검색 서비스 팀에서 면접을 볼 때, 해당 질문을 받았다.
제가 사용한 라이브러리에서 테스트를 위한 테스트용 토큰을 지원해주기 때문에 테스트를 하기가 수월하며, 테스트용 토큰과 실제 결제에서 발생하는 토큰에는 로직 차이가 없기때문에 문제가 없을 것이다.
라고 대답을 했었다.
결제모듈 서버에 요청해서 받아온 정보와 대조해서 검증하는 실제 비즈니스 로직에서 정상적으로 작동한다는 의미를 말하고 싶었지만,
만약 테스트용 토큰을 제공하지 않는 라이브러리였다면 어땠을까? 과연 난 모든 외부 서비스의 경우에 똑같이 대처할 수 있을까?
면접 후에 이렇게 생각한 순간 잘못 답변했다고 느꼈었다...
이 문제에서 내가 내린 결론은 외부 서비스에서 성공, 실패하는 경우는 상정하지 않는것으로 했다.
이유는 다음과 같다.
1. 외부 서비스의 총체적인 흐름을 우리는 알지 못한다. (또한 알 필요가 없다.)
2. 외부 서비스에서 발생할 수 있는 다양한 요인은 오로지 외부 서비스에 책임이 있다. (서버 부하 등의 문제를 제외)
3. 외부 서비스는 언제든지 교체될 수 있기때문에, 테스트코드가 하나의 외부서비스를 의존하는것은 유지보수성이 떨어진다.
이와 같은 이유로 테스트를 외부서비스에 의존하지 않게 바꿨는데, 처음에는 Mock을 사용해서 테스트를 했었다.
@SpringBootTest
@Transactional
@ExtendWith(MockitoExtension.class)
class PurchaseServiceTest {
@Autowired
ItemRepository itemRepository;
@Autowired
AsyncOrderService stockService;
@Mock
IamPortTemplate iamPortTemplate;
PurchaseService paymentAppService;
final String TEST_IMP_UID = "imp_448280090638";
@BeforeEach
public void setup() {
paymentAppService = new PurchaseService(itemRepository, iamPortTemplate, stockService);
}
@Test
void PaymentAppService_결제_검증_성공() {
Item item = itemRepository.save(Item.builder()
.name("결제테스트")
.price(1004)
.stock(Stock.builder().remain(100).build())
.build());
lenient().when(iamPortTemplate.purchase(any(), any()))
.thenReturn(new GetOrderDto(1L, 1L, "1", item.getId(), item.getName()));
PaymentRequest request = new PaymentRequest(1004, TEST_IMP_UID, "결제테스트", 1L, 5);
assertThat(paymentAppService.purchase(request)).isNotNull();
}
@Test
void PaymentAppService_결제_검증_실패__상품이_존재하지_않음() {
PaymentRequest request = new PaymentRequest(1004, TEST_IMP_UID, "결제테스트", 1L, 5);
lenient().when(iamPortTemplate.purchase(any(), any()))
.thenReturn(new GetOrderDto(1L, 1L, "1", 0L, "결제테스트"));
assertThatThrownBy(() -> paymentAppService.purchase(request)).isInstanceOf(ItemStatusException.class);
}
@Test
void PaymentAppService_결제_검증_실패__상품_이름이_일치하지_않음() {
itemRepository.save(Item.builder()
.name("test")
.price(1004)
.stock(Stock.builder().remain(100).build())
.build());
lenient().when(iamPortTemplate.purchase(any(), any()))
.thenReturn(new GetOrderDto(1L, 1L, "1", 0L, "fakeTest"));
PaymentRequest request = new PaymentRequest(1004, TEST_IMP_UID, "fakeTest", 1L, 5);
assertThatThrownBy(() -> paymentAppService.purchase(request)).isInstanceOf(ItemStatusException.class);
}
@Test
void PaymentAppService_결제_검증_실패__상품_가격이_일치하지_않음() {
Item item = itemRepository.save(Item.builder()
.name("test")
.price(1004)
.stock(Stock.builder().remain(100).build())
.build());
lenient().when(iamPortTemplate.purchase(any(), any()))
.thenReturn(new GetOrderDto(1L, 1L, "1", item.getId(), item.getName()));
PaymentRequest request = new PaymentRequest(1003, TEST_IMP_UID, "결제테스트", 1L, 5);
assertThat(request.getAmount()).isNotEqualTo(1004);
}
}
IamPortTemplate 이라는 외부 서비스를 사용하는 클래스를 Mock 객체로 주입한 후, 스터빙을 통해 외부 서비스에 의존적이지 않게 만들었다.
그런데 코드가 영 별로다.
내가 Mock을 많이 다뤄보지 않은 것도 있겠지만, 각각의 테스트마다 스터빙하는 코드가 담겨있는 것도,
IamPortTemplate 객체를 주입받는 PaymentAppService 객체를 직접 생성해줘야하는 것도..
테스트를 돌릴 때는 외부 서비스가 아닌, 테스트용 빈을 컨테이너에 올릴 수 있으면 이런 문제가 다 해결될텐데 그런 방법은 없을까?
@Profile 어노테이션을 사용해 스프링 부트 애플리케이션의 런타임 환경을 관리할 수 있다고 한다.
즉, 테스트 코드에서 사용되는 빈을 분리할 수 있다는 것이다.
@Component
@Profile("local")
@RequiredArgsConstructor
@Transactional
@LogTracer
public class IamPortTemplate implements PaymentTemplate {
private final PaymentClient paymentClient;
@Override
public <T> T purchase(ValidatePayment validatePayment, IamPortCallBack<T> T) {
Payment payment = (Payment) paymentClient.validate(validatePayment.impUid());
itemNameValidate(validatePayment.getItemName().equals(payment.getName()));
itemPriceValidate(validatePayment.amount() == payment.getAmount().intValue());
return T.call();
}
}
@Component
@Profile("test")
@Transactional
@RequiredArgsConstructor
public class TestPaymentTemplate implements PaymentTemplate {
private final ItemRepository itemRepository;
@Override
public <T> T purchase(ValidatePayment validatePayment, IamPortCallBack<T> T) {
Item item = itemRepository.findByName(validatePayment.getItemName()).orElseThrow();
itemPriceValidate(validatePayment.amount() == item.getPrice());
return T.call();
}
}
이렇게 분리해서 bean을 등록해, 외부 서비스는 테스트 해주지 않는 것으로 코드를 마무리 지었다.
@SpringBootTest
@Transactional
class PurchaseServiceTest {
@Autowired
ItemRepository itemRepository;
@Autowired
AsyncOrderService stockService;
@Autowired
PurchaseService paymentAppService;
@Test
void PaymentAppService_결제_검증_성공() {
itemRepository.save(Item.builder()
.name("결제테스트")
.price(1004)
.stock(Stock.builder().remain(100).build())
.build());
PaymentRequest request = new PaymentRequest(1004, null, "결제테스트", 1L, 5);
assertThat(paymentAppService.purchase(request)).isNotNull();
}
@Test
void PaymentAppService_결제_검증_실패__상품이_존재하지_않음() {
PaymentRequest request = new PaymentRequest(1004, null, "결제테스트", 1L, 5);
assertThatThrownBy(() -> paymentAppService.purchase(request)).isInstanceOf(ItemStatusException.class);
}
@Test
void PaymentAppService_결제_검증_실패__상품_이름이_일치하지_않음() {
itemRepository.save(Item.builder()
.name("test")
.price(1004)
.stock(Stock.builder().remain(100).build())
.build());
PaymentRequest request = new PaymentRequest(1004, null, "fakeTest", 1L, 5);
assertThatThrownBy(() -> paymentAppService.purchase(request)).isInstanceOf(ItemStatusException.class);
}
@Test
void PaymentAppService_결제_검증_실패__상품_가격이_일치하지_않음() {
itemRepository.save(Item.builder()
.name("test")
.price(1004)
.stock(Stock.builder().remain(100).build())
.build());
PaymentRequest request = new PaymentRequest(1003, null, "결제테스트", 1L, 5);
assertThat(request.getAmount()).isNotEqualTo(1004);
}
}
바뀐 테스트는 다음과 같이 비즈니스 로직만을 테스트 할 수 있다.
'ToyProject' 카테고리의 다른 글
[ToyProject] 상품 구매 서비스 개선 - 2 - (0) | 2023.07.01 |
---|---|
[ToyProject] 상품 구매 서비스 개선 - 1 - (0) | 2023.07.01 |
[ToyProject] 동시성 문제 해결에 대한 고민 (0) | 2023.06.30 |
[ToyProject] 상품 구매 서비스 리팩토링 (0) | 2023.06.25 |
[ToyProject] 상품 구매 시 발생하는 재고 동시성 문제 (0) | 2023.06.20 |