결제 Testing - 1

예제 소스 코드

이번 블로그에 사용되는 코드는 아래 링크 통해 확인 할 수 있습니다.

https://github.com/syh8088/payment_testing

결제 TEST 구현하기

안녕하세요. 이번에 결제 프로세스를 통해 Spring Boot 에서 테스트 구현에 있어 한번 준비 해보았습니다. 일단 TEST 는 왜 필요 하는지 알아야 될
것 같은데요.

예를들어 회사에 취업을 하고 결제 프로젝트를 맡게 되면서 서비스를 구현 했다고 가정 하겠습니다. 스타트업인 만큼 빠르게 성과를 보여줘야 하기 때문에
TEST 코드는 준비 하지 않고 런칭 하게 되었습니다. 빠르게 성과를 보여줬지만 문제는 지금부터 입니다.

서비스란 서비스 출시 이후 작성했던 코드들은 부분적으로 수정도 해야하고 추가도 해야 할것 입니다. TEST 코드를 작성하지 않는 상태에서 기존에 작성했던
비지니스 로직들은 영향이 미칠것 입니다. 예를들어 특정 기능을 구현하기 위해 A 객체와 B 객체 서로 간의 역활을 위임하고 책임을 분배하고 있는 상황에서
A 객체 기능 수정 및 추가로 인해 B 객체에서도 영향이 있을 것 입니다.

즉 A 객체를 기능 수정이 일어나고 이후 B 객체에 영향으로 인해 버그가 발생 할 수 있는 소지가 있습니다. 개발자는 이런 상황을 감지 하지 못 하고 상용 배포를 할 경우
서비스에 대한 큰 문제가 발생 할 수 있습니다. 즉 소프트웨어의 안정성을 보장 할 수 없게 됩니다.

앞써 이야기 한대로 앞으로 기능 추가 할때마다 발생 할 수 있는 모든 TEST Case 를 고려 해야 합니다. TEST 코드를 작성하지 않을시 매순간 이러한 모든 Case 를 고려를 해야 할것 입니다.

TEST 코드가 있음으로 장점

앞써 TEST 코드가 없다면 발생 할 수 있는 문제에 대해 알아보았는데요. 그럼 번거롭지만 TEST 코드를 작성시 발생 할 수 있는 장점을 무엇 일까요?
우선 앞으로 점차 서비스가 커지고 기능을 추가되면서 여러 기능을 추가 할때마다 발생 할 수 있는 여러 Case 를 빠르게 인지 하고 TEST 할 수 있습니다.

그리고 TEST 코드를 통해 TEST 를 하게 된다면 수동적으로 TEST 를 한것 보다 속도면에서도 빠른 강점을 볼 수 있습니다.

초기 TEST 코드 작성하는데 있어서 비용적인 문제가 없다고 할 수 없지만 길게 본다면 분명 큰 장점 이라고 생각 됩니다.

TEST 대상이 되는 결제 시스템

이번에 TEST 대상이 되는 서비스 시스템은 결제 시스템 입니다. 여기서 제가 TEST 대상이 되는 여러 시스템이 있지만 결제 시스템으로 선택한 이유는
사용자가 결제를 시도 하면서 실제로 비용이 발생되고 이에 따르 서비스 제공자는 단 1원도 오차 없이 작동 해야 하기 때문입니다.

예를들어 사용자가 결제를 했는데도 특정 에러로 결제는 했지만 결제로 인한 상태 데이터가 업데이트가 되지 않으면서 이에 따른 소비자에게 손실이 발생 하면 신뢰도는 저하 될 것 입니다.

이러한 문제를 발생하지 않도록 견고하게 특정 에러가 발생되더라도 자동적으로 시스템이 이를 감지하고 문제 해결을 할 수만 있다면 좋겠지만 이런 문제를 사전에 방지 하기 위해서라도
여러 기능에 대한 TEST 코드를 만들면서 시스템의 안정성을 확보 해야 합니다.

결제 프로세스

간단하게 TEST 대상이 되는 결제 시스템에 대해 설명 하도록 하겠습니다.

  1. 우선 사용자가 결제 버튼을 클릭하면 ‘Checkout’ 이벤트가 발생되면서 사용자가 특정 상품에 대한 구매 이벤트를 발생 하도록 합니다.
  2. ‘Checkout’ 이벤트가 발생되면서 서비스단에서는 PaymentEvent 가 생성되고 더불어 해당 이벤트에 대한 unique id 값을 생성 하도록 합니다.

