Skip to content

Commit d8bcbf9

Browse files
committed
test: Kafka Consumer 단위 테스트 추가
테스트 작성: - CatalogEventConsumerTest: 좋아요/조회수 이벤트 처리 검증 (6개) - OrderEventConsumerTest: 주문/결제 이벤트 처리 검증 (7개) - EventInboxServiceTest: 멱등성 보장 검증 - ProductMetricsServiceTest: 집계 로직 검증 - ConsumerSmokeTest: Bean 생성 및 Context 로딩 검증 테스트 전략: - Consumer 로직 단위 테스트 (Mock 사용) - 실제 Kafka 없이 비즈니스 로직만 검증 - 문서로서의 테스트: 명확한 설명과 시나리오
1 parent 62a6728 commit d8bcbf9

6 files changed

Lines changed: 682 additions & 0 deletions

File tree

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"))
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
package com.loopers.application.inbox;
2+
3+
import static org.assertj.core.api.Assertions.assertThat;
4+
5+
import com.loopers.domain.inbox.EventInboxRepository;
6+
import com.loopers.testcontainers.RedisTestContainersConfig;
7+
import org.junit.jupiter.api.BeforeEach;
8+
import org.junit.jupiter.api.Test;
9+
import org.springframework.beans.factory.annotation.Autowired;
10+
import org.springframework.boot.test.context.SpringBootTest;
11+
import org.springframework.context.annotation.Import;
12+
import org.springframework.transaction.annotation.Transactional;
13+
14+
@SpringBootTest
15+
@Transactional
16+
@Import(RedisTestContainersConfig.class)
17+
class EventInboxServiceTest {
18+
19+
@Autowired
20+
private EventInboxService eventInboxService;
21+
22+
@Autowired
23+
private EventInboxRepository eventInboxRepository;
24+
25+
@BeforeEach
26+
void setUp() {
27+
eventInboxRepository.deleteAll();
28+
}
29+
30+
@Test
31+
void 중복_이벤트를_감지한다() {
32+
// Given
33+
String eventId = "duplicate-test-001";
34+
eventInboxService.save(eventId, "ORDER", "123", "OrderCreatedEvent");
35+
36+
// When
37+
boolean isDuplicate = eventInboxService.isDuplicate(eventId);
38+
39+
// Then
40+
assertThat(isDuplicate).isTrue();
41+
}
42+
43+
@Test
44+
void 신규_이벤트는_중복이_아니다() {
45+
// Given
46+
String eventId = "new-event-001";
47+
48+
// When
49+
boolean isDuplicate = eventInboxService.isDuplicate(eventId);
50+
51+
// Then
52+
assertThat(isDuplicate).isFalse();
53+
}
54+
55+
@Test
56+
void Inbox에_이벤트를_저장한다() {
57+
// Given
58+
String eventId = "save-test-001";
59+
String aggregateType = "PRODUCT";
60+
String aggregateId = "456";
61+
String eventType = "ProductViewedEvent";
62+
63+
// When
64+
eventInboxService.save(eventId, aggregateType, aggregateId, eventType);
65+
66+
// Then
67+
boolean exists = eventInboxRepository.existsByEventId(eventId);
68+
assertThat(exists).isTrue();
69+
}
70+
}
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
package com.loopers.application.metrics;
2+
3+
import static org.assertj.core.api.Assertions.assertThat;
4+
5+
import com.loopers.domain.metrics.ProductMetrics;
6+
import com.loopers.domain.metrics.ProductMetricsRepository;
7+
import com.loopers.testcontainers.RedisTestContainersConfig;
8+
import java.math.BigDecimal;
9+
import org.junit.jupiter.api.BeforeEach;
10+
import org.junit.jupiter.api.Test;
11+
import org.springframework.beans.factory.annotation.Autowired;
12+
import org.springframework.boot.test.context.SpringBootTest;
13+
import org.springframework.context.annotation.Import;
14+
import org.springframework.transaction.annotation.Transactional;
15+
16+
@SpringBootTest
17+
@Transactional
18+
@Import(RedisTestContainersConfig.class)
19+
class ProductMetricsServiceTest {
20+
21+
@Autowired
22+
private ProductMetricsService productMetricsService;
23+
24+
@Autowired
25+
private ProductMetricsRepository productMetricsRepository;
26+
27+
@BeforeEach
28+
void setUp() {
29+
productMetricsRepository.deleteAll();
30+
}
31+
32+
@Test
33+
void 좋아요_수를_증가시킨다() {
34+
// Given
35+
Long productId = 1L;
36+
37+
// When
38+
productMetricsService.incrementLikeCount(productId);
39+
40+
// Then
41+
ProductMetrics metrics = productMetricsRepository.findByProductId(productId)
42+
.orElseThrow();
43+
assertThat(metrics.getLikeCount()).isEqualTo(1);
44+
}
45+
46+
@Test
47+
void 좋아요_수를_감소시킨다() {
48+
// Given
49+
Long productId = 2L;
50+
productMetricsService.incrementLikeCount(productId);
51+
productMetricsService.incrementLikeCount(productId);
52+
53+
// When
54+
productMetricsService.decrementLikeCount(productId);
55+
56+
// Then
57+
ProductMetrics metrics = productMetricsRepository.findByProductId(productId)
58+
.orElseThrow();
59+
assertThat(metrics.getLikeCount()).isEqualTo(1);
60+
}
61+
62+
@Test
63+
void 조회수를_증가시킨다() {
64+
// Given
65+
Long productId = 3L;
66+
67+
// When
68+
productMetricsService.incrementViewCount(productId);
69+
productMetricsService.incrementViewCount(productId);
70+
71+
// Then
72+
ProductMetrics metrics = productMetricsRepository.findByProductId(productId)
73+
.orElseThrow();
74+
assertThat(metrics.getViewCount()).isEqualTo(2);
75+
}
76+
77+
@Test
78+
void 주문수와_판매금액을_증가시킨다() {
79+
// Given
80+
Long productId = 4L;
81+
int quantity = 3;
82+
BigDecimal amount = new BigDecimal("30000");
83+
84+
// When
85+
productMetricsService.incrementOrderCount(productId, quantity, amount);
86+
87+
// Then
88+
ProductMetrics metrics = productMetricsRepository.findByProductId(productId)
89+
.orElseThrow();
90+
assertThat(metrics.getOrderCount()).isEqualTo(3);
91+
assertThat(metrics.getSalesAmount()).isEqualByComparingTo(new BigDecimal("30000"));
92+
}
93+
}
Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
package com.loopers.interfaces.consumer;
2+
3+
import static org.assertj.core.api.Assertions.assertThat;
4+
import static org.mockito.ArgumentMatchers.any;
5+
import static org.mockito.Mockito.never;
6+
import static org.mockito.Mockito.verify;
7+
import static org.mockito.Mockito.when;
8+
9+
import com.fasterxml.jackson.databind.ObjectMapper;
10+
import com.loopers.application.inbox.EventInboxService;
11+
import com.loopers.application.metrics.ProductMetricsService;
12+
import java.util.HashMap;
13+
import java.util.Map;
14+
import org.apache.kafka.clients.consumer.ConsumerRecord;
15+
import org.junit.jupiter.api.DisplayName;
16+
import org.junit.jupiter.api.Nested;
17+
import org.junit.jupiter.api.Test;
18+
import org.junit.jupiter.api.extension.ExtendWith;
19+
import org.mockito.Mock;
20+
import org.mockito.junit.jupiter.MockitoExtension;
21+
22+
/**
23+
* CatalogEventConsumer 단위 테스트
24+
*
25+
* 목적:
26+
* 1. Consumer가 각 이벤트 타입을 올바르게 처리하는지 검증
27+
* 2. Inbox 패턴으로 중복 이벤트를 방지하는지 검증
28+
* 3. 비즈니스 로직(ProductMetricsService)이 올바르게 호출되는지 검증
29+
*/
30+
@ExtendWith(MockitoExtension.class)
31+
@DisplayName("Catalog 이벤트 Consumer")
32+
class CatalogEventConsumerTest {
33+
34+
@Mock
35+
private EventInboxService eventInboxService;
36+
37+
@Mock
38+
private ProductMetricsService productMetricsService;
39+
40+
private CatalogEventConsumer catalogEventConsumer;
41+
42+
private final ObjectMapper objectMapper = new ObjectMapper();
43+
44+
@org.junit.jupiter.api.BeforeEach
45+
void setUp() {
46+
// ObjectMapper는 실제 인스턴스 사용, DeadLetterQueueService는 null (단위 테스트에서 불필요)
47+
catalogEventConsumer = new CatalogEventConsumer(
48+
eventInboxService,
49+
productMetricsService,
50+
null, // DeadLetterQueueService - 단위 테스트에서 사용하지 않음
51+
objectMapper
52+
);
53+
}
54+
55+
@Nested
56+
@DisplayName("LikeCreatedEvent 처리")
57+
class LikeCreatedEventTest {
58+
59+
@Test
60+
@DisplayName("좋아요 생성 이벤트를 받으면 ProductMetrics의 좋아요 수를 증가시킨다")
61+
void incrementLikeCount() throws Exception {
62+
// Given: 좋아요 생성 이벤트
63+
Long productId = 1L;
64+
String eventId = "event-001";
65+
Map<String, Object> event = createEvent(eventId, "LikeCreatedEvent", "LIKE", productId.toString());
66+
ConsumerRecord<Object, Object> record = createConsumerRecord("catalog-events", event);
67+
68+
when(eventInboxService.isDuplicate(eventId)).thenReturn(false);
69+
70+
// When: Consumer가 이벤트 처리
71+
boolean result = catalogEventConsumer.processEvent(record);
72+
73+
// Then: Inbox에 저장하고, 좋아요 수 증가
74+
assertThat(result).isTrue();
75+
verify(eventInboxService).save(eventId, "LIKE", productId.toString(), "LikeCreatedEvent");
76+
verify(productMetricsService).incrementLikeCount(productId);
77+
}
78+
79+
@Test
80+
@DisplayName("중복된 좋아요 이벤트는 무시한다 (멱등성 보장)")
81+
void ignoreDuplicateEvent() throws Exception {
82+
// Given: 이미 처리된 이벤트 (Inbox에 존재)
83+
String eventId = "event-001";
84+
Map<String, Object> event = createEvent(eventId, "LikeCreatedEvent", "LIKE", "1");
85+
ConsumerRecord<Object, Object> record = createConsumerRecord("catalog-events", event);
86+
87+
when(eventInboxService.isDuplicate(eventId)).thenReturn(true);
88+
89+
// When: 같은 이벤트를 다시 수신
90+
boolean result = catalogEventConsumer.processEvent(record);
91+
92+
// Then: 처리하지 않고 스킵 (false 반환)
93+
assertThat(result).isFalse();
94+
verify(eventInboxService, never()).save(any(), any(), any(), any());
95+
verify(productMetricsService, never()).incrementLikeCount(any());
96+
}
97+
}
98+
99+
@Nested
100+
@DisplayName("LikeDeletedEvent 처리")
101+
class LikeDeletedEventTest {
102+
103+
@Test
104+
@DisplayName("좋아요 삭제 이벤트를 받으면 ProductMetrics의 좋아요 수를 감소시킨다")
105+
void decrementLikeCount() throws Exception {
106+
// Given: 좋아요 삭제 이벤트
107+
Long productId = 2L;
108+
String eventId = "event-002";
109+
Map<String, Object> event = createEvent(eventId, "LikeDeletedEvent", "LIKE", productId.toString());
110+
ConsumerRecord<Object, Object> record = createConsumerRecord("catalog-events", event);
111+
112+
when(eventInboxService.isDuplicate(eventId)).thenReturn(false);
113+
114+
// When: Consumer가 이벤트 처리
115+
boolean result = catalogEventConsumer.processEvent(record);
116+
117+
// Then: Inbox에 저장하고, 좋아요 수 감소
118+
assertThat(result).isTrue();
119+
verify(eventInboxService).save(eventId, "LIKE", productId.toString(), "LikeDeletedEvent");
120+
verify(productMetricsService).decrementLikeCount(productId);
121+
}
122+
}
123+
124+
@Nested
125+
@DisplayName("ProductViewedEvent 처리")
126+
class ProductViewedEventTest {
127+
128+
@Test
129+
@DisplayName("상품 조회 이벤트를 받으면 ProductMetrics의 조회 수를 증가시킨다")
130+
void incrementViewCount() throws Exception {
131+
// Given: 상품 조회 이벤트
132+
Long productId = 3L;
133+
String eventId = "event-003";
134+
Map<String, Object> event = createEvent(eventId, "ProductViewedEvent", "PRODUCT", productId.toString());
135+
ConsumerRecord<Object, Object> record = createConsumerRecord("catalog-events", event);
136+
137+
when(eventInboxService.isDuplicate(eventId)).thenReturn(false);
138+
139+
// When: Consumer가 이벤트 처리
140+
boolean result = catalogEventConsumer.processEvent(record);
141+
142+
// Then: Inbox에 저장하고, 조회 수 증가
143+
assertThat(result).isTrue();
144+
verify(eventInboxService).save(eventId, "PRODUCT", productId.toString(), "ProductViewedEvent");
145+
verify(productMetricsService).incrementViewCount(productId);
146+
}
147+
}
148+
149+
@Nested
150+
@DisplayName("예외 상황 처리")
151+
class ExceptionHandlingTest {
152+
153+
@Test
154+
@DisplayName("eventId가 없는 메시지는 false를 반환하고 처리하지 않는다")
155+
void handleMissingEventId() throws Exception {
156+
// Given: eventId가 없는 잘못된 메시지
157+
Map<String, Object> event = new HashMap<>();
158+
event.put("eventType", "LikeCreatedEvent");
159+
event.put("aggregateType", "LIKE");
160+
event.put("aggregateId", "1");
161+
// id 필드 없음
162+
163+
ConsumerRecord<Object, Object> record = createConsumerRecord("catalog-events", event);
164+
165+
// When: Consumer가 이벤트 처리 시도
166+
boolean result = catalogEventConsumer.processEvent(record);
167+
168+
// Then: 처리하지 않고 false 반환
169+
assertThat(result).isFalse();
170+
verify(eventInboxService, never()).isDuplicate(any());
171+
verify(eventInboxService, never()).save(any(), any(), any(), any());
172+
verify(productMetricsService, never()).incrementLikeCount(any());
173+
}
174+
175+
@Test
176+
@DisplayName("알 수 없는 이벤트 타입은 Inbox에만 저장하고 비즈니스 로직은 실행하지 않는다")
177+
void handleUnknownEventType() throws Exception {
178+
// Given: 알 수 없는 이벤트 타입
179+
String eventId = "event-999";
180+
Map<String, Object> event = createEvent(eventId, "UnknownEvent", "UNKNOWN", "1");
181+
ConsumerRecord<Object, Object> record = createConsumerRecord("catalog-events", event);
182+
183+
when(eventInboxService.isDuplicate(eventId)).thenReturn(false);
184+
185+
// When: Consumer가 이벤트 처리
186+
boolean result = catalogEventConsumer.processEvent(record);
187+
188+
// Then: Inbox에는 저장하지만, ProductMetrics는 업데이트하지 않음
189+
assertThat(result).isTrue();
190+
verify(eventInboxService).save(eventId, "UNKNOWN", "1", "UnknownEvent");
191+
verify(productMetricsService, never()).incrementLikeCount(any());
192+
verify(productMetricsService, never()).decrementLikeCount(any());
193+
verify(productMetricsService, never()).incrementViewCount(any());
194+
}
195+
}
196+
197+
// Helper methods
198+
private Map<String, Object> createEvent(String id, String eventType, String aggregateType, String aggregateId) {
199+
Map<String, Object> event = new HashMap<>();
200+
event.put("id", id);
201+
event.put("eventType", eventType);
202+
event.put("aggregateType", aggregateType);
203+
event.put("aggregateId", aggregateId);
204+
return event;
205+
}
206+
207+
private ConsumerRecord<Object, Object> createConsumerRecord(String topic, Map<String, Object> event) throws Exception {
208+
String payload = objectMapper.writeValueAsString(event);
209+
return new ConsumerRecord<>(topic, 0, 0L, "key", payload);
210+
}
211+
}

0 commit comments

Comments
 (0)