Skip to content

Commit 5bc8b06

Browse files
authored
feat: application 모듈 단위 테스트 작성 및 CI 테스트 자동화 (#33)
* setting: Fixture Monkey 의존성 추가 (#32) - libs.versions.toml에 fixture-monkey 1.1.11 버전 추가 - test-implementation 번들에 fixture-monkey-starter-kotlin 포함 * test: application 모듈 테스트 픽스처 및 video 도메인 테스트 작성 (#32) - Fixtures.kt: Fixture Monkey 기반 공통 픽스처 팩토리 작성 - VideoAnalyzeServiceTest: 신규/중복/FAILED URL 분석 요청 시나리오 검증 - VideoAnalyzeEventListenerTest: 분석 성공/실패 시 상태 업데이트 및 일정 저장 검증 - VideoAnalysisResultSaverTest: 분석 결과 저장 시 스케줄 아이템 변환 검증 - VideoScheduleServiceTest: 일정 조회 시 Place 조인 및 도메인 매핑 검증 - VideoScheduleItemTest: 도메인 모델 isRetryable 판정 로직 검증 - PlaceEnrichServiceTest: Google Places 장소 보강 성공/실패/재시도 검증 * test: youtube 도메인 테스트 작성 (#32) - YouTubeCollectServiceTest: 영상 수집 시 중복 제거 및 키워드별 수집 검증 - YouTubeChannelCollectServiceTest: 구독자 필터링, 방송사 제외, 중복 제거 검증 - DiscoverVideoServiceTest: Discover API 영상 조회 및 상태별 필터링 검증 - DiscoverChannelServiceTest: Discover API 채널 조회 검증 - SearchKeywordTest: 키워드 랜덤 선택 로직 검증 * test: member, notification, log 도메인 테스트 작성 (#32) - AuthServiceTest: 소셜 로그인 신규/기존 회원 토큰 발급 검증 - ExceptionAlertEventListenerTest: 알림 전송, InterruptedException 플래그 복원, RuntimeException 격리 검증 - AccessLogEventListenerTest: 접근 로그 이벤트 저장 검증 * ci: PR 생성 시 ktlint + test 자동 검증 추가 (#32) - ci-pull-request.yml에 pull_request 트리거 및 auto-check job 추가 - PR 생성/업데이트 시 ktlint check + gradle test 자동 실행 - 기존 '빌드검증' 댓글 트리거 방식은 유지 * refactor: ktlintformat (#32) * refactor: CodeRabbit 리뷰 반영 - 테스트 검증 강화 (#32) - PlaceEnrichServiceTest: 테스트 이름에 맞게 실제 DB 오류 시뮬레이션 추가 - VideoAnalyzeEventListenerTest: assert() → assertEquals()로 변경 (-ea 플래그 무관하게 동작) - YouTubeCollectServiceTest: verify(times(5)) 추가하여 5개 키워드 전부 호출 검증 - SearchKeywordTest: isNotEmpty() → size >= 5로 pickRandom() 계약 반영
1 parent ab7e2de commit 5bc8b06

19 files changed

Lines changed: 1574 additions & 4 deletions

.github/ISSUE_TEMPLATE/linktrip-issue-template.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
---
22
name: LinkTrip Issue
33
about: 기능 개발 및 버그 수정을 위한 이슈 템플릿
4-
title: "[Feature] "
4+
title: "[Feat] "
55
labels: ''
66
assignees: toychip
77
---

.github/PULL_REQUEST_TEMPLATE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
## 관련 이슈
22

33
<!-- 연관된 이슈 번호가 있다면 태그 -->
4-
4+
-
55
---
66

77
## 변경 내용

.github/workflows/ci-pull-request.yml

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
name: CI - Pull request
22

33
on:
4+
pull_request:
5+
branches: [ main ]
46
issue_comment:
57
types: [ created ]
68

@@ -10,6 +12,39 @@ permissions:
1012
pull-requests: write
1113

1214
jobs:
15+
auto-check:
16+
name: 자동 검증 (ktlint + test)
17+
runs-on: ubuntu-latest
18+
if: ${{ github.event_name == 'pull_request' }}
19+
steps:
20+
- name: Repository 접근
21+
uses: actions/checkout@v4
22+
23+
- name: JDK 21 셋팅
24+
uses: actions/setup-java@v4
25+
with:
26+
java-version: '21'
27+
distribution: 'temurin'
28+
29+
- name: Gradle 의존성 캐싱
30+
uses: actions/cache@v4
31+
with:
32+
path: |
33+
~/.gradle/caches
34+
~/.gradle/wrapper
35+
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
36+
restore-keys: |
37+
${{ runner.os }}-gradle-
38+
39+
- name: Setup Gradle
40+
uses: gradle/actions/setup-gradle@v4
41+
42+
- name: ktlint check
43+
run: ./gradlew ktlintCheck
44+
45+
- name: 테스트 실행
46+
run: ./gradlew test
47+
1348
build:
1449
name: ready for launch (CI)
1550
runs-on: ubuntu-latest
@@ -51,7 +86,7 @@ jobs:
5186
- name: ktlint check
5287
run: ./gradlew ktlintCheck
5388

54-
- name: 프로젝트 빌드
89+
- name: 프로젝트 빌드 (테스트 포함)
5590
run: ./gradlew build
5691

5792
- name: Set build status output

gradle/libs.versions.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ querydsl = "5.1.0"
77
ktLintPlugin = "12.1.1"
88
gradleBuildScanPlugin = "3.18.1"
99
javaVersion = "21"
10+
fixture-monkey = "1.1.11"
1011

1112
[plugins]
1213
spring-boot = { id = "org.springframework.boot", version.ref = "springBoot" }
@@ -68,12 +69,13 @@ spring-boot-starter-test = { group = "org.springframework.boot", name = "spring-
6869
kotlin-junit5 = { group = "org.jetbrains.kotlin", name = "kotlin-test-junit5" }
6970
junit = { group = "org.junit.platform", name = "junit-platform-launcher" }
7071
mockito-kotlin = { group = "org.mockito.kotlin", name = "mockito-kotlin", version = "5.4.0" }
72+
fixture-monkey-starter-kotlin = { group = "com.navercorp.fixturemonkey", name = "fixture-monkey-starter-kotlin", version.ref = "fixture-monkey" }
7173

7274
[bundles]
7375
# 공통 (convention에서 사용)
7476
spring-common = ["spring-boot-starter-autoconfigure"]
7577
kotlin-spring = ["kotlin-reflect", "kotlin-jackson", "kotlin-logging"]
76-
test-implementation = ["spring-boot-starter-test", "kotlin-junit5", "mockito-kotlin"]
78+
test-implementation = ["spring-boot-starter-test", "kotlin-junit5", "mockito-kotlin", "fixture-monkey-starter-kotlin"]
7779
test-runtime = ["junit"]
7880

7981
# 모듈별
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
package com.linktrip.application
2+
3+
import com.navercorp.fixturemonkey.FixtureMonkey
4+
import com.navercorp.fixturemonkey.kotlin.KotlinPlugin
5+
6+
object Fixtures {
7+
val monkey: FixtureMonkey =
8+
FixtureMonkey.builder()
9+
.plugin(KotlinPlugin())
10+
.build()
11+
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
package com.linktrip.application.domain.log
2+
3+
import com.linktrip.application.port.output.log.AccessLogPort
4+
import org.junit.jupiter.api.Test
5+
import org.junit.jupiter.api.extension.ExtendWith
6+
import org.mockito.junit.jupiter.MockitoExtension
7+
import org.mockito.kotlin.mock
8+
import org.mockito.kotlin.verify
9+
import org.mockito.kotlin.whenever
10+
11+
@ExtendWith(MockitoExtension::class)
12+
class AccessLogEventListenerTest {
13+
@Test
14+
fun `AccessLog 이벤트가 발생하면_등록된 모든 AccessLogPort에 로그를 저장한다`() {
15+
// given - 2개의 AccessLogPort가 등록된 상태
16+
val port1 = mock<AccessLogPort>()
17+
val port2 = mock<AccessLogPort>()
18+
val listener = AccessLogEventListener(listOf(port1, port2))
19+
20+
val accessLog =
21+
AccessLog(
22+
requestId = "req-1",
23+
method = "GET",
24+
uri = "/api/test",
25+
clientIp = "127.0.0.1",
26+
statusCode = 200,
27+
durationMs = 50,
28+
)
29+
val event = AccessLogEvent(accessLog)
30+
31+
// when - 이벤트를 처리한다
32+
listener.handle(event)
33+
34+
// then - 모든 port에 로그가 저장된다
35+
verify(port1).save(accessLog)
36+
verify(port2).save(accessLog)
37+
}
38+
39+
@Test
40+
fun `하나의 AccessLogPort에서 저장 실패해도_나머지 port에는 정상적으로 저장한다`() {
41+
// given - port1에서 저장 시 예외가 발생하는 상태
42+
val port1 = mock<AccessLogPort>()
43+
val port2 = mock<AccessLogPort>()
44+
val listener = AccessLogEventListener(listOf(port1, port2))
45+
46+
val accessLog =
47+
AccessLog(
48+
requestId = "req-1",
49+
method = "POST",
50+
uri = "/api/data",
51+
clientIp = "10.0.0.1",
52+
statusCode = 500,
53+
durationMs = 100,
54+
)
55+
val event = AccessLogEvent(accessLog)
56+
57+
whenever(port1.save(accessLog)).thenThrow(RuntimeException("저장 실패"))
58+
59+
// when - 이벤트를 처리한다
60+
listener.handle(event)
61+
62+
// then - port1이 실패해도 port2에는 정상적으로 저장된다
63+
verify(port1).save(accessLog)
64+
verify(port2).save(accessLog)
65+
}
66+
}
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
package com.linktrip.application.domain.member
2+
3+
import com.linktrip.application.port.output.auth.TokenProvider
4+
import com.linktrip.application.port.output.persistence.MemberPort
5+
import org.junit.jupiter.api.Assertions.assertEquals
6+
import org.junit.jupiter.api.Assertions.assertFalse
7+
import org.junit.jupiter.api.Assertions.assertTrue
8+
import org.junit.jupiter.api.Test
9+
import org.junit.jupiter.api.extension.ExtendWith
10+
import org.mockito.InjectMocks
11+
import org.mockito.Mock
12+
import org.mockito.junit.jupiter.MockitoExtension
13+
import org.mockito.kotlin.any
14+
import org.mockito.kotlin.never
15+
import org.mockito.kotlin.verify
16+
import org.mockito.kotlin.whenever
17+
18+
@ExtendWith(MockitoExtension::class)
19+
class AuthServiceTest {
20+
@Mock
21+
lateinit var memberPort: MemberPort
22+
23+
@Mock
24+
lateinit var tokenProvider: TokenProvider
25+
26+
@InjectMocks
27+
lateinit var service: AuthService
28+
29+
@Test
30+
fun `이미 가입된 시리얼 번호로 인증하면_기존 회원 정보를 조회하고_새로 저장하지 않으며_isNewMember가 false이다`() {
31+
// given - 이미 가입된 시리얼 번호의 회원
32+
val existingMember = Member(id = "member-1", serialNumber = "serial-123")
33+
whenever(memberPort.findBySerialNumber("serial-123")).thenReturn(existingMember)
34+
whenever(tokenProvider.create("member-1")).thenReturn("token-abc")
35+
36+
// when - 기존 시리얼 번호로 인증한다
37+
val result = service.authenticateBySerial("serial-123")
38+
39+
// then - 기존 회원 정보를 반환하고, 새로 저장하지 않으며, isNewMember가 false이다
40+
assertEquals("member-1", result.memberId)
41+
assertEquals("token-abc", result.accessToken)
42+
assertFalse(result.isNewMember)
43+
verify(memberPort, never()).save(any())
44+
}
45+
46+
@Test
47+
fun `처음 보는 시리얼 번호로 인증하면_새 회원을 생성하여 저장하고_isNewMember가 true이다`() {
48+
// given - DB에 존재하지 않는 신규 시리얼 번호
49+
whenever(memberPort.findBySerialNumber("new-serial")).thenReturn(null)
50+
val savedMember = Member(id = "new-member-1", serialNumber = "new-serial")
51+
whenever(memberPort.save(any())).thenReturn(savedMember)
52+
whenever(tokenProvider.create("new-member-1")).thenReturn("new-token")
53+
54+
// when - 신규 시리얼 번호로 인증한다
55+
val result = service.authenticateBySerial("new-serial")
56+
57+
// then - 새 회원이 저장되고, isNewMember가 true이다
58+
assertEquals("new-member-1", result.memberId)
59+
assertEquals("new-token", result.accessToken)
60+
assertTrue(result.isNewMember)
61+
verify(memberPort).save(any())
62+
}
63+
64+
@Test
65+
fun `기존 회원이든 신규 회원이든_인증 시 항상 memberId 기반의 JWT 토큰이 생성된다`() {
66+
// given - 기존 회원
67+
val member = Member(id = "m1", serialNumber = "s1")
68+
whenever(memberPort.findBySerialNumber("s1")).thenReturn(member)
69+
whenever(tokenProvider.create("m1")).thenReturn("token")
70+
71+
// when - 인증을 수행한다
72+
service.authenticateBySerial("s1")
73+
74+
// then - memberId 기반으로 토큰이 생성된다
75+
verify(tokenProvider).create("m1")
76+
}
77+
}
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
package com.linktrip.application.domain.notification
2+
3+
import com.linktrip.application.port.output.notification.NotificationPort
4+
import org.junit.jupiter.api.Assertions.assertTrue
5+
import org.junit.jupiter.api.Test
6+
import org.junit.jupiter.api.extension.ExtendWith
7+
import org.mockito.junit.jupiter.MockitoExtension
8+
import org.mockito.kotlin.doAnswer
9+
import org.mockito.kotlin.mock
10+
import org.mockito.kotlin.never
11+
import org.mockito.kotlin.verify
12+
import org.mockito.kotlin.whenever
13+
14+
@ExtendWith(MockitoExtension::class)
15+
class ExceptionAlertEventListenerTest {
16+
@Test
17+
fun `예외 알림 이벤트가 발생하면_등록된 모든 NotificationPort에 알림을 전송한다`() {
18+
// given - 2개의 NotificationPort가 등록된 상태
19+
val port1 = mock<NotificationPort>()
20+
val port2 = mock<NotificationPort>()
21+
val listener = ExceptionAlertEventListener(listOf(port1, port2))
22+
23+
val event =
24+
ExceptionAlertEvent(
25+
message = "에러 발생",
26+
cause = "NullPointerException",
27+
statusCode = 500,
28+
stackTrace = "at com.linktrip...",
29+
)
30+
31+
// when - 이벤트를 처리한다
32+
listener.handle(event)
33+
34+
// then - 모든 port에 알림이 전송된다
35+
verify(port1).sendExceptionAlert(event)
36+
verify(port2).sendExceptionAlert(event)
37+
}
38+
39+
@Test
40+
fun `첫 번째 port에서 InterruptedException 발생 시_이후 port 호출을 중단하고_스레드 인터럽트 플래그를 복원한다`() {
41+
// given - port1에서 InterruptedException이 발생하는 상태
42+
val port1 = mock<NotificationPort>()
43+
val port2 = mock<NotificationPort>()
44+
val listener = ExceptionAlertEventListener(listOf(port1, port2))
45+
46+
val event =
47+
ExceptionAlertEvent(
48+
message = "에러",
49+
cause = null,
50+
statusCode = 500,
51+
stackTrace = null,
52+
)
53+
54+
doAnswer { throw InterruptedException("중단") }.whenever(port1).sendExceptionAlert(event)
55+
56+
// when - 이벤트를 처리한다
57+
listener.handle(event)
58+
59+
// then - port2는 호출되지 않는다 (return으로 즉시 중단)
60+
verify(port1).sendExceptionAlert(event)
61+
verify(port2, never()).sendExceptionAlert(event)
62+
63+
// then - 스레드 인터럽트 플래그가 복원되어 있다
64+
// 프로덕션 코드에서 Thread.currentThread().interrupt()를 제거하면 이 검증이 실패한다
65+
assertTrue(Thread.currentThread().isInterrupted) {
66+
"InterruptedException 발생 후 Thread.interrupt()로 플래그가 복원되어야 한다"
67+
}
68+
69+
// cleanup - 다른 테스트에 영향 주지 않도록 인터럽트 플래그 클리어
70+
Thread.interrupted()
71+
}
72+
73+
@Test
74+
fun `두 번째 port에서 RuntimeException 발생 시_해당 port만 건너뛰고_나머지 port는 정상적으로 알림을 전송한다`() {
75+
// given - port2에서 RuntimeException이 발생하는 상태
76+
val port1 = mock<NotificationPort>()
77+
val port2 = mock<NotificationPort>()
78+
val port3 = mock<NotificationPort>()
79+
val listener = ExceptionAlertEventListener(listOf(port1, port2, port3))
80+
81+
val event =
82+
ExceptionAlertEvent(
83+
message = "에러",
84+
cause = null,
85+
statusCode = 500,
86+
stackTrace = null,
87+
)
88+
89+
whenever(port2.sendExceptionAlert(event)).thenThrow(RuntimeException("전송 실패"))
90+
91+
// when - 이벤트를 처리한다
92+
listener.handle(event)
93+
94+
// then - port2만 실패하고, port1과 port3은 정상적으로 알림이 전송된다
95+
// RuntimeException은 catch 후 continue하므로 port3까지 도달한다
96+
verify(port1).sendExceptionAlert(event)
97+
verify(port2).sendExceptionAlert(event)
98+
verify(port3).sendExceptionAlert(event)
99+
}
100+
}

0 commit comments

Comments
 (0)