토스 결제 위젯

  1. 특정 결제 unique id 값 포함해서 상품 가격 데이터를 Toss 서버에 전달 하게 되고 이에 응답으로 Toss 에서 제공하는 결제 위젯 페이지를 띄우게 됩니다.
  2. 사용자가 Toss 에서 제공하는 위젯페이지 통해 실질적으로 결제가 진행 됩니다.
  3. 결제가 진행되고 Toss 는 사용자에게 결제 결과 응답을 해줍니다.
  4. 응답과 동시에 결제 서비스 서버로 Redirect 발생됩니다.
  5. 최종적으로 결제 승인을 처리 하기 위해 사용자가 구매한 결제를 최종적으로 성공 했다는 사실을 Toss 서버에 전달 합니다. 성공시 최종적으로 결제가 완료 됩니다. 동시에 결제 상태 업데이트를 하게 됩니다.

Junit 5 로 테스트 코드 작성 해보기

지금까지 TEST 대상이 되는 결제 시스템을 간단히 알아보았습니다. 이제부터 본격적으로 TEST 코드를 작성 해보겠습니다. 사용되는 테스트 프레임워크로는 JUnit5 과 Mockito 를 사용하겠습니다.
JUnit5 경우는 자바에서 사용되는 단위 테스트를 작성하는데 사용되는 프레임워크 입니다.

1
testImplementation 'org.springframework.boot:spring-boot-starter-test'

build.gradle 에서 해당 의존성을 추가 하면 자동으로 Junit 라이브러리를 내장 하게 됩니다.

TDD 란 무엇일까?

TEST 에 대해 알아보기전 TDD (Test Driven Development) 관련해서 알아보는것도 중요하다 생각하여 한번 추가 해보았습니다.
구현 순서로 생각해보자면 먼저 기능을 구현 한 뒤에 그 다음 TEST 를 구현한다고 생각하기 쉽다고 생각 할 수 있습니다. ‘TDD’ 경우는 기능을 구현하전
먼저 TEST 를 구현하고 기능을 구현 하는 개발 방법론 입니다.

TDD Red-Green-Refactor

TDD 의 대표적인 개발주기인 Red, Green, Refactor 입니다. 각각 단어를 설명 하자면

  1. ‘Red’ 단계 경우 실패하는 테스트 코드를 먼저 작성 합니다.
  2. ‘Green’ 단계 테스트 코드를 성공시키기 위한 기능이 되는 실제 코드를 작업 합니다.
  3. ‘Yellow’ 단계 경우 중복 코드 제거 그리고 일반화 등의 리팩토링을 수행 하는 작업 입니다.

이렇게 먼저 테스트 코드를 작성하고 이후 기능 구현 순서로 가야 하는 이유는 여러가지가 있을수 있겠습니다. 사람의 심리상 기능 구현을 한 다음 시간이 없다 생각하여
TEST 구현을 못 할수도 있습니다. 이러한 실수를 방지 하기위해 TEST 구현 부터 하는 이유 중 하나 이겠습니다.

그리고 또 하나는 기능 구현부터 한다면 미리 작성한 기능 구현 베이스로 TEST 를 작성을 하기 때문에 이미 어느정도 작동이 잘되는 소스 코드 기반으로 TEST 구현 하기 때문 입니다.
이러한 긍정적인 사고로 인해 해당 오퍼레이션에서 예외가 발생 할 수 있는 케이스를 누락 될 수 있는 실수가 발생 할 수 있습니다.

먼저 TEST 구현하고 나중에 기능 구현을 하게 된다면 TEST 구현하는 과정에서 이전 보다 놓치기 쉬운 더 많은 예외 케이스에 대한 사고가 더해지면서 결국 소프트웨어가 안정화 될수 있겠습니다.

Layered Architecture 레이어드 아키텍처

Layered Architecture 계층

TEST 구현하기 전 ‘Layered Architecture’ 에 대해 알아볼려고 합니다. 이렇게 각각의 레이어를 분리 하는 이유는 ‘관심사’ 의 분리로 시작 한다고 보면 될 것 같습니다.

여러 기능을 각각의 역활을 분리 하면서 관심사를 분리 하므로써 레이어를 분리 하는 것 입니다. 즉 관심사를 분리해 각각의 책임을 나누고 유지보수를 조금 더
용이 하기 위함이라고 보면 됩니다.

그럼 TEST 코드 작성시에도 이렇게 레이어로 분리 해보면서 관심사를 분리 해보는 것이 어떨까 입니다.

통합 테스트 (Integration Test)

통합 테스트 예시

객체지향 개발 방식에서 예를들어 각각의 A 객체 B 객체 서로 협력 하면서 동작 하게 됩니다. 각각 단일 테스트로는 A 객체 그리고 B 객체 어떤 결과가 발생
되는지 예측하기 쉬웠지만 이를 서로 협력 통해 통합 하면서 생각하면 어떻게 동작을 하는지 예측하기 어렵게 됩니다.

