문제 상황
우테코 Lv2 3번째 미션에서는 토스 API 연동을 통해 결제 기능을 구현해야 했다. 이는 우리가 요청에 대한 응답만을 주었던 서버로의 역할 뿐만 아니라, 결제 관련 정보를 토스 측에 전해주고, 토스 측으로 받은 응답을 통해 서비스를 이어가는 클라이언트로의 위치를 겸하게 되었음을 의미한다. 즉, 이제 외부 서버로부터 정보를 받아야 한다.
토스 API 문서가 워낙 친절해서 이것저것 찾아보며 구현은 완성했다.
그러나, 페어였던 제제와 함께 찾아나가지 못한 질문이 있었는데
외부 API 호출을 사용하는 서비스를 어떻게 테스트해야하는가? 에 대한 고민이다.
고민을 구체화 해보면 다음과 같다.
문제1. 외부 API 응답에 대한 제어권이 개발자에게 있지 않다.
개발자는 토스 API에 맞춘 요청을 전하고, 예측하고 있는 응답이 오기를 기대할 뿐이다.
외부 API 응답 로직을 직접적으로 컨트롤 할 수 없었다.
외부 서버 응답을 스스로 제어할 수 없어 성공/실패 시나리오별로 외부 API 응답을 제어할 수 있음을 의미한다.
=> 수요1. 외부 API 응답에 대한 제어권을 기반으로 테스트하고 싶다.
문제2. 외부 API의 의존성을 제거하고 싶다.
테스트 코드는 목적이 중요하다. 그럼 나는 어떤 목적으로 테스트 코드를 작성하고 싶었던 걸까?
" 나는 결제 로직을 테스트하고 싶었다."
아직 추상적이다. 조금 더 구체화해보자.
=> "나는 정상 결제 요청 시, 예측하고 있는 정상 결제 응답이 오는지 테스트하고 싶다"
=> "나는 API에서 오류 반환 시, 커스텀 에러로 잘 전환이 되는지 테스트하고 싶다"
그러나 실제 API 호출을 사용하면 이런 테스트 목적의 정합성이 떨어질 수 있다.
예를 들어 외부 서버의 오류로 발생한 테스트 실패가 내 코드의 문제인지 외부 서버의 문제인지 알 수 없다.
또한, api 통신에 요금이 발생한다면 과금이 될 수도 있고,
통신이 불가한 상황이라면 아예 테스트가 무용지물이 된다.
=> 수요2 : 테스트 코드에서 외부 API가 개입되지 않았으면 좋겠다
겪었던 문제와 수요를 정리해보자
문제1. API 응답을 제어하지 못한다 => 원하는 테스트 시나리오 작성이 불분명하다
문제2. 외부 API 의존성 개입의 우려 => 테스트 실패의 원인을 특정할 수 없다.
수요1. API 응답을 직접 stubbing 하여 지정하고 싶다
수요2. 테스트 코드에서 외부 API 의존성을 떼어내고 싶다
해결방안
찾아보니 스프링 6.1에 RestClient를 소개하면서 MockRestServiceServer 와 관한 언급이 있었다.
MockRestServiceServer란 쉽게 말해 우리가 호출하는 외부 서버를 Mocking하는 것으로 요청에 따른 응답을 직접 stubbing하여 지정할 수 있다. 이는 api 호출의 응답을 우리가 제어할 수 있음을 의미하며, api의 영향력을 테스트 코드에서 제거하고 코드의 본질적인 부분(서비스/비즈니스 로직)을 테스트할 수 있음을 의미한다.
공식문서에 나온 간단한 예시를 보면 그 개념이 쉽게 이해된다.
RestTemplate restTemplate = new RestTemplate();
MockRestServiceServer mockServer = MockRestServiceServer.bindTo(restTemplate).build();
mockServer.expect(requestTo("/greeting")).andRespond(withSuccess());
// Test code that uses the above RestTemplate ...
mockServer.verify();
위의 예시는 /greeting으로 요청이 왔을 때 200 OK 응답을 내려주도록 api 호출에 대한 응답을 지정하고 있다.
그럼 테스트 로직 작성에 들어가보자
PaymentService 세팅 설명
- EnableConfigurationProperties : PaymentProperties 안에 있는 api 로직 / secretKey등을 가져옴
- SpringBootTest : 가능한 랜덤 포트에 접속해 테스트 실행
- DirtiesContext : 테스트 간섭 방지를 위해 method 실행 전마다 context를 새로 띄움
- MockBean : 결제 정보가 실제 DB에 저장되는 것을 방지
- BeforeEach : mockServer 생성
- AfterEach : server 닫기
@EnableConfigurationProperties({PaymentProperties.class})
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@DirtiesContext(methodMode = DirtiesContext.MethodMode.BEFORE_METHOD)
class PaymentServiceTest {
@Autowired
private PaymentProperties paymentProperties;
@Autowired
private RestTemplate restTemplate;
@Autowired
private PaymentService paymentService;
@MockBean
private PaymentRepository paymentRepository;
private MockRestServiceServer mockServer;
@BeforeEach
void setUp() {
mockServer = MockRestServiceServer.createServer(restTemplate);
}
@AfterEach
void clear() {
mockServer.reset();
}
}
결제 성공 테스트 : 목적 - 정상 요청에 대해 정상응답 검증
@Test
@DisplayName("성공 : 결제를 요청한다")
void sucessPayment() {
PaymentApproveRequest request = new PaymentApproveRequest("testKey", "testId", "1000");
PaymentApproveResponse expectedResponse = new PaymentApproveResponse(request.paymentKey(), request.orderId());
Reservation dummy = new Reservation(null, null);
mockServer.expect(requestTo(paymentProperties.getApproveUrl()))
.andExpect(method(HttpMethod.POST))
.andRespond(withSuccess(makeJsonFrom(expectedResponse), MediaType.APPLICATION_JSON));
PaymentApproveResponse actualResponse = paymentService.pay(request, dummy);
assertAll(
() -> mockServer.verify(),
() -> assertThat(actualResponse.paymentKey()).isEqualTo(expectedResponse.paymentKey()),
() -> assertThat(actualResponse.orderId()).isEqualTo(expectedResponse.orderId())
);
}
// Json 형태의 문자열 반환
private String makeJsonFrom(PaymentApproveResponse response) {
try {
return new JSONObject()
.put("paymentKey", response.paymentKey())
.put("orderId", response.orderId())
.toString();
} catch (JSONException e) {
throw new RuntimeException(e);
}
}
여기서 외부 서버를 모킹한 부분을 자세히 보자
mockServer.expect(requestTo(paymentProperties.getApproveUrl()))
.andExpect(method(HttpMethod.POST))
.andRespond(withSuccess(makeJsonFrom(expectedResponse), MediaType.APPLICATION_JSON));
결제 승인 요청 url로 / Post 요청시 / 지정한 stubbing 객체와 함께 200 OK를 반환하도록 했다.
예상한 대로 요청 값에 따른 정상 응답 값이 반환되며 테스트가 통과했다.
결제 실패 테스트 : 목적 - 커스텀 에러로 잘 전환되는지 테스트
@Test
@DisplayName("실패 : 500에러 반환시 custom exeption으로 예외가 전환된다.")
void is5XXException_PaymentException() {
PaymentApproveRequest request = new PaymentApproveRequest("testKey", "testId", "1000");
TestErrorResponse response = new TestErrorResponse("INTERNAL_SERVER_ERROR", "test_error");
Reservation dummy = new Reservation(null, null);
mockServer.expect(ExpectedCount.manyTimes(), requestTo(paymentProperties.getApproveUrl()))
.andExpect(method(HttpMethod.POST))
.andRespond(withServerError().body(makeJsonFrom(response)));
assertAll(
() -> assertThatThrownBy(() -> paymentService.pay(request, dummy))
.isInstanceOf(ApiException.class),
() -> mockServer.verify()
);
}
이번에는 500에러 반환시 ApiException으로 잘 에러가 전환되는지 테스트해보자
외부 서버를 모킹한 부분을 보면
mockServer.expect(ExpectedCount.manyTimes(), requestTo(paymentProperties.getApproveUrl()))
.andExpect(method(HttpMethod.POST))
.andRespond(withServerError().body(makeJsonFrom(response)));
Post 결제 승인 요청이 여러번 발생하면 / 500에러를 stubbing한 응답 객체와 함께 반환하도록 하였다.
여기서 ExpectedCount를 mayTimes로 검증한 이유는
네트워크 순단 이슈에 대비해 @RetryWith을 활용해 에러 발생 시 재시도하기 때문이다.
(@RetryWith에 대해서 느낀점은 조만간 다시 정리해보는 걸로....)
테스트의 목적이 드러나는 then(검증 부분)을 보면
assertAll(
() -> assertThatThrownBy(() -> paymentService.pay(request, dummy))
.isInstanceOf(ApiException.class),
() -> mockServer.verify()
);
실제로 500에러가 잘 발생되었는지 mockServer를 verify함과
동시에 HttpClientErrorException이 커스텀 에러인 ApiException으로 잘 전환되는지 검증하고 있다
테스트도 잘 통과한다.
느낀 점과 아쉬운 점
느낀 점
다시 나의 문제 상황과 수요를 돌아보자
수요1. API 응답을 직접 stubbing 하여 지정하고 싶다
수요2. 테스트 코드에서 외부 API 의존성을 떼어내고 싶다
MockRestServiceServer는 직접 API 응답을 제어함으로써 원하는 테스트 시나리오에 맞게 테스트를 작성할 수 있게 해주었다. 또한, 테스트와 외부 API의 의존성을 제거함으로써 테스트의 실패 원자성(실패하는 원인이 단 한가지 인 것)을 보장시켜 주었다. 그런 점에서 초기 느꼈던 불편한 문제들은 MockRestServiceServer를 통해 전반적으로 해결이 가능했다
아쉬운 점
글을 통해 다시 테스트 작성 과정을 돌아보니 아쉬운 점이 눈에 많이 띄는 것 같다.
첫째로 테스트 목적이 불분명했다.
- 테스트1에서 검증한 정상응답 반환 과정은 Mock을 사용하는 이상 stubbing한 객체가 그대로 반환되기에 사실상 stubbing한 응답 객체가 잘 반환되었는지 검증하는 것에 지나지 않는다는 생각이 들었다.
- 그 이유를 곱씹어 보면 외부 API 호출 == 테스트 해야 하는 로직 으로 받아들였기 떄문이 아닐까 싶다. 외부 API 를 호출하는 로직이 곧 테스트 해야하는 대상인 것이 아니다. 테스트 해야 하는 대상을 먼저 탐색하고 그 중에 외부 API 호출이 있다면 MockServer를 대안으로 떠올려보자
둘째로 결제 로직이 아닌 것을 테스트했다.
- 테스트 2에서 검증한 에러 전환은 기본적으로 PaymentService의 로직이 아니다. 테스트 위치가 부적절했다. 또한, 서비스나 비즈니스 로직이 아니다. 그저 ResponseErrorHandler가 custom exception으로 잘 전환하는지 검증한 것이나 마찬가지였다.
즉, PaymentService가 수행하는 `결제 로직`에 온전히 집중하지 못한 부분이 있었다
전반적으로 테스트를 왜 작성해야하는지 스스로 명확하게 답하지 못한 채 둥실둥실 테스트 코드를 써내려갔던 것 같다. 테스트 해야 하는 것과 아닌 것을 구분하고 왜 테스트를 해야하는지 스스로 설명할 수 있어야 한다는 사실을 되새기며 글을 마무리한다.
reference)
https://docs.spring.io/spring-framework/reference/testing/spring-mvc-test-client.html
'우테코' 카테고리의 다른 글
프로젝트 API문서 작성을 위한 Swagger 도입기 (1) | 2024.09.02 |
---|---|
우테코 미션으로 찍먹한 QueryDsl : 동적쿼리 (0) | 2024.06.21 |
[우테코- Lv3] 아이디어 기획본1 (0) | 2024.06.19 |
[Rest Docs vs Swagger] 2편 : Swagger Spring docs적용기 (0) | 2024.06.16 |
[Rest Docs vs Swagger] 1편 : Rest Docs로 API 문서 자동화해보기 (0) | 2024.06.13 |