Kuma's Curious Paradise
테스트 코드 8 - 토스 결제 API 테스트 코드 작성하기 본문
프로젝트에서 토스 결제 API와 같은 외부 API를 활용하는 경우, 테스트 코드를 어떻게 작성해야 할까? 구글링해 본 결과, 프론트 코드를 만들어 호출 및 실행을 확인하는 경우가 많은 듯했다. 하지만 이것만으로는 충분하지 않다. 이들은 프로젝트 내 다른 비즈니스 로직과 얽혀 있는 경우가 많기 때문에 이러한 로직들까지도 테스트할 필요가 있었다. 따라서 오늘은 토스 결제 API를 중심으로 외부 API와 함께 비즈니스 로직이 포함된 테스트 코드를 작성해 본 경험을 적어보고자 한다.
이제부터 소개할 테스트 코드는 실제 외부 API를 호출하는 '통합 테스트'가 아니다. API의 응답을 모킹하여, '~~~하게 응답했다 치고' 작성하는 'MockMvcTest'임을 미리 밝힌다.
1. 테스트해야 할 코드
@RestController
@RequiredArgsConstructor
@RequestMapping("/members/payments")
public class PaymentController {
private final PaymentService paymentService;
private final OrderService orderService;
private final Base64.Encoder encoder = Base64.getEncoder();
@Value("${payment.toss.test_secret_api_key}")
private String widgetSecretKey;
@PostMapping("/confirm")
public ResponseEntity<PaymentResultDto> confirmPayment(@RequestParam String paymentKey, @RequestParam("orderId") String tossOrderId, @RequestParam Integer amount) throws Exception {
Order order = paymentService.getOrderByOrderSerial(tossOrderId);
Long orderId = order.getId();
orderService.getOrder(orderId);
Integer totalPrice = paymentService.getTotalPrice(orderId);
if (!Objects.equals(amount, totalPrice)) {
throw BadRequestException.wrongOrder("결제 금액");
}
paymentService.prepareOrder(order, tossOrderId, paymentKey);
JsonObject obj = new JsonObject();
obj.addProperty("orderId", tossOrderId);
obj.addProperty("amount", amount);
obj.addProperty("paymentKey", paymentKey);
byte[] encodedBytes = encoder.encode((widgetSecretKey + ":").getBytes("UTF-8"));
String authorizations = "Basic " + new String(encodedBytes);
URL url = new URL("https://api.tosspayments.com/v1/payments/confirm");
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
connection.setRequestProperty("Authorization", authorizations);
connection.setRequestProperty("Content-Type", "application/json");
connection.setRequestMethod("POST");
connection.setDoOutput(true);
OutputStream outputStream = connection.getOutputStream();
outputStream.write(obj.toString().getBytes("UTF-8"));
int code = connection.getResponseCode();
boolean isSuccess = code == 200;
InputStream responseStream = isSuccess ? connection.getInputStream() : connection.getErrorStream();
InputStreamReader reader = new InputStreamReader(responseStream, StandardCharsets.UTF_8);
JsonObject jsonObject = JsonParser.parseReader(reader).getAsJsonObject();
responseStream.close();
String status = jsonObject.get("status").getAsString();
// 결제 성공
if (isSuccess && status.equalsIgnoreCase("DONE")) {
try {
paymentService.completeOrder(order);
} catch (Exception e) {
// 서버에 문제가 생긴 경우
String cancelUrlString = "https://api.tosspayments.com/v1/payments/" + paymentKey + "/cancel";
URL cancelUrl = new URL(cancelUrlString);
HttpURLConnection cancelConnection = (HttpURLConnection) cancelUrl.openConnection();
cancelConnection.setRequestProperty("Authorization", authorizations);
cancelConnection.setRequestProperty("Content-Type", "application/json");
cancelConnection.setRequestMethod("POST");
cancelConnection.setDoOutput(true);
OutputStream cancelOutputStream = connection.getOutputStream();
cancelOutputStream.write(obj.toString().getBytes("UTF-8"));
PaymentResultDto result = PaymentResultDto.fail("결제 완료 처리 중 에러 발생하여 취소 API 호출을 완료했습니다.");
return ResponseEntity.ok(result);
}
PaymentResultDto result = PaymentResultDto.success("결제에 성공하였습니다!");
return ResponseEntity.ok(result);
}
// 결제 실패
paymentService.undoOrder(order);
PaymentResultDto result = PaymentResultDto.fail("결제 승인 처리가 정상적으로 완료되지 않았습니다. \nStatus: " + jsonObject.get("status").getAsString());
return ResponseEntity.status(code).body(result);
}
}
외부 API에 대한 테스트 코드를 만들 시 가장 중요한 부분은 외부 API의 응답을 mocking하는 것이다.
위 코드에서 토스의 응답은 크게 두 가지로 나뉜다.
1) 결제 성공 -> code : 200, status : DONE
2) 결제 실패 -> code & status (해당 에러의 코드와 상태가 주어질 것)
이 응답을 만들어 내기 위해서는 HttpUrlConnection 객체를 모킹해야 하는데, 이것이 참 골치아픈 일이었다. 좀 더 모킹하기 쉬운 RestTemplate으로 응답을 주고받으면 좋을 텐데...
따라서 PaymentController가 RestTemplate을 쓰도록 리팩토링 과정을 거쳤다. (!)
2. PaymentController 리팩토링 (RestTemplate 버전)
@RestController
@RequiredArgsConstructor
@RequestMapping("/members/payments")
public class PaymentController {
private final PaymentService paymentService;
private final RestTemplate restTemplate;
private final Base64.Encoder encoder = Base64.getEncoder();
@Value("${payment.toss.test_secret_api_key}")
private String widgetSecretKey;
@PostMapping("/confirm")
public ResponseEntity<PaymentResultDto> confirmPayment(@RequestParam String paymentKey,
@RequestParam("orderId") String tossOrderId,
@RequestParam Integer amount) throws Exception {
Order order = paymentService.getOrderByOrderSerial(tossOrderId);
Long orderId = order.getId();
Integer totalPrice = paymentService.getTotalPrice(orderId);
if (!Objects.equals(amount, totalPrice)) {
throw BadRequestException.wrongOrder("결제 금액");
}
paymentService.prepareOrder(order, paymentKey);
JsonObject obj = new JsonObject();
obj.addProperty("orderId", tossOrderId);
obj.addProperty("amount", amount);
obj.addProperty("paymentKey", paymentKey);
byte[] encodedBytes = encoder.encode((widgetSecretKey + ":").getBytes("UTF-8"));
String authorizations = "Basic " + new String(encodedBytes);
HttpHeaders headers = new HttpHeaders();
headers.set("Authorization", authorizations);
headers.setContentType(MediaType.APPLICATION_JSON);
HttpEntity<String> requestEntity = new HttpEntity<>(obj.toString(), headers);
ResponseEntity<String> responseEntity = restTemplate.postForEntity(
"https://api.tosspayments.com/v1/payments/confirm",
requestEntity,
String.class
);
JsonObject jsonObject = JsonParser.parseString(responseEntity.getBody()).getAsJsonObject();
String status = jsonObject.get("status").getAsString();
if (responseEntity.getStatusCode().is2xxSuccessful() && status.equalsIgnoreCase("DONE")) {
try {
paymentService.completeOrder(order);
} catch (Exception e) {
// 결제 완료 처리 중 오류 발생 시 결제 취소 호출
String cancelUrl = "https://api.tosspayments.com/v1/payments/" + paymentKey + "/cancel";
restTemplate.postForEntity(cancelUrl, requestEntity, String.class);
PaymentResultDto result = PaymentResultDto.withdraw("결제 완료 처리 중 에러 발생하여 취소 API 호출을 완료했습니다.");
return ResponseEntity.ok(result);
}
PaymentResultDto result = PaymentResultDto.success("결제에 성공하였습니다!");
return ResponseEntity.ok(result);
}
// 결제 실패
paymentService.undoOrder(order);
PaymentResultDto result = PaymentResultDto.fail("결제 승인 처리가 정상적으로 완료되지 않았습니다. \nStatus: " + jsonObject.get("status").getAsString());
return ResponseEntity.status(responseEntity.getStatusCode()).body(result);
}
}
이렇게 리팩토링을 마친 후, 프론트 코드를 다시 붙여서 API를 호출해 보았다. 결제가 잘 되는 걸 확인했다.
이제 테스트 코드를 만들어 보자.
3. PaymentController 테스트 코드
@WebMvcTest(PaymentController.class)
@Import(TestSecurityConfig.class)
public class PaymentControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private PaymentService paymentService;
@MockBean
private RestTemplate restTemplate;
@Value("${payment.toss.test_secret_api_key}")
String tossTestSecretApiKey;
private Member member = MemberFixture.createMember("abc@example.com");
private String paymentKey = "paymentKey";
private String tossOrderId = "20240915-2345678";
private Integer amount = 30000;
@Nested
class confirmPayment {
@Test
void 결제성공_toss_api_호출() throws Exception {
// given
Order mockOrder = mock(Order.class);
when(paymentService.getOrderByOrderSerial(tossOrderId)).thenReturn(mockOrder);
when(mockOrder.getId()).thenReturn(1L);
when(paymentService.getTotalPrice(1L)).thenReturn(amount);
doNothing().when(paymentService).prepareOrder(eq(mockOrder), eq(paymentKey));
JsonObject successResponse = new JsonObject();
successResponse.addProperty("status", "DONE");
ResponseEntity<String> restTemplateResponse = ResponseEntity.ok(successResponse.toString());
when(restTemplate.postForEntity(
eq("https://api.tosspayments.com/v1/payments/confirm"),
any(HttpEntity.class),
eq(String.class)
)).thenReturn(restTemplateResponse);
doNothing().when(paymentService).completeOrder(mockOrder);
// when
ResultActions result = mockMvc.perform(post("/members/payments/confirm")
.param("paymentKey", paymentKey)
.param("orderId", tossOrderId)
.param("amount", String.valueOf(amount))
.contentType(MediaType.APPLICATION_JSON)
.characterEncoding(StandardCharsets.UTF_8)
.with(SecurityMockMvcRequestPostProcessors.user(spy(new MemberDetails(member))))
);
// then
result.andExpect(status().isOk())
.andExpect(jsonPath("$.isPaymentSuccess").value(true))
.andExpect(jsonPath("$.message").value("결제에 성공하였습니다!"));
verify(paymentService).getOrderByOrderSerial(tossOrderId);
verify(paymentService).getTotalPrice(1L);
verify(paymentService).prepareOrder(mockOrder, paymentKey);
verify(paymentService).completeOrder(mockOrder);
}
@Test
void 결제실패_토스API호출실패() throws Exception {
// given
Order mockOrder = mock(Order.class);
when(paymentService.getOrderByOrderSerial(tossOrderId)).thenReturn(mockOrder);
when(mockOrder.getId()).thenReturn(1L);
when(paymentService.getTotalPrice(1L)).thenReturn(amount);
doNothing().when(paymentService).prepareOrder(eq(mockOrder), eq(paymentKey));
JsonObject failureResponse = new JsonObject();
failureResponse.addProperty("status", "FAILED");
ResponseEntity<String> restTemplateResponse = ResponseEntity.badRequest().body(failureResponse.toString());
when(restTemplate.postForEntity(
eq("https://api.tosspayments.com/v1/payments/confirm"),
any(HttpEntity.class),
eq(String.class)
)).thenReturn(restTemplateResponse);
// when
ResultActions result = mockMvc.perform(post("/members/payments/confirm")
.param("paymentKey", paymentKey)
.param("orderId", tossOrderId)
.param("amount", String.valueOf(amount))
.contentType(MediaType.APPLICATION_JSON)
.characterEncoding(StandardCharsets.UTF_8)
.with(SecurityMockMvcRequestPostProcessors.user(new MemberDetails(member)))
);
// then
result.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.isPaymentSuccess").value(false))
.andExpect(jsonPath("$.message").value(containsString("결제 승인 처리가 정상적으로 완료되지 않았습니다.")));
}
}
}
결제 API 호출 성공, 실패 테스트 코드는 이렇다.
그중 성공 테스트의 RestTemplate 모킹 부분을 살펴보자.
JsonObject successResponse = new JsonObject();
successResponse.addProperty("status", "DONE");
ResponseEntity<String> restTemplateResponse = ResponseEntity.ok(successResponse.toString());
when(restTemplate.postForEntity(
eq("https://api.tosspayments.com/v1/payments/confirm"),
any(HttpEntity.class),
eq(String.class)
)).thenReturn(restTemplateResponse);
1) status가 "DONE"인 json 객체를 생성한다.
2) 응답으로 반환할 ResponseEntity를 준비한다. 이때 1번에서 만든 successResponse를 responseEntity의 body에 넣는다.
3) when-thenReturn 구문을 사용해 postForEntity()로 가짜 post요청을 보낸 후 해당 ResponseEntity를 반환하도록 만든다.
postForEntity()는 이렇게 생겼다.
- url: 요청을 보낼 URL
- request: 요청 본문(body), 주로 HttpEntity로 전달한다. 요청에 필요한 내용을 담고 있다.
- responseType: 응답의 타입
- uriVariables: URL 경로에 필요한 변수들
when(restTemplate.postForEntity(
eq("https://api.tosspayments.com/v1/payments/confirm"), // URL 일치자
any(HttpEntity.class), // HttpEntity 일치자
eq(String.class) // 응답 타입 일치자
)).thenReturn(restTemplateResponse); // 모킹된 응답 반환
따라서 이 경우 필요가 없는 urlVariables를 제외한 나머지 파라미터들을 담아 주었다.
이렇게 실제로 토스 API를 호출하지 않고, 미리 정의한 응답을 반환하여 결제 성공 흐름을 테스트할 수 있다. 결제 실패 흐름도 마찬가지로 구성하면 된다.
비즈니스 로직을 포함한 전체 테스트 코드는 이렇다.
@WebMvcTest(PaymentController.class)
@Import(TestSecurityConfig.class)
public class PaymentControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private PaymentService paymentService;
@MockBean
private RestTemplate restTemplate;
@Value("${payment.toss.test_secret_api_key}")
String tossTestSecretApiKey;
private Member member = MemberFixture.createMember("abc@example.com");
private String paymentKey = "paymentKey";
private String tossOrderId = "20240915-2345678";
private Integer amount = 30000;
@Nested
class confirmPayment {
@Test
void 결제성공_toss_api_호출() throws Exception {
// given
Order mockOrder = mock(Order.class);
when(paymentService.getOrderByOrderSerial(tossOrderId)).thenReturn(mockOrder);
when(mockOrder.getId()).thenReturn(1L);
when(paymentService.getTotalPrice(1L)).thenReturn(amount);
doNothing().when(paymentService).prepareOrder(mockOrder, paymentKey);
JsonObject successResponse = new JsonObject();
successResponse.addProperty("status", "DONE");
ResponseEntity<String> restTemplateResponse = ResponseEntity.ok(successResponse.toString());
when(restTemplate.postForEntity(
eq("https://api.tosspayments.com/v1/payments/confirm"),
any(HttpEntity.class),
eq(String.class)
)).thenReturn(restTemplateResponse);
doNothing().when(paymentService).completeOrder(mockOrder);
// when
ResultActions result = mockMvc.perform(post("/members/payments/confirm")
.param("paymentKey", paymentKey)
.param("orderId", tossOrderId)
.param("amount", String.valueOf(amount))
.contentType(MediaType.APPLICATION_JSON)
.characterEncoding(StandardCharsets.UTF_8)
.with(SecurityMockMvcRequestPostProcessors.user(spy(new MemberDetails(member))))
);
// then
result.andExpect(status().isOk())
.andExpect(jsonPath("$.isPaymentSuccess").value(true))
.andExpect(jsonPath("$.message").value("결제에 성공하였습니다!"));
verify(paymentService).getOrderByOrderSerial(tossOrderId);
verify(paymentService).getTotalPrice(1L);
verify(paymentService).prepareOrder(mockOrder, paymentKey);
verify(paymentService).completeOrder(mockOrder);
}
@Test
void 결제실패_금액_불일치() throws Exception {
// given
Integer wrongAmount = 40000;
Order mockOrder = mock(Order.class);
when(paymentService.getOrderByOrderSerial(tossOrderId)).thenReturn(mockOrder);
when(mockOrder.getId()).thenReturn(1L);
when(paymentService.getTotalPrice(1L)).thenReturn(wrongAmount);
// when
ResultActions result = mockMvc.perform(post("/members/payments/confirm")
.param("paymentKey", paymentKey)
.param("orderId", tossOrderId)
.param("amount", String.valueOf(amount))
.contentType(MediaType.APPLICATION_JSON)
.characterEncoding(StandardCharsets.UTF_8)
.with(SecurityMockMvcRequestPostProcessors.user(new MemberDetails(member)))
);
// then
result.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.message").value("주문 정보가 상이합니다. 결제 금액(을)를 확인하세요."));
}
@Test
void 결제실패_주문_조회_실패() throws Exception {
// given
when(paymentService.getOrderByOrderSerial(tossOrderId)).thenThrow(NotFoundException.entityNotFound());
// when
ResultActions result = mockMvc.perform(post("/members/payments/confirm")
.param("paymentKey", paymentKey)
.param("orderId", tossOrderId)
.param("amount", String.valueOf(amount))
.contentType(MediaType.APPLICATION_JSON)
.characterEncoding(StandardCharsets.UTF_8)
.with(SecurityMockMvcRequestPostProcessors.user(new MemberDetails(member)))
);
// then
result.andExpect(status().isNotFound())
.andExpect(jsonPath("$.message").value("정보를 찾을 수 없습니다."));
}
@Test
void 결제실패_토스API호출실패() throws Exception {
// given
Order mockOrder = mock(Order.class);
when(paymentService.getOrderByOrderSerial(tossOrderId)).thenReturn(mockOrder);
when(mockOrder.getId()).thenReturn(1L);
when(paymentService.getTotalPrice(1L)).thenReturn(amount);
doNothing().when(paymentService).prepareOrder(eq(mockOrder), eq(paymentKey));
JsonObject failureResponse = new JsonObject();
failureResponse.addProperty("status", "FAILED");
ResponseEntity<String> restTemplateResponse = ResponseEntity.badRequest().body(failureResponse.toString());
when(restTemplate.postForEntity(
eq("https://api.tosspayments.com/v1/payments/confirm"),
any(HttpEntity.class),
eq(String.class)
)).thenReturn(restTemplateResponse);
// when
ResultActions result = mockMvc.perform(post("/members/payments/confirm")
.param("paymentKey", paymentKey)
.param("orderId", tossOrderId)
.param("amount", String.valueOf(amount))
.contentType(MediaType.APPLICATION_JSON)
.characterEncoding(StandardCharsets.UTF_8)
.with(SecurityMockMvcRequestPostProcessors.user(new MemberDetails(member)))
);
// then
result.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.isPaymentSuccess").value(false))
.andExpect(jsonPath("$.message").value(containsString("결제 승인 처리가 정상적으로 완료되지 않았습니다.")));
}
@Test
void 결제실패_주문완료처리중오류() throws Exception {
// given
Order mockOrder = mock(Order.class);
when(paymentService.getOrderByOrderSerial(tossOrderId)).thenReturn(mockOrder);
when(mockOrder.getId()).thenReturn(1L);
when(paymentService.getTotalPrice(1L)).thenReturn(amount);
doNothing().when(paymentService).prepareOrder(eq(mockOrder), eq(paymentKey));
JsonObject successResponse = new JsonObject();
successResponse.addProperty("status", "DONE");
ResponseEntity<String> restTemplateResponse = ResponseEntity.ok(successResponse.toString());
when(restTemplate.postForEntity(
eq("https://api.tosspayments.com/v1/payments/confirm"),
any(HttpEntity.class),
eq(String.class)
)).thenReturn(restTemplateResponse);
doThrow(new RuntimeException("주문 완료 처리 중 오류 발생")).when(paymentService).completeOrder(mockOrder);
when(restTemplate.postForEntity(
eq("https://api.tosspayments.com/v1/payments/" + paymentKey + "/cancel"),
any(HttpEntity.class),
eq(String.class)
)).thenReturn(ResponseEntity.ok(""));
// when
ResultActions result = mockMvc.perform(post("/members/payments/confirm")
.param("paymentKey", paymentKey)
.param("orderId", tossOrderId)
.param("amount", String.valueOf(amount))
.contentType(MediaType.APPLICATION_JSON)
.characterEncoding(StandardCharsets.UTF_8)
.with(SecurityMockMvcRequestPostProcessors.user(new MemberDetails(member)))
);
// then
result.andExpect(status().isOk())
.andExpect(jsonPath("$.isPaymentSuccess").value(false))
.andExpect(jsonPath("$.message").value("결제 완료 처리 중 에러 발생하여 취소 API 호출을 완료했습니다."));
}
}
}
RestTemplate으로 리팩토링하는 것이 꼭 필요했던 과정인지... 아직도 확신이 서질 않는다. 하지만 조금이라도 익숙한 RestTemplate으로 넘어오면서 원래 코드도 눈에 좀 더 잘 들어왔고, 코드의 길이도 확연히 줄어들었다. 테스트 코드 모킹도 쉬워졌다. 코드를 작성한 개발자가 알아보기 쉬워졌다는 건 유지보수성이 높아졌다는 뜻과 동의어가 아닐까? 라고 생각해 본다.
마지막으로, 외부 API는 단순히 API호출의 성공 여부를 넘어, 해당 API와 함께 동작하는 비즈니스 로직을 철저히 검증하는 과정이 필요하다고 생각한다. 그 과정에 이 글이 완벽하지는 않겠지만, 조금이라도 도움이 되었으면 좋겠다.
'스프링' 카테고리의 다른 글
성능 개선기 : fetch join과 pagination 을 함께 사용할 때의 문제 해결 + 캐시로 인한 성능 개선 (0) | 2024.10.10 |
---|---|
AOP & 캐시 적용하기 (0) | 2024.09.30 |
[유레카] 떠오르는대로(!) 정리하는 유레카 수업 2 : MyBatis 사용하기 (0) | 2024.09.10 |
[유레카] 떠오르는대로(!) 정리하는 유레카 수업 1 : DB 커넥션 풀은 무엇일까? (5) | 2024.09.06 |
토스 결제 코드 : 프론트가 없으면 만들어서 확인하기 (1) | 2024.09.05 |