‘통합 테스트 (Integration Test)’ 통해 여러 모듈이 협력하는 기능을 통합적으로 검증 하는 테스트가 나온것 입니다.

서로 협력을 통해 일반적으로 단일 테스트로는 예측하기 어려운 문제를 통합 테스트를 통해 이를 해결하자는 이야기 입니다.

이번 테스트 경우는 앞써 Layered Architecture 를 설명한 대로 각각의 레이어 단위로 테스트를 하고 서로 협력 하는 레이어 부분은 통합 테스트를 통해 진행 해볼려고 합니다.

Persistence Layer 테스트

Persistence Layer Test

먼저 ‘Persistence Layer’ 에서 등록된 상품을 조회 하는 테스트 작성을 진행 해볼려고 합니다.

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
32
33
34
35
36
37
38
39
@ActiveProfiles("test")
@SpringBootTest
class ProductRepositoryTest {

@Autowired
private ProductRepository productRepository;

@Test
@DisplayName("등록된 상품들을 조회한다.")
void selectProduct() {
// given
Product product1 = this.createProduct("AAA", "상품A", BigDecimal.valueOf(1000));
Product product2 = this.createProduct("BBB", "상품B", BigDecimal.valueOf(2000));
Product product3 = this.createProduct("CCC", "상품C", BigDecimal.valueOf(3000));

productRepository.saveAll(List.of(product1, product2, product3));

// when
List<Product> products = productRepository.selectProductAll();

// then
assertThat(products).hasSize(3)
.extracting("productId", "name", "price")
.usingRecursiveFieldByFieldElementComparator(
RecursiveComparisonConfiguration.builder()
.withComparatorForType(BigDecimal::compareTo, BigDecimal.class).build()
)
.containsExactlyInAnyOrder(
tuple("AAA", "상품A", BigDecimal.valueOf(1000)),
tuple("BBB", "상품B", BigDecimal.valueOf(2000)),
tuple("CCC", "상품C", BigDecimal.valueOf(3000))
);
}

private Product createProduct(String productId, String name, BigDecimal price) {

return Product.of(productId, name, price);
}
}

TEST 코드를 보면 단순하게 등록된 상품을 조회 하는 역활인데 이것을 TEST 를 굳이 해야 하는가? 라는 의문이 생길수 있습니다. 하지만 지금은 예측이
쉽게 가능한 단순 상품 조회이지만 나중에 상품 조회 기능이 더욱 복잡해지고 기능이 추가 되면서 이에 보장 할 수 있는 미리 TEST 코드를 작성 하면서 신뢰성 있는
기능 추가가 가능해집니다.

Persistence Layer 테스트 주요 목표는 데이터 Access 가 정상적으로 역할을 잘 수행 하고 있는지 그리고 오로지 Data 에 대한 CRUD 관련 TEST 만 진행 하는 것이 좋습니다.
즉 그외 비지니스 가공 로직에 포함 되어서는 안됩니다.

@SpringBootTest VS @DataJpaTest

상품 조회 기능 테스트를 하기 위해서 JPA 기술 통해 조회를 하고 있는데요. 그러면 생각 할 수 있는 부분이 ‘@SpringBootTest’ 사용하지 않고
‘@DataJpaTest’ 를 사용하면서 불필요한 빈을 최소화 하면 어떨까 생각이 발생 할 수 있습니다.

@DataJpaTest 경우는 @SpringBootTest 다르게 JPA 관련된 빈들만 등록해주기 때문에 더 가볍다는 장점이 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Target(value=TYPE)
@Retention(value=RUNTIME)
@Documented
@Inherited @BootstrapWith(value=org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTestContextBootstrapper.class)
@ExtendWith(value=org.springframework.test.context.junit.jupiter.SpringExtension.class)
@OverrideAutoConfiguration(enabled=false)
@TypeExcludeFilters(value=DataJpaTypeExcludeFilter.class)
@Transactional
@AutoConfigureCache
@AutoConfigureDataJpa
@AutoConfigureTestDatabase
@AutoConfigureTestEntityManager
@ImportAutoConfiguration
public @interface DataJpaTest

개인적으로 @DataJpaTest 보다 @SpringBootTest 이 선호 하는데요. ‘@DataJpaTest’ 안에 들어다 보면 @Transaction 에노테이션을 확인 할수 있습니다.

1
2
3
4
5
6
7
@Target(value=TYPE)
@Retention(value=RUNTIME)
@Documented
@Inherited
@BootstrapWith(value=SpringBootTestContextBootstrapper.class)
@ExtendWith(value=org.springframework.test.context.junit.jupiter.SpringExtension.class)
public @interface SpringBootTest

