Skip to content

Commit e4ce8ac

Browse files
author
green
committed
test: Outbox 패턴 및 Kafka Consumer 테스트
commerce-api: - IntegrationTestSupport: Outbox 테스트 지원 - MockKafkaConfig: 테스트용 Kafka Mock 설정 - application-test.yml: 테스트 환경 설정 commerce-streamer: - CatalogEventHandlerIntegrationTest: 이벤트 핸들러 통합 테스트 - ProductMetricsIntegrationTest: 메트릭스 집계 테스트 - ProductOutOfStockStrategyTest: 품절 전략 테스트
1 parent 1d68362 commit e4ce8ac

File tree

12 files changed

+455
-1
lines changed

12 files changed

+455
-1
lines changed

apps/commerce-api/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ dependencies {
22
// add-ons
33
implementation(project(":modules:jpa"))
44
implementation(project(":modules:redis"))
5+
implementation(project(":modules:kafka"))
56
implementation(project(":supports:jackson"))
67
implementation(project(":supports:logging"))
78
implementation(project(":supports:monitoring"))

apps/commerce-api/src/test/java/com/loopers/CommerceApiContextTest.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
package com.loopers;
22

3+
import com.loopers.support.test.MockKafkaConfig;
34
import org.junit.jupiter.api.Test;
45
import org.springframework.boot.test.context.SpringBootTest;
6+
import org.springframework.context.annotation.Import;
57

68
@SpringBootTest
9+
@Import(MockKafkaConfig.class) // OutboxRelay가 KafkaTemplate 의존 → Mock으로 대체
710
class CommerceApiContextTest {
811

912
@Test

apps/commerce-api/src/test/java/com/loopers/support/test/IntegrationTestSupport.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,12 @@
44
import org.junit.jupiter.api.AfterEach;
55
import org.springframework.beans.factory.annotation.Autowired;
66
import org.springframework.boot.test.context.SpringBootTest;
7+
import org.springframework.context.annotation.Import;
78
import org.springframework.test.context.ActiveProfiles;
89

910
@SpringBootTest
1011
@ActiveProfiles("test")
12+
@Import(MockKafkaConfig.class) // OutboxRelay가 KafkaTemplate 의존 → Mock으로 대체
1113
public abstract class IntegrationTestSupport {
1214

1315
@Autowired
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
package com.loopers.support.test;
2+
3+
import org.mockito.Mockito;
4+
import org.springframework.boot.test.context.TestConfiguration;
5+
import org.springframework.context.annotation.Bean;
6+
import org.springframework.context.annotation.Primary;
7+
import org.springframework.kafka.core.KafkaTemplate;
8+
9+
@TestConfiguration
10+
public class MockKafkaConfig {
11+
12+
@Bean
13+
@Primary
14+
@SuppressWarnings("unchecked")
15+
public KafkaTemplate<String, String> kafkaTemplate() {
16+
return Mockito.mock(KafkaTemplate.class);
17+
}
18+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
# 테스트에서 동일한 이름의 빈을 Mock으로 교체할 수 있도록 허용
2+
spring:
3+
main:
4+
allow-bean-definition-overriding: true

apps/commerce-streamer/build.gradle.kts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,5 +19,4 @@ dependencies {
1919
// test-fixtures
2020
testImplementation(testFixtures(project(":modules:jpa")))
2121
testImplementation(testFixtures(project(":modules:redis")))
22-
testImplementation(testFixtures(project(":modules:kafka")))
2322
}
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
package com.loopers.application;
2+
3+
import static org.assertj.core.api.Assertions.assertThat;
4+
import static org.junit.jupiter.api.Assertions.assertAll;
5+
6+
import com.fasterxml.jackson.databind.ObjectMapper;
7+
import com.fasterxml.jackson.databind.node.ObjectNode;
8+
import com.loopers.domain.eventhandled.EventHandledRepository;
9+
import com.loopers.support.test.IntegrationTestSupport;
10+
import org.junit.jupiter.api.DisplayName;
11+
import org.junit.jupiter.api.Nested;
12+
import org.junit.jupiter.api.Test;
13+
import org.springframework.beans.factory.annotation.Autowired;
14+
15+
@DisplayName("CatalogEventHandler 통합 테스트")
16+
class CatalogEventHandlerIntegrationTest extends IntegrationTestSupport {
17+
18+
@Autowired
19+
private CatalogEventHandler catalogEventHandler;
20+
21+
@Autowired
22+
private EventHandledRepository eventHandledRepository;
23+
24+
@Autowired
25+
private ObjectMapper objectMapper;
26+
27+
private ObjectNode emptyPayload() {
28+
return objectMapper.createObjectNode();
29+
}
30+
31+
@Nested
32+
@DisplayName("멱등성 처리")
33+
class Idempotency {
34+
35+
@Test
36+
@DisplayName("새로운 eventId는 처리하고 event_handled에 저장한다")
37+
void shouldProcessNewEvent() {
38+
String eventId = "new-event-id-001";
39+
String eventType = "product_out_of_stock";
40+
String aggregateId = "123";
41+
Long occurredAt = System.currentTimeMillis();
42+
43+
boolean result =
44+
catalogEventHandler.handle(eventId, eventType, aggregateId, occurredAt, emptyPayload());
45+
46+
assertThat(result).isTrue();
47+
assertThat(eventHandledRepository.existsByEventId(eventId)).isTrue();
48+
}
49+
50+
@Test
51+
@DisplayName("동일 eventId로 여러 번 요청해도 한 번만 저장된다")
52+
void shouldBeIdempotent_whenSameEventIdRequestedMultipleTimes() {
53+
String eventId = "duplicate-event-id-001";
54+
String eventType = "product_out_of_stock";
55+
String aggregateId = "456";
56+
Long occurredAt = System.currentTimeMillis();
57+
58+
boolean firstResult =
59+
catalogEventHandler.handle(eventId, eventType, aggregateId, occurredAt, emptyPayload());
60+
boolean secondResult =
61+
catalogEventHandler.handle(eventId, eventType, aggregateId, occurredAt, emptyPayload());
62+
boolean thirdResult =
63+
catalogEventHandler.handle(eventId, eventType, aggregateId, occurredAt, emptyPayload());
64+
65+
assertAll(
66+
() -> assertThat(firstResult).isTrue(),
67+
() -> assertThat(secondResult).isTrue(),
68+
() -> assertThat(thirdResult).isTrue());
69+
assertThat(eventHandledRepository.existsByEventId(eventId)).isTrue();
70+
}
71+
72+
@Test
73+
@DisplayName("서로 다른 eventId는 각각 처리된다")
74+
void shouldProcessDifferentEventIds() {
75+
String eventType = "product_out_of_stock";
76+
String aggregateId = "789";
77+
Long occurredAt = System.currentTimeMillis();
78+
79+
catalogEventHandler.handle("event-001", eventType, aggregateId, occurredAt, emptyPayload());
80+
catalogEventHandler.handle("event-002", eventType, aggregateId, occurredAt, emptyPayload());
81+
catalogEventHandler.handle("event-003", eventType, aggregateId, occurredAt, emptyPayload());
82+
83+
assertAll(
84+
() -> assertThat(eventHandledRepository.existsByEventId("event-001")).isTrue(),
85+
() -> assertThat(eventHandledRepository.existsByEventId("event-002")).isTrue(),
86+
() -> assertThat(eventHandledRepository.existsByEventId("event-003")).isTrue());
87+
}
88+
}
89+
}
Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
package com.loopers.application.strategy;
2+
3+
import static org.assertj.core.api.Assertions.assertThat;
4+
import static org.assertj.core.api.Assertions.assertThatThrownBy;
5+
6+
import com.fasterxml.jackson.databind.JsonNode;
7+
import com.fasterxml.jackson.databind.ObjectMapper;
8+
import com.loopers.application.CatalogEventHandler;
9+
import com.loopers.domain.metrics.ProductMetrics;
10+
import com.loopers.infrastructure.metrics.ProductMetricsJpaRepository;
11+
import com.loopers.domain.event.EventType;
12+
import com.loopers.support.test.IntegrationTestSupport;
13+
import java.util.UUID;
14+
import org.junit.jupiter.api.DisplayName;
15+
import org.junit.jupiter.api.Nested;
16+
import org.junit.jupiter.api.Test;
17+
import org.springframework.beans.factory.annotation.Autowired;
18+
19+
@DisplayName("ProductMetrics 집계 통합 테스트")
20+
class ProductMetricsIntegrationTest extends IntegrationTestSupport {
21+
22+
@Autowired
23+
private CatalogEventHandler catalogEventHandler;
24+
25+
@Autowired
26+
private ProductMetricsJpaRepository productMetricsJpaRepository;
27+
28+
@Autowired
29+
private ObjectMapper objectMapper;
30+
31+
@Nested
32+
@DisplayName("sales_count 집계")
33+
class SalesCountAggregation {
34+
35+
@Test
36+
@DisplayName("PRODUCT_SOLD 이벤트 처리 시 sales_count가 quantity만큼 증가한다")
37+
void shouldIncreaseSalesCount_whenProductSoldEventReceived() throws Exception {
38+
Long productId = 100L;
39+
int quantity = 3;
40+
String eventId = UUID.randomUUID().toString();
41+
JsonNode payload = objectMapper.readTree("{\"quantity\":" + quantity + ",\"orderId\":1}");
42+
43+
catalogEventHandler.handle(
44+
eventId, EventType.PRODUCT_SOLD.getCode(), String.valueOf(productId), System.currentTimeMillis(), payload);
45+
46+
ProductMetrics metrics = productMetricsJpaRepository.findById(productId).orElseThrow();
47+
assertThat(metrics.getSalesCount()).isEqualTo(quantity);
48+
}
49+
50+
@Test
51+
@DisplayName("동일 상품에 여러 번 판매 이벤트 발생 시 sales_count가 누적된다")
52+
void shouldAccumulateSalesCount_whenMultipleProductSoldEvents() throws Exception {
53+
Long productId = 101L;
54+
long now = System.currentTimeMillis();
55+
56+
catalogEventHandler.handle(
57+
UUID.randomUUID().toString(),
58+
EventType.PRODUCT_SOLD.getCode(),
59+
String.valueOf(productId),
60+
now,
61+
objectMapper.readTree("{\"quantity\":2,\"orderId\":1}"));
62+
63+
catalogEventHandler.handle(
64+
UUID.randomUUID().toString(),
65+
EventType.PRODUCT_SOLD.getCode(),
66+
String.valueOf(productId),
67+
now + 1,
68+
objectMapper.readTree("{\"quantity\":5,\"orderId\":2}"));
69+
70+
ProductMetrics metrics = productMetricsJpaRepository.findById(productId).orElseThrow();
71+
assertThat(metrics.getSalesCount()).isEqualTo(7);
72+
}
73+
74+
@Test
75+
@DisplayName("quantity 필드가 없으면 예외가 발생한다")
76+
void shouldThrowException_whenQuantityMissing() throws Exception {
77+
Long productId = 102L;
78+
String eventId = UUID.randomUUID().toString();
79+
JsonNode payloadWithoutQuantity = objectMapper.readTree("{\"orderId\":1}");
80+
81+
assertThatThrownBy(
82+
() ->
83+
catalogEventHandler.handle(
84+
eventId,
85+
EventType.PRODUCT_SOLD.getCode(),
86+
String.valueOf(productId),
87+
System.currentTimeMillis(),
88+
payloadWithoutQuantity))
89+
.isInstanceOf(IllegalArgumentException.class)
90+
.hasMessageContaining("quantity");
91+
}
92+
}
93+
94+
@Nested
95+
@DisplayName("like_count 집계")
96+
class LikeCountAggregation {
97+
98+
@Test
99+
@DisplayName("PRODUCT_LIKED 이벤트 처리 시 like_count가 1 증가한다")
100+
void shouldIncreaseLikeCount_whenProductLikedEventReceived() throws Exception {
101+
Long productId = 200L;
102+
String eventId = UUID.randomUUID().toString();
103+
JsonNode emptyPayload = objectMapper.readTree("{}");
104+
105+
catalogEventHandler.handle(
106+
eventId,
107+
EventType.PRODUCT_LIKED.getCode(),
108+
String.valueOf(productId),
109+
System.currentTimeMillis(),
110+
emptyPayload);
111+
112+
ProductMetrics metrics = productMetricsJpaRepository.findById(productId).orElseThrow();
113+
assertThat(metrics.getLikeCount()).isEqualTo(1);
114+
}
115+
116+
@Test
117+
@DisplayName("PRODUCT_UNLIKED 이벤트 처리 시 like_count가 1 감소한다")
118+
void shouldDecreaseLikeCount_whenProductUnlikedEventReceived() throws Exception {
119+
Long productId = 201L;
120+
long now = System.currentTimeMillis();
121+
JsonNode emptyPayload = objectMapper.readTree("{}");
122+
123+
// 먼저 좋아요 2회
124+
catalogEventHandler.handle(
125+
UUID.randomUUID().toString(),
126+
EventType.PRODUCT_LIKED.getCode(),
127+
String.valueOf(productId),
128+
now,
129+
emptyPayload);
130+
catalogEventHandler.handle(
131+
UUID.randomUUID().toString(),
132+
EventType.PRODUCT_LIKED.getCode(),
133+
String.valueOf(productId),
134+
now + 1,
135+
emptyPayload);
136+
137+
// 좋아요 취소 1회
138+
catalogEventHandler.handle(
139+
UUID.randomUUID().toString(),
140+
EventType.PRODUCT_UNLIKED.getCode(),
141+
String.valueOf(productId),
142+
now + 2,
143+
emptyPayload);
144+
145+
ProductMetrics metrics = productMetricsJpaRepository.findById(productId).orElseThrow();
146+
assertThat(metrics.getLikeCount()).isEqualTo(1);
147+
}
148+
149+
@Test
150+
@DisplayName("like_count는 0 미만으로 내려가지 않는다")
151+
void shouldNotGoBelowZero_whenUnlikedMoreThanLiked() throws Exception {
152+
Long productId = 202L;
153+
JsonNode emptyPayload = objectMapper.readTree("{}");
154+
155+
// 좋아요 없이 취소 시도
156+
catalogEventHandler.handle(
157+
UUID.randomUUID().toString(),
158+
EventType.PRODUCT_UNLIKED.getCode(),
159+
String.valueOf(productId),
160+
System.currentTimeMillis(),
161+
emptyPayload);
162+
163+
ProductMetrics metrics = productMetricsJpaRepository.findById(productId).orElseThrow();
164+
assertThat(metrics.getLikeCount()).isZero();
165+
}
166+
}
167+
168+
@Nested
169+
@DisplayName("view_count 집계")
170+
class ViewCountAggregation {
171+
172+
@Test
173+
@DisplayName("PRODUCT_VIEWED 이벤트 처리 시 view_count가 1 증가한다")
174+
void shouldIncreaseViewCount_whenProductViewedEventReceived() throws Exception {
175+
Long productId = 300L;
176+
String eventId = UUID.randomUUID().toString();
177+
JsonNode emptyPayload = objectMapper.readTree("{}");
178+
179+
catalogEventHandler.handle(
180+
eventId,
181+
EventType.PRODUCT_VIEWED.getCode(),
182+
String.valueOf(productId),
183+
System.currentTimeMillis(),
184+
emptyPayload);
185+
186+
ProductMetrics metrics = productMetricsJpaRepository.findById(productId).orElseThrow();
187+
assertThat(metrics.getViewCount()).isEqualTo(1);
188+
}
189+
190+
@Test
191+
@DisplayName("동일 상품에 여러 번 조회 이벤트 발생 시 view_count가 누적된다")
192+
void shouldAccumulateViewCount_whenMultipleProductViewedEvents() throws Exception {
193+
Long productId = 301L;
194+
long now = System.currentTimeMillis();
195+
JsonNode emptyPayload = objectMapper.readTree("{}");
196+
197+
catalogEventHandler.handle(
198+
UUID.randomUUID().toString(),
199+
EventType.PRODUCT_VIEWED.getCode(),
200+
String.valueOf(productId),
201+
now,
202+
emptyPayload);
203+
204+
catalogEventHandler.handle(
205+
UUID.randomUUID().toString(),
206+
EventType.PRODUCT_VIEWED.getCode(),
207+
String.valueOf(productId),
208+
now + 1,
209+
emptyPayload);
210+
211+
catalogEventHandler.handle(
212+
UUID.randomUUID().toString(),
213+
EventType.PRODUCT_VIEWED.getCode(),
214+
String.valueOf(productId),
215+
now + 2,
216+
emptyPayload);
217+
218+
ProductMetrics metrics = productMetricsJpaRepository.findById(productId).orElseThrow();
219+
assertThat(metrics.getViewCount()).isEqualTo(3);
220+
}
221+
}
222+
}

0 commit comments

Comments
 (0)