Notice
Recent Posts
Recent Comments
Link
«   2025/05   »
1 2 3
4 5 6 7 8 9 10
11 12 13 14 15 16 17
18 19 20 21 22 23 24
25 26 27 28 29 30 31
Tags
more
Archives
Today
Total
관리 메뉴

Kuma's Curious Paradise

테스트 코드 8 - 토스 결제 API 테스트 코드 작성하기 본문

스프링

테스트 코드 8 - 토스 결제 API 테스트 코드 작성하기

쿠마냥 2024. 9. 16. 19:29

프로젝트에서 토스 결제 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와 함께 동작하는 비즈니스 로직을 철저히 검증하는 과정이 필요하다고 생각한다. 그 과정에 이 글이 완벽하지는 않겠지만, 조금이라도 도움이 되었으면 좋겠다.