하지만 반대로 @SpringBootTest 는 @Transaction 에노테이션이 없는것을 확인 할 수 있습니다.

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
@Test
void A상품_조회() {
// given
// A 상품 등록 - 1개 등록

// when
// 등록한 상품 조회

// then
// 등록 한 상품 count 비교
// 1개라고 예상
}

@Test
void B상품_조회() {
// given
// B 상품 등록 - 1개 등록

// when
// 등록한 상품 조회

// then
// 등록 한 상품 count 비교
// 1개라고 예상
}

한번 시나리오를 작성 해보겠습니다. @Transaction 이 있으면 ‘A상품_조회’ TEST 를 완료 하면 자동으로 롤백이 진행 되고 (상품 테이블 디비 등록 롤백) ‘B상품_조회’ TEST 를 진행 할것 입니다.
그러면 ‘B상품_조회’ TEST 를 진행 하면 상품 테이블에 한개 상품이 등록되면서 총 1개 상품이 등록되어져 있습니다. (‘B상품_조회’ TEST 가 끝나면 롤백 진행)

하지만 TEST 코드에서 @Transaction 추가시 고려해야 할 상황들이 있는데요. 앞써 이야기한 TDD 방식 그러니깐 TEST 코드를 먼저 작성 하고 기능을 구현 하는 과정에서
실수 할 수 있는 부분이 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

@DataJpaTest
class ProductRepositoryTest {

@Test
void A상품_조회() {
// given
// A 상품 등록 - 1개 등록
// 등록된 A 상품 재고 수량 5로 변경 (JPA 변경 감지 기능 통해 디비 업데이트 진행)

// when
// 등록한 상품 조회

// then
// 등록 한 상품 재고 수량 비교
// 재고 수량 5개라고 예상
}
}

상품 등록 후 재고 수량을 5개로 업데이트 하고 수량 비교 하는 TEST 코드 입니다. @Transaction 내에 실행 되는 코드이니 JPA 변경 감지 기능 통해 정상적으로 변경된 재고 수량으로
업데이트가 발생 할 것 입니다.

하지만 이렇게 의도한 대로 선 TEST 코드 작성 후 기능 구현 진행하는 과정에서 이렇게 정상적으로 TEST 진행되는 것을 착각 하고
재고 수량 변경 하는 기능 구현하는 단계에 정작 필요한 @Transaction 에노테이션을 추가 하지 않는 실수를 범하면 이야기가 달라집니다.

실제로 이대로 배포를 하고 사용자가 이 프로세스대로 진행시 분명 에러가 발생 할 것 입니다. 구현단에는 @Transaction 기능을 추가 하지 않는 상태이니
JPA 변경감지가 발동하지 않고 제품 수량이 업데이트가 되지 않는 문제가 발생 할 것 입니다.

이러한 실수를 방지 하기 위해 저같은 경우 @SpringBootTest 사용해서 실수를 사전에 방지 하도록 합니다.

Business Layer 테스트

Business Layer Test

Business Layer 테스트 알아보겠습니다. 말 그대로 비지니스 관련 로직 정상적으로 구현되고 있는지 체크 하는 역활 입니다.
이전에 알아보았던 Persistence Layer 테스트와 함께 상호 작용을 하면서 Business Layer 테스트 진행 하도록 합니다.

그리고 중요한 것이 트랜잭션이 정상적으로 보장이 되고 있는지 체크도 해야 합니다. 예외 Exception 이 정상적으로 발생되는 TEST 진행 하면서
정상적으로 롤백이 잘 진행 되었는지 체크도 해야 합니다.

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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
@ActiveProfiles("test")
@SpringBootTest
class PaymentConfirmApiServiceTest {

@Autowired
private ProductRepository productRepository;

@Autowired
private PaymentEventRepository paymentEventRepository;

@Autowired
private PaymentOrderRepository paymentOrderRepository;

@Autowired
private PaymentOrderHistoryRepository paymentOrderHistoryRepository;

@Autowired
private PaymentCheckOutApiService paymentCheckOutApiService;

@Autowired
private PaymentConfirmApiService paymentConfirmApiService;

@Autowired
private PaymentEventQueryService paymentEventQueryService;

@MockBean
protected TossPaymentClient tossPaymentClient;

@AfterEach
void tearDown() {
paymentOrderHistoryRepository.deleteAllInBatch();
paymentOrderRepository.deleteAllInBatch();
productRepository.deleteAllInBatch();
paymentEventRepository.deleteAllInBatch();
}

@Test
@DisplayName("상품 주문 하기 위한 Confirm 기능을 실행 합니다.")
void paymentConfirm() {

// given
Product product1 = this.createProduct("AAA", "상품A", BigDecimal.valueOf(1000));
Product product2 = this.createProduct("BBB", "상품B", BigDecimal.valueOf(2000));
Product product3 = this.createProduct("CCC", "상품C", BigDecimal.valueOf(3000));

List<Product> productList = productRepository.saveAll(List.of(product1, product2, product3));
List<ProductOutPut> productOutPutList = ProductOutPut.of(productList);
List<Long> productNoList = productOutPutList.stream()
.map(ProductOutPut::getNo)
.toList();

PaymentCheckOutRequest paymentCheckOutRequest = PaymentCheckOutRequest.of(productNoList);
PaymentCheckOutResponse paymentCheckOutResponse = paymentCheckOutApiService.paymentCheckOut(paymentCheckOutRequest);

int intTotalAmount = 6000;
String totalAmount = String.valueOf(intTotalAmount);

String paymentKey = this.paymentIdb64uuid();
PaymentConfirmRequest paymentConfirmRequest
= PaymentConfirmRequest.of(
paymentKey,
paymentCheckOutResponse.getOrderId(),
totalAmount
);

// stubbing
this.createStubbingTossPostPayment(paymentKey, paymentCheckOutResponse.getOrderId(), totalAmount);

// when
paymentConfirmApiService.paymentConfirm(paymentConfirmRequest);

// then
List<PaymentEventOutPut> paymentEventOutPutList = paymentEventQueryService.selectPayments();

assertThat(paymentEventOutPutList).hasSize(1)
.extracting("paymentKey", "orderId", "isPaymentDone")
.usingRecursiveFieldByFieldElementComparator(
RecursiveComparisonConfiguration.builder()
.withComparatorForType(BigDecimal::compareTo, BigDecimal.class).build()
)
.containsExactlyInAnyOrder(
tuple(paymentKey, paymentCheckOutResponse.getOrderId(), true)
);

for (PaymentEventOutPut paymentEventOutPut : paymentEventOutPutList) {

BigDecimal responseTotalAmount = paymentEventOutPut.getTotalAmount();
assertThat(responseTotalAmount).isEqualByComparingTo(BigDecimal.valueOf(intTotalAmount));

List<PaymentOrderOutPut> paymentOrderList = paymentEventOutPut.getPaymentOrderList();

assertThat(paymentOrderList).hasSize(3)
.extracting("productId", "name", "amount", "status")
.usingRecursiveFieldByFieldElementComparator(
RecursiveComparisonConfiguration.builder()
.withComparatorForType(BigDecimal::compareTo, BigDecimal.class).build()
)
.containsExactlyInAnyOrder(
tuple("AAA", "상품A", BigDecimal.valueOf(1000), PaymentOrderStatus.SUCCESS),
tuple("BBB", "상품B", BigDecimal.valueOf(2000), PaymentOrderStatus.SUCCESS),
tuple("CCC", "상품C", BigDecimal.valueOf(3000), PaymentOrderStatus.SUCCESS)
);
}
}

@Test
@DisplayName("상품 주문 하기 위한 Confirm 기능을 실행시 경우 예외가 발생 합니다. - 재시도가 가능하지 않는 결제 에러")
void paymentConfirmThenThrow() {

// given
Product product1 = this.createProduct("AAA", "상품A", BigDecimal.valueOf(1000));
Product product2 = this.createProduct("BBB", "상품B", BigDecimal.valueOf(2000));
Product product3 = this.createProduct("CCC", "상품C", BigDecimal.valueOf(3000));

List<Product> productList = productRepository.saveAll(List.of(product1, product2, product3));
List<ProductOutPut> productOutPutList = ProductOutPut.of(productList);
List<Long> productNoList = productOutPutList.stream()
.map(ProductOutPut::getNo)
.toList();

PaymentCheckOutRequest paymentCheckOutRequest = PaymentCheckOutRequest.of(productNoList);
PaymentCheckOutResponse paymentCheckOutResponse = paymentCheckOutApiService.paymentCheckOut(paymentCheckOutRequest);

int intTotalAmount = 6000;
String totalAmount = String.valueOf(intTotalAmount);

String paymentKey = this.paymentIdb64uuid();
PaymentConfirmRequest paymentConfirmRequest
= PaymentConfirmRequest.of(
paymentKey,
paymentCheckOutResponse.getOrderId(),
totalAmount
);

// stubbing
this.createStubbingTossPostPaymentThenThrow();

// when
paymentConfirmApiService.paymentConfirm(paymentConfirmRequest);

// then
List<PaymentEventOutPut> paymentEventOutPutList = paymentEventQueryService.selectPayments();

assertThat(paymentEventOutPutList).hasSize(1)
.extracting("paymentKey", "orderId", "isPaymentDone")
.usingRecursiveFieldByFieldElementComparator(
RecursiveComparisonConfiguration.builder()
.withComparatorForType(BigDecimal::compareTo, BigDecimal.class).build()
)
.containsExactlyInAnyOrder(
tuple(paymentKey, paymentCheckOutResponse.getOrderId(), false)
);

for (PaymentEventOutPut paymentEventOutPut : paymentEventOutPutList) {

BigDecimal responseTotalAmount = paymentEventOutPut.getTotalAmount();
assertThat(responseTotalAmount).isEqualByComparingTo(BigDecimal.valueOf(intTotalAmount));

List<PaymentOrderOutPut> paymentOrderList = paymentEventOutPut.getPaymentOrderList();

assertThat(paymentOrderList).hasSize(3)
.extracting("productId", "name", "amount", "status")
.usingRecursiveFieldByFieldElementComparator(
RecursiveComparisonConfiguration.builder()
.withComparatorForType(BigDecimal::compareTo, BigDecimal.class).build()
)
.containsExactlyInAnyOrder(
tuple("AAA", "상품A", BigDecimal.valueOf(1000), PaymentOrderStatus.FAILURE),
tuple("BBB", "상품B", BigDecimal.valueOf(2000), PaymentOrderStatus.FAILURE),
tuple("CCC", "상품C", BigDecimal.valueOf(3000), PaymentOrderStatus.FAILURE)
);
}
}

@Test
@DisplayName("상품 주문 하기 위한 Confirm 기능을 실행시 경우 예외가 발생 합니다. - 이미 완료된 결제건")
void paymentConfirmAlreadyCompletedPaymentThenThrow() {

// given
Product product1 = this.createProduct("AAA", "상품A", BigDecimal.valueOf(1000));
Product product2 = this.createProduct("BBB", "상품B", BigDecimal.valueOf(2000));
Product product3 = this.createProduct("CCC", "상품C", BigDecimal.valueOf(3000));

List<Product> productList = productRepository.saveAll(List.of(product1, product2, product3));
List<ProductOutPut> productOutPutList = ProductOutPut.of(productList);
List<Long> productNoList = productOutPutList.stream()
.map(ProductOutPut::getNo)
.toList();

PaymentCheckOutRequest paymentCheckOutRequest = PaymentCheckOutRequest.of(productNoList);
PaymentCheckOutResponse paymentCheckOutResponse = paymentCheckOutApiService.paymentCheckOut(paymentCheckOutRequest);

int intTotalAmount = 6000;
String totalAmount = String.valueOf(intTotalAmount);

String paymentKey = this.paymentIdb64uuid();
PaymentConfirmRequest paymentConfirmRequest
= PaymentConfirmRequest.of(
paymentKey,
paymentCheckOutResponse.getOrderId(),
totalAmount
);

// stubbing
this.createStubbingAlreadyCompletedTossPostPaymentThenThrow(paymentKey, paymentCheckOutResponse.getOrderId(), totalAmount);

// when
paymentConfirmApiService.paymentConfirm(paymentConfirmRequest);

// then
List<PaymentEventOutPut> paymentEventOutPutList = paymentEventQueryService.selectPayments();

assertThat(paymentEventOutPutList).hasSize(1)
.extracting("paymentKey", "orderId", "isPaymentDone")
.usingRecursiveFieldByFieldElementComparator(
RecursiveComparisonConfiguration.builder()
.withComparatorForType(BigDecimal::compareTo, BigDecimal.class).build()
)
.containsExactlyInAnyOrder(
tuple(paymentKey, paymentCheckOutResponse.getOrderId(), true)
);

for (PaymentEventOutPut paymentEventOutPut : paymentEventOutPutList) {

BigDecimal responseTotalAmount = paymentEventOutPut.getTotalAmount();
assertThat(responseTotalAmount).isEqualByComparingTo(BigDecimal.valueOf(intTotalAmount));

List<PaymentOrderOutPut> paymentOrderList = paymentEventOutPut.getPaymentOrderList();

assertThat(paymentOrderList).hasSize(3)
.extracting("productId", "name", "amount", "status")
.usingRecursiveFieldByFieldElementComparator(
RecursiveComparisonConfiguration.builder()
.withComparatorForType(BigDecimal::compareTo, BigDecimal.class).build()
)
.containsExactlyInAnyOrder(
tuple("AAA", "상품A", BigDecimal.valueOf(1000), PaymentOrderStatus.SUCCESS),
tuple("BBB", "상품B", BigDecimal.valueOf(2000), PaymentOrderStatus.SUCCESS),
tuple("CCC", "상품C", BigDecimal.valueOf(3000), PaymentOrderStatus.SUCCESS)
);
}
}

@Test
@DisplayName("결제 금액이 유효하지 않을 경우 예외가 발생 합니다.")
void invalidPaymentAmountThenThrow() {

// given
Product product1 = this.createProduct("AAA", "상품A", BigDecimal.valueOf(1000));
Product product2 = this.createProduct("BBB", "상품B", BigDecimal.valueOf(2000));
Product product3 = this.createProduct("CCC", "상품C", BigDecimal.valueOf(3000));

List<Product> productList = productRepository.saveAll(List.of(product1, product2, product3));
List<ProductOutPut> productOutPutList = ProductOutPut.of(productList);
List<Long> productNoList = productOutPutList.stream()
.map(ProductOutPut::getNo)
.toList();

PaymentCheckOutRequest paymentCheckOutRequest = PaymentCheckOutRequest.of(productNoList);
PaymentCheckOutResponse paymentCheckOutResponse = paymentCheckOutApiService.paymentCheckOut(paymentCheckOutRequest);

int intTotalAmount = 5000;
String totalAmount = String.valueOf(intTotalAmount);

String paymentKey = this.paymentIdb64uuid();
PaymentConfirmRequest paymentConfirmRequest
= PaymentConfirmRequest.of(
paymentKey,
paymentCheckOutResponse.getOrderId(),
totalAmount
);

// when // then
assertThatThrownBy(() -> paymentConfirmApiService.paymentConfirm(paymentConfirmRequest))
.isInstanceOf(BusinessException.class)
.hasMessage("결제 금액이 유효하지 않습니다.");
}

private void createStubbingTossPostPayment(String paymentKey, String orderId, String amount) {

String responsePostPaymentsBody = "{\"mId\":\"tvivarepublica\",\"lastTransactionKey\":\"F69384D0887558A85A78199D534FBBD5\",\"paymentKey\":\"" + paymentKey + "\",\"orderId\":\"" + orderId + "\",\"orderName\":\"상품A 그외\",\"taxExemptionAmount\":0,\"status\":\"DONE\",\"requestedAt\":\"2024-11-30T16:53:34+09:00\",\"approvedAt\":\"2024-11-30T16:53:57+09:00\",\"useEscrow\":false,\"cultureExpense\":false,\"card\":{\"issuerCode\":\"11\",\"acquirerCode\":\"11\",\"number\":\"52361232****606*\",\"installmentPlanMonths\":0,\"isInterestFree\":false,\"interestPayer\":null,\"approveNo\":\"00000000\",\"useCardPoint\":false,\"cardType\":\"신용\",\"ownerType\":\"개인\",\"acquireStatus\":\"READY\",\"amount\":" + amount + "},\"virtualAccount\":null,\"transfer\":null,\"mobilePhone\":null,\"giftCertificate\":null,\"cashReceipt\":null,\"cashReceipts\":null,\"discount\":null,\"cancels\":null,\"secret\":\"ps_GePWvyJnrKvelyyyB2pLVgLzN97E\",\"type\":\"NORMAL\",\"easyPay\":null,\"country\":\"KR\",\"failure\":null,\"isPartialCancelable\":true,\"receipt\":{\"url\":\"https://dashboard.tosspayments.com/receipt/redirection?transactionId=tviva20241130165334qVdY4&ref=PX\"},\"checkout\":{\"url\":\"https://api.tosspayments.com/v1/payments/tviva20241130165334qVdY4/checkout\"},\"currency\":\"KRW\",\"totalAmount\":" + amount + ",\"balanceAmount\":6000,\"suppliedAmount\":5455,\"vat\":545,\"taxFreeAmount\":0,\"method\":\"카드\",\"version\":\"2022-11-16\",\"metadata\":null}";
when(tossPaymentClient.paymentConfirm(
any(String.class), any(PaymentConfirmInPut.class)
)).thenReturn(ResponseEntity.ok(responsePostPaymentsBody));
}

private void createStubbingTossPostPaymentThenThrow() {

PaymentException paymentException = new PaymentException(
PSPErrorCode.CLIENT_ERROR,
"INVALID_STOPPED_CARD",
new String[]{"정지된 카드 입니다."},
"{ \"code\": \"INVALID_STOPPED_CARD\", \"message\": \"정지된 카드 입니다.\" }"
);

when(tossPaymentClient.paymentConfirm(
any(String.class), any(PaymentConfirmInPut.class)
)).thenThrow(paymentException);
}

private void createStubbingAlreadyCompletedTossPostPaymentThenThrow(String paymentKey, String orderId, String amount) {

PaymentException paymentException = new PaymentException(
PSPErrorCode.CLIENT_ERROR,
"ALREADY_COMPLETED_PAYMENT",
new String[]{"이미 완료된 결제 입니다."},
"{ \"code\": \"ALREADY_COMPLETED_PAYMENT\", \"message\": \"이미 완료된 결제 입니다.\" }"
);

when(tossPaymentClient.paymentConfirm(
any(String.class), any(PaymentConfirmInPut.class)
)).thenThrow(paymentException);

String responsePostPaymentsBody = "{\"mId\":\"tvivarepublica\",\"lastTransactionKey\":\"F69384D0887558A85A78199D534FBBD5\",\"paymentKey\":\"" + paymentKey + "\",\"orderId\":\"" + orderId + "\",\"orderName\":\"상품A 그외\",\"taxExemptionAmount\":0,\"status\":\"DONE\",\"requestedAt\":\"2024-11-30T16:53:34+09:00\",\"approvedAt\":\"2024-11-30T16:53:57+09:00\",\"useEscrow\":false,\"cultureExpense\":false,\"card\":{\"issuerCode\":\"11\",\"acquirerCode\":\"11\",\"number\":\"52361232****606*\",\"installmentPlanMonths\":0,\"isInterestFree\":false,\"interestPayer\":null,\"approveNo\":\"00000000\",\"useCardPoint\":false,\"cardType\":\"신용\",\"ownerType\":\"개인\",\"acquireStatus\":\"READY\",\"amount\":" + amount + "},\"virtualAccount\":null,\"transfer\":null,\"mobilePhone\":null,\"giftCertificate\":null,\"cashReceipt\":null,\"cashReceipts\":null,\"discount\":null,\"cancels\":null,\"secret\":\"ps_GePWvyJnrKvelyyyB2pLVgLzN97E\",\"type\":\"NORMAL\",\"easyPay\":null,\"country\":\"KR\",\"failure\":null,\"isPartialCancelable\":true,\"receipt\":{\"url\":\"https://dashboard.tosspayments.com/receipt/redirection?transactionId=tviva20241130165334qVdY4&ref=PX\"},\"checkout\":{\"url\":\"https://api.tosspayments.com/v1/payments/tviva20241130165334qVdY4/checkout\"},\"currency\":\"KRW\",\"totalAmount\":" + amount + ",\"balanceAmount\":6000,\"suppliedAmount\":5455,\"vat\":545,\"taxFreeAmount\":0,\"method\":\"카드\",\"version\":\"2022-11-16\",\"metadata\":null}";
when(tossPaymentClient.getPayments(
any(String.class)
)).thenReturn(ResponseEntity.ok(responsePostPaymentsBody));
}

private Product createProduct(String productId, String name, BigDecimal price) {

return Product.of(productId, name, price);
}

private String paymentIdb64uuid() {
SecureRandom secureRandom = new SecureRandom();
byte[] randomBytes = new byte[25];
secureRandom.nextBytes(randomBytes);
String identifier = Base64.getUrlEncoder().withoutPadding().encodeToString(randomBytes);
return identifier.substring(0, 30);
}
}

Business Layer 테스트에서는 총 4가지 TEST 를 진행 할려고 합니다.

  1. 상품 주문 하기 위한 Confirm 기능을 실행 합니다.
  2. 상품 주문 하기 위한 Confirm 기능을 실행시 경우 예외가 발생 합니다. - 재시도가 가능하지 않는 결제 에러
  3. 상품 주문 하기 위한 Confirm 기능을 실행시 경우 예외가 발생 합니다. - 이미 완료된 결제건
  4. 결제 금액이 유효하지 않을 경우 예외가 발생 합니다.

앞써 최종 결제 승인이 완료 될려면 이전에 checkout 기능 통해 구매 이벤트를 발생 하도록 하고 마지막으로 confirm 기능으로 최종 결제 승인이 발생 하도록 합니다.

멱등성1.png
멱등성2.png

결제 시도하는 과정에서 토스 PSP 사 잠시동안 서버 불안정으로 결제 승인 실패 하는 경우 retry 기능을 통해 다시 결제 승인 시도를 하지만 재시도가 불가능한 결제 에러 발생 한 경우는
이에 맞는 적절한 예외가 발생되는지 TEST 도 추가 해보았습니다.

마지막으로 이미 결제건에 대해 다시 시도 할 경우에도 적절 하게 예외 처리 하였는지도 체크 합니다.


Copyright 201- syh8088. 무단 전재 및 재배포 금지. 출처 표기 시 인용 가능.

💰

×

Help us with donation