From d1a89f323aeb4a4ddd2e640d5aff6f582b57a941 Mon Sep 17 00:00:00 2001 From: YangHJ2415 Date: Mon, 13 Oct 2025 02:55:19 +0900 Subject: [PATCH 1/4] =?UTF-8?q?fix(be)=20:=20Test=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=ED=86=B5=EA=B3=BC=ED=95=98=EB=8F=84=EB=A1=9D=20=EC=88=98?= =?UTF-8?q?=EC=A0=95,=20Mock=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ai/tour/client/TourApiClientTest.kt | 163 ++++++++++++++---- 1 file changed, 129 insertions(+), 34 deletions(-) diff --git a/src/test/kotlin/com/back/koreaTravelGuide/domain/ai/tour/client/TourApiClientTest.kt b/src/test/kotlin/com/back/koreaTravelGuide/domain/ai/tour/client/TourApiClientTest.kt index 3566346..08ca04a 100644 --- a/src/test/kotlin/com/back/koreaTravelGuide/domain/ai/tour/client/TourApiClientTest.kt +++ b/src/test/kotlin/com/back/koreaTravelGuide/domain/ai/tour/client/TourApiClientTest.kt @@ -2,30 +2,39 @@ package com.back.koreaTravelGuide.domain.ai.tour.client import com.back.koreaTravelGuide.KoreaTravelGuideApplication import com.back.koreaTravelGuide.domain.ai.tour.dto.TourParams +import com.fasterxml.jackson.databind.ObjectMapper +import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Assumptions.assumeTrue +import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Test import org.springframework.beans.factory.annotation.Autowired -import org.springframework.beans.factory.annotation.Value import org.springframework.boot.test.context.SpringBootTest +import org.springframework.http.HttpMethod +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType import org.springframework.test.context.ActiveProfiles +import org.springframework.test.web.client.ExpectedCount +import org.springframework.test.web.client.MockRestServiceServer +import org.springframework.test.web.client.match.MockRestRequestMatchers.method +import org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo +import org.springframework.test.web.client.response.MockRestResponseCreators.withStatus +import org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess +import org.springframework.web.client.RestTemplate +import org.springframework.web.util.UriComponentsBuilder +import kotlin.test.assertEquals import kotlin.test.assertTrue -/** - * 실제 관광청 API 상태를 확인하기 위한 통합 테스트. - */ +// 실제 API 호출 기반 단위 테스트 @SpringBootTest(classes = [KoreaTravelGuideApplication::class]) @ActiveProfiles("test") -class TourApiClientTest { - @Autowired - private lateinit var tourApiClient: TourApiClient - - @Value("\${tour.api.key}") - private lateinit var serviceKey: String - - @DisplayName("fetchTourInfo - 실제 관광청 API 호출 (데이터 기대)") +class TourApiClientTest @Autowired constructor( + private val tourApiClient: TourApiClient, +) { + @DisplayName("fetchTourInfo - 실제 관광청 API가 빈 응답을 줄 경우") @Test - fun fetchTourInfoTest() { + fun fetchTourInfoRealCallEmptyResponse() { val params = TourParams( contentTypeId = "12", @@ -35,34 +44,120 @@ class TourApiClientTest { val result = tourApiClient.fetchTourInfo(params) - println("실제 API 응답 아이템 수: ${result.items.size}") - println("첫 번째 아이템: ${result.items.firstOrNull()}") + // isEmpty가 true인 경우 테스트를 진행, 아닐 경우 메세지 출력 + assumeTrue(result.items.isEmpty()) { + "관광청 API가 정상 데이터를 제공하고 있어 장애 시나리오 테스트를 건너뜁니다." + } - assertTrue(result.items.isNotEmpty(), "실제 API 호출 결과가 비어 있습니다. 장애 여부를 확인하세요.") + assertTrue(result.items.isEmpty()) } - @DisplayName("fetchTourInfo - 실제 관광청 API 장애 시 빈 결과 확인") - @Test - fun fetchTourInfoEmptyTest() { - val params = - TourParams( - contentTypeId = "12", - areaCode = "1", - sigunguCode = "1", - ) + // MockRestServiceServer 기반 단위 테스트 + @Nested + inner class MockServerTests { + private lateinit var restTemplate: RestTemplate + private lateinit var mockServer: MockRestServiceServer + private lateinit var mockClient: TourApiClient - val result = tourApiClient.fetchTourInfo(params) + private val serviceKey = "test-service-key" + private val baseUrl = "https://example.com" - println("실제 API 응답 아이템 수: ${result.items.size}") - println("첫 번째 아이템: ${result.items.firstOrNull()}") + @BeforeEach + fun setUp() { + restTemplate = RestTemplate() + mockServer = MockRestServiceServer.createServer(restTemplate) + mockClient = TourApiClient(restTemplate, ObjectMapper(), serviceKey, baseUrl) + } - // 장애가 아닐 경우, 테스트를 스킵 - assumeTrue(result.items.isEmpty()) { - "API가 정상 응답을 반환하고 있어 장애 시나리오 테스트를 건너뜁니다." + @AfterEach + fun tearDown() { + mockServer.verify() } - // 장애 상황일 시 - println("실제 API가 비어 있는 응답을 반환했습니다.") - assertTrue(result.items.isEmpty()) + @DisplayName("fetchTourInfo - 외부 API가 정상 응답을 반환하면 파싱된 결과를 제공") + @Test + fun fetchTourInfoReturnsParsedItems() { + val params = + TourParams( + contentTypeId = "12", + areaCode = "1", + sigunguCode = "1", + ) + + mockServer.expect(ExpectedCount.once(), requestTo(expectedAreaBasedListUrl(params))) + .andExpect(method(HttpMethod.GET)) + .andRespond(withSuccess(SUCCESS_RESPONSE, MediaType.APPLICATION_JSON)) + + val result = mockClient.fetchTourInfo(params) + + assertEquals(1, result.items.size) + val firstItem = result.items.first() + assertEquals("12345", firstItem.contentId) + assertEquals("테스트 타이틀", firstItem.title) + } + + @DisplayName("fetchTourInfo - 외부 API가 404를 반환하면 빈 결과를 전달") + @Test + fun fetchTourInfoReturnsEmptyListWhenApiFails() { + val params = + TourParams( + contentTypeId = "12", + areaCode = "1", + sigunguCode = "1", + ) + + mockServer.expect(ExpectedCount.once(), requestTo(expectedAreaBasedListUrl(params))) + .andExpect(method(HttpMethod.GET)) + .andRespond(withStatus(HttpStatus.NOT_FOUND)) + + val result = mockClient.fetchTourInfo(params) + + assertTrue(result.items.isEmpty()) + } + + private fun expectedAreaBasedListUrl(params: TourParams): String = + UriComponentsBuilder.fromUriString(baseUrl) + .path("/areaBasedList2") + .queryParam("serviceKey", serviceKey) + .queryParam("MobileOS", "WEB") + .queryParam("MobileApp", "KoreaTravelGuide") + .queryParam("_type", "json") + .queryParam("contentTypeId", params.contentTypeId) + .queryParam("areaCode", params.areaCode) + .queryParam("sigunguCode", params.sigunguCode) + .build() + .encode() + .toUriString() + + } + + companion object { + private val SUCCESS_RESPONSE = + """ + { + "response": { + "header": { + "resultCode": "0000", + "resultMsg": "OK" + }, + "body": { + "items": { + "item": [ + { + "contentid": "12345", + "contenttypeid": "12", + "createdtime": "202310010000", + "modifiedtime": "202310020000", + "title": "테스트 타이틀", + "addr1": "서울특별시 종로구", + "areacode": "1", + "firstimage": "https://example.com/image.jpg" + } + ] + } + } + } + } + """.trimIndent() } } From 693bf6c72e6ec43386bd61607ab2670d82032000 Mon Sep 17 00:00:00 2001 From: YangHJ2415 Date: Mon, 13 Oct 2025 03:21:22 +0900 Subject: [PATCH 2/4] =?UTF-8?q?feat(be)=20:=20service=EC=9D=98=20parsing?= =?UTF-8?q?=20=EA=B8=B0=EB=8A=A5=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ai/tour/client/TourApiClientTest.kt | 221 +++++++++--------- .../ai/tour/service/TourParamsParserTest.kt | 50 ++++ 2 files changed, 161 insertions(+), 110 deletions(-) create mode 100644 src/test/kotlin/com/back/koreaTravelGuide/domain/ai/tour/service/TourParamsParserTest.kt diff --git a/src/test/kotlin/com/back/koreaTravelGuide/domain/ai/tour/client/TourApiClientTest.kt b/src/test/kotlin/com/back/koreaTravelGuide/domain/ai/tour/client/TourApiClientTest.kt index 08ca04a..b1f7c87 100644 --- a/src/test/kotlin/com/back/koreaTravelGuide/domain/ai/tour/client/TourApiClientTest.kt +++ b/src/test/kotlin/com/back/koreaTravelGuide/domain/ai/tour/client/TourApiClientTest.kt @@ -29,54 +29,14 @@ import kotlin.test.assertTrue // 실제 API 호출 기반 단위 테스트 @SpringBootTest(classes = [KoreaTravelGuideApplication::class]) @ActiveProfiles("test") -class TourApiClientTest @Autowired constructor( - private val tourApiClient: TourApiClient, -) { - @DisplayName("fetchTourInfo - 실제 관광청 API가 빈 응답을 줄 경우") - @Test - fun fetchTourInfoRealCallEmptyResponse() { - val params = - TourParams( - contentTypeId = "12", - areaCode = "1", - sigunguCode = "1", - ) - - val result = tourApiClient.fetchTourInfo(params) - - // isEmpty가 true인 경우 테스트를 진행, 아닐 경우 메세지 출력 - assumeTrue(result.items.isEmpty()) { - "관광청 API가 정상 데이터를 제공하고 있어 장애 시나리오 테스트를 건너뜁니다." - } - - assertTrue(result.items.isEmpty()) - } - - // MockRestServiceServer 기반 단위 테스트 - @Nested - inner class MockServerTests { - private lateinit var restTemplate: RestTemplate - private lateinit var mockServer: MockRestServiceServer - private lateinit var mockClient: TourApiClient - - private val serviceKey = "test-service-key" - private val baseUrl = "https://example.com" - - @BeforeEach - fun setUp() { - restTemplate = RestTemplate() - mockServer = MockRestServiceServer.createServer(restTemplate) - mockClient = TourApiClient(restTemplate, ObjectMapper(), serviceKey, baseUrl) - } - - @AfterEach - fun tearDown() { - mockServer.verify() - } - - @DisplayName("fetchTourInfo - 외부 API가 정상 응답을 반환하면 파싱된 결과를 제공") +class TourApiClientTest + @Autowired + constructor( + private val tourApiClient: TourApiClient, + ) { + @DisplayName("fetchTourInfo - 실제 관광청 API가 빈 응답을 줄 경우") @Test - fun fetchTourInfoReturnsParsedItems() { + fun fetchTourInfoRealCallEmptyResponse() { val params = TourParams( contentTypeId = "12", @@ -84,80 +44,121 @@ class TourApiClientTest @Autowired constructor( sigunguCode = "1", ) - mockServer.expect(ExpectedCount.once(), requestTo(expectedAreaBasedListUrl(params))) - .andExpect(method(HttpMethod.GET)) - .andRespond(withSuccess(SUCCESS_RESPONSE, MediaType.APPLICATION_JSON)) + val result = tourApiClient.fetchTourInfo(params) - val result = mockClient.fetchTourInfo(params) + // isEmpty가 true인 경우 테스트를 진행, 아닐 경우 메세지 출력 + assumeTrue(result.items.isEmpty()) { + "관광청 API가 정상 데이터를 제공하고 있어 장애 시나리오 테스트를 건너뜁니다." + } - assertEquals(1, result.items.size) - val firstItem = result.items.first() - assertEquals("12345", firstItem.contentId) - assertEquals("테스트 타이틀", firstItem.title) + assertTrue(result.items.isEmpty()) } - @DisplayName("fetchTourInfo - 외부 API가 404를 반환하면 빈 결과를 전달") - @Test - fun fetchTourInfoReturnsEmptyListWhenApiFails() { - val params = - TourParams( - contentTypeId = "12", - areaCode = "1", - sigunguCode = "1", - ) + // MockRestServiceServer 기반 단위 테스트 + @Nested + inner class MockServerTests { + private lateinit var restTemplate: RestTemplate + private lateinit var mockServer: MockRestServiceServer + private lateinit var mockClient: TourApiClient + + private val serviceKey = "test-service-key" + private val baseUrl = "https://example.com" + + @BeforeEach + fun setUp() { + restTemplate = RestTemplate() + mockServer = MockRestServiceServer.createServer(restTemplate) + mockClient = TourApiClient(restTemplate, ObjectMapper(), serviceKey, baseUrl) + } - mockServer.expect(ExpectedCount.once(), requestTo(expectedAreaBasedListUrl(params))) - .andExpect(method(HttpMethod.GET)) - .andRespond(withStatus(HttpStatus.NOT_FOUND)) + @AfterEach + fun tearDown() { + mockServer.verify() + } - val result = mockClient.fetchTourInfo(params) + @DisplayName("fetchTourInfo - 외부 API가 정상 응답을 반환하면 파싱된 결과를 제공") + @Test + fun fetchTourInfoReturnsParsedItems() { + val params = + TourParams( + contentTypeId = "12", + areaCode = "1", + sigunguCode = "1", + ) + + mockServer.expect(ExpectedCount.once(), requestTo(expectedAreaBasedListUrl(params))) + .andExpect(method(HttpMethod.GET)) + .andRespond(withSuccess(SUCCESS_RESPONSE, MediaType.APPLICATION_JSON)) + + val result = mockClient.fetchTourInfo(params) + + assertEquals(1, result.items.size) + val firstItem = result.items.first() + assertEquals("12345", firstItem.contentId) + assertEquals("테스트 타이틀", firstItem.title) + } - assertTrue(result.items.isEmpty()) - } + @DisplayName("fetchTourInfo - 외부 API가 404를 반환하면 빈 결과를 전달") + @Test + fun fetchTourInfoReturnsEmptyListWhenApiFails() { + val params = + TourParams( + contentTypeId = "12", + areaCode = "1", + sigunguCode = "1", + ) - private fun expectedAreaBasedListUrl(params: TourParams): String = - UriComponentsBuilder.fromUriString(baseUrl) - .path("/areaBasedList2") - .queryParam("serviceKey", serviceKey) - .queryParam("MobileOS", "WEB") - .queryParam("MobileApp", "KoreaTravelGuide") - .queryParam("_type", "json") - .queryParam("contentTypeId", params.contentTypeId) - .queryParam("areaCode", params.areaCode) - .queryParam("sigunguCode", params.sigunguCode) - .build() - .encode() - .toUriString() + mockServer.expect(ExpectedCount.once(), requestTo(expectedAreaBasedListUrl(params))) + .andExpect(method(HttpMethod.GET)) + .andRespond(withStatus(HttpStatus.NOT_FOUND)) - } + val result = mockClient.fetchTourInfo(params) + + assertTrue(result.items.isEmpty()) + } - companion object { - private val SUCCESS_RESPONSE = - """ - { - "response": { - "header": { - "resultCode": "0000", - "resultMsg": "OK" - }, - "body": { - "items": { - "item": [ - { - "contentid": "12345", - "contenttypeid": "12", - "createdtime": "202310010000", - "modifiedtime": "202310020000", - "title": "테스트 타이틀", - "addr1": "서울특별시 종로구", - "areacode": "1", - "firstimage": "https://example.com/image.jpg" + private fun expectedAreaBasedListUrl(params: TourParams): String = + UriComponentsBuilder.fromUriString(baseUrl) + .path("/areaBasedList2") + .queryParam("serviceKey", serviceKey) + .queryParam("MobileOS", "WEB") + .queryParam("MobileApp", "KoreaTravelGuide") + .queryParam("_type", "json") + .queryParam("contentTypeId", params.contentTypeId) + .queryParam("areaCode", params.areaCode) + .queryParam("sigunguCode", params.sigunguCode) + .build() + .encode() + .toUriString() + } + + companion object { + private val SUCCESS_RESPONSE = + """ + { + "response": { + "header": { + "resultCode": "0000", + "resultMsg": "OK" + }, + "body": { + "items": { + "item": [ + { + "contentid": "12345", + "contenttypeid": "12", + "createdtime": "202310010000", + "modifiedtime": "202310020000", + "title": "테스트 타이틀", + "addr1": "서울특별시 종로구", + "areacode": "1", + "firstimage": "https://example.com/image.jpg" + } + ] } - ] + } } } - } - } - """.trimIndent() + """.trimIndent() + } } -} diff --git a/src/test/kotlin/com/back/koreaTravelGuide/domain/ai/tour/service/TourParamsParserTest.kt b/src/test/kotlin/com/back/koreaTravelGuide/domain/ai/tour/service/TourParamsParserTest.kt new file mode 100644 index 0000000..d000173 --- /dev/null +++ b/src/test/kotlin/com/back/koreaTravelGuide/domain/ai/tour/service/TourParamsParserTest.kt @@ -0,0 +1,50 @@ +package com.back.koreaTravelGuide.domain.ai.tour.service + +import kotlin.test.assertEquals +import kotlin.test.assertNull +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test + +class TourParamsParserTest { + private val parser = TourParamsParser() + + @DisplayName("parse - 공백이 섞인 입력을 정리해 DTO를 만든다") + @Test + fun parseTrimsTokens() { + val result = parser.parse("12", " 6 , 10 ") + + assertEquals("12", result.contentTypeId) + assertEquals("6", result.areaCode) + assertEquals("10", result.sigunguCode) + } + + @DisplayName("parse - 시군구 코드가 없으면 null 로 남긴다") + @Test + fun parseWhenSigunguMissing() { + val result = parser.parse("15", "7") + + assertEquals("15", result.contentTypeId) + assertEquals("7", result.areaCode) + assertNull(result.sigunguCode) + } + + @DisplayName("parse - 콤마가 여러 번 등장하면 빈 문자열을 허용한다") + @Test + fun parseWhenCommaRepeated() { + val result = parser.parse("32", "1,,2") + + assertEquals("32", result.contentTypeId) + assertEquals("1", result.areaCode) + assertEquals("", result.sigunguCode) + } + + @DisplayName("parse - 완전히 비어 있는 입력은 빈 문자열과 null 로 파싱된다") + @Test + fun parseWhenInputBlank() { + val result = parser.parse("25", "") + + assertEquals("25", result.contentTypeId) + assertEquals("", result.areaCode) + assertNull(result.sigunguCode) + } +} From 32cfb710b4a34ac31c54ddb66692a04726ad6211 Mon Sep 17 00:00:00 2001 From: YangHJ2415 Date: Mon, 13 Oct 2025 03:40:07 +0900 Subject: [PATCH 3/4] =?UTF-8?q?feat(be)=20:=20=EC=BA=90=EC=8B=9C=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20?= =?UTF-8?q?MockK=20=EC=9D=98=EC=A1=B4=EC=84=B1=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle.kts | 1 + .../core/TourAreaBasedServiceCoreCacheTest.kt | 81 +++++++++++++++++++ 2 files changed, 82 insertions(+) create mode 100644 src/test/kotlin/com/back/koreaTravelGuide/domain/ai/tour/service/core/TourAreaBasedServiceCoreCacheTest.kt diff --git a/build.gradle.kts b/build.gradle.kts index 6554833..6745ee5 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -74,6 +74,7 @@ dependencies { testImplementation("org.springframework.boot:spring-boot-starter-test") testImplementation("org.jetbrains.kotlin:kotlin-test-junit5") testImplementation("org.springframework.security:spring-security-test") + testImplementation("io.mockk:mockk:1.13.12") testRuntimeOnly("org.junit.platform:junit-platform-launcher") } diff --git a/src/test/kotlin/com/back/koreaTravelGuide/domain/ai/tour/service/core/TourAreaBasedServiceCoreCacheTest.kt b/src/test/kotlin/com/back/koreaTravelGuide/domain/ai/tour/service/core/TourAreaBasedServiceCoreCacheTest.kt new file mode 100644 index 0000000..cd5e7c7 --- /dev/null +++ b/src/test/kotlin/com/back/koreaTravelGuide/domain/ai/tour/service/core/TourAreaBasedServiceCoreCacheTest.kt @@ -0,0 +1,81 @@ +package com.back.koreaTravelGuide.domain.ai.tour.service.core + +import com.back.koreaTravelGuide.domain.ai.tour.client.TourApiClient +import com.back.koreaTravelGuide.domain.ai.tour.dto.TourItem +import com.back.koreaTravelGuide.domain.ai.tour.dto.TourParams +import com.back.koreaTravelGuide.domain.ai.tour.dto.TourResponse +import com.back.koreaTravelGuide.domain.ai.tour.service.usecase.TourAreaBasedUseCase +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.cache.CacheManager +import org.springframework.cache.annotation.EnableCaching +import org.springframework.cache.concurrent.ConcurrentMapCacheManager +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig +import kotlin.test.assertEquals + +@SpringJUnitConfig(TourAreaBasedServiceCoreCacheTest.Config::class) +class TourAreaBasedServiceCoreCacheTest { + @Autowired + private lateinit var service: TourAreaBasedUseCase + + @Autowired + private lateinit var tourApiClient: TourApiClient + + @DisplayName("fetchAreaBasedTours - 동일 파라미터 두 번 호출 시 API는 한 번만 호출된다") + @Test + fun cachesAreaBasedTours() { + val params = TourParams(contentTypeId = "15", areaCode = "3", sigunguCode = "5") + val apiResponse = + TourResponse( + items = + listOf( + TourItem( + contentId = "88888", + contentTypeId = "15", + createdTime = "202401010000", + modifiedTime = "202401020000", + title = "캐시 검증 관광지", + addr1 = "대전 어딘가", + areaCode = "3", + firstimage = null, + firstimage2 = null, + mapX = null, + mapY = null, + distance = null, + mlevel = null, + sigunguCode = "5", + lDongRegnCd = null, + lDongSignguCd = null, + ), + ), + ) + + every { tourApiClient.fetchTourInfo(params) } returns apiResponse + + val firstCall = service.fetchAreaBasedTours(params) + val secondCall = service.fetchAreaBasedTours(params) + + assertEquals(apiResponse, firstCall) + assertEquals(apiResponse, secondCall) + verify(exactly = 1) { tourApiClient.fetchTourInfo(params) } + } + + @Configuration + @EnableCaching + class Config { + @Bean + fun tourApiClient(): TourApiClient = mockk(relaxed = true) + + @Bean + fun cacheManager(): CacheManager = ConcurrentMapCacheManager("tourAreaBased", "tourLocationBased", "tourDetail") + + @Bean + fun tourAreaBasedServiceCore(tourApiClient: TourApiClient): TourAreaBasedServiceCore = TourAreaBasedServiceCore(tourApiClient) + } +} From 9edd3fa2903b755d5f4675e05c383f3e1760faa2 Mon Sep 17 00:00:00 2001 From: YangHJ2415 Date: Mon, 13 Oct 2025 09:39:21 +0900 Subject: [PATCH 4/4] =?UTF-8?q?refactor(be)=20:=20=EC=8B=A4=EC=A0=9C=20api?= =?UTF-8?q?=20=ED=98=B8=EC=B6=9C=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ai/tour/client/TourApiClientTest.kt | 203 ++++++++---------- 1 file changed, 88 insertions(+), 115 deletions(-) diff --git a/src/test/kotlin/com/back/koreaTravelGuide/domain/ai/tour/client/TourApiClientTest.kt b/src/test/kotlin/com/back/koreaTravelGuide/domain/ai/tour/client/TourApiClientTest.kt index b1f7c87..32cb8c9 100644 --- a/src/test/kotlin/com/back/koreaTravelGuide/domain/ai/tour/client/TourApiClientTest.kt +++ b/src/test/kotlin/com/back/koreaTravelGuide/domain/ai/tour/client/TourApiClientTest.kt @@ -4,12 +4,10 @@ import com.back.koreaTravelGuide.KoreaTravelGuideApplication import com.back.koreaTravelGuide.domain.ai.tour.dto.TourParams import com.fasterxml.jackson.databind.ObjectMapper import org.junit.jupiter.api.AfterEach -import org.junit.jupiter.api.Assumptions.assumeTrue import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Test -import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.context.SpringBootTest import org.springframework.http.HttpMethod import org.springframework.http.HttpStatus @@ -26,17 +24,34 @@ import org.springframework.web.util.UriComponentsBuilder import kotlin.test.assertEquals import kotlin.test.assertTrue -// 실제 API 호출 기반 단위 테스트 @SpringBootTest(classes = [KoreaTravelGuideApplication::class]) @ActiveProfiles("test") -class TourApiClientTest - @Autowired - constructor( - private val tourApiClient: TourApiClient, - ) { - @DisplayName("fetchTourInfo - 실제 관광청 API가 빈 응답을 줄 경우") +class TourApiClientTest { + // MockRestServiceServer 기반 단위 테스트 + @Nested + inner class MockServerTests { + private lateinit var restTemplate: RestTemplate + private lateinit var mockServer: MockRestServiceServer + private lateinit var mockClient: TourApiClient + + private val serviceKey = "test-service-key" + private val baseUrl = "https://example.com" + + @BeforeEach + fun setUp() { + restTemplate = RestTemplate() + mockServer = MockRestServiceServer.createServer(restTemplate) + mockClient = TourApiClient(restTemplate, ObjectMapper(), serviceKey, baseUrl) + } + + @AfterEach + fun tearDown() { + mockServer.verify() + } + + @DisplayName("fetchTourInfo - 외부 API가 정상 응답을 반환하면 파싱된 결과를 제공") @Test - fun fetchTourInfoRealCallEmptyResponse() { + fun fetchTourInfoReturnsParsedItems() { val params = TourParams( contentTypeId = "12", @@ -44,121 +59,79 @@ class TourApiClientTest sigunguCode = "1", ) - val result = tourApiClient.fetchTourInfo(params) + mockServer.expect(ExpectedCount.once(), requestTo(expectedAreaBasedListUrl(params))) + .andExpect(method(HttpMethod.GET)) + .andRespond(withSuccess(SUCCESS_RESPONSE, MediaType.APPLICATION_JSON)) - // isEmpty가 true인 경우 테스트를 진행, 아닐 경우 메세지 출력 - assumeTrue(result.items.isEmpty()) { - "관광청 API가 정상 데이터를 제공하고 있어 장애 시나리오 테스트를 건너뜁니다." - } + val result = mockClient.fetchTourInfo(params) - assertTrue(result.items.isEmpty()) + assertEquals(1, result.items.size) + val firstItem = result.items.first() + assertEquals("12345", firstItem.contentId) + assertEquals("테스트 타이틀", firstItem.title) } - // MockRestServiceServer 기반 단위 테스트 - @Nested - inner class MockServerTests { - private lateinit var restTemplate: RestTemplate - private lateinit var mockServer: MockRestServiceServer - private lateinit var mockClient: TourApiClient - - private val serviceKey = "test-service-key" - private val baseUrl = "https://example.com" - - @BeforeEach - fun setUp() { - restTemplate = RestTemplate() - mockServer = MockRestServiceServer.createServer(restTemplate) - mockClient = TourApiClient(restTemplate, ObjectMapper(), serviceKey, baseUrl) - } - - @AfterEach - fun tearDown() { - mockServer.verify() - } - - @DisplayName("fetchTourInfo - 외부 API가 정상 응답을 반환하면 파싱된 결과를 제공") - @Test - fun fetchTourInfoReturnsParsedItems() { - val params = - TourParams( - contentTypeId = "12", - areaCode = "1", - sigunguCode = "1", - ) - - mockServer.expect(ExpectedCount.once(), requestTo(expectedAreaBasedListUrl(params))) - .andExpect(method(HttpMethod.GET)) - .andRespond(withSuccess(SUCCESS_RESPONSE, MediaType.APPLICATION_JSON)) - - val result = mockClient.fetchTourInfo(params) - - assertEquals(1, result.items.size) - val firstItem = result.items.first() - assertEquals("12345", firstItem.contentId) - assertEquals("테스트 타이틀", firstItem.title) - } - - @DisplayName("fetchTourInfo - 외부 API가 404를 반환하면 빈 결과를 전달") - @Test - fun fetchTourInfoReturnsEmptyListWhenApiFails() { - val params = - TourParams( - contentTypeId = "12", - areaCode = "1", - sigunguCode = "1", - ) - - mockServer.expect(ExpectedCount.once(), requestTo(expectedAreaBasedListUrl(params))) - .andExpect(method(HttpMethod.GET)) - .andRespond(withStatus(HttpStatus.NOT_FOUND)) + @DisplayName("fetchTourInfo - 외부 API가 404를 반환하면 빈 결과를 전달") + @Test + fun fetchTourInfoReturnsEmptyListWhenApiFails() { + val params = + TourParams( + contentTypeId = "12", + areaCode = "1", + sigunguCode = "1", + ) - val result = mockClient.fetchTourInfo(params) + mockServer.expect(ExpectedCount.once(), requestTo(expectedAreaBasedListUrl(params))) + .andExpect(method(HttpMethod.GET)) + .andRespond(withStatus(HttpStatus.NOT_FOUND)) - assertTrue(result.items.isEmpty()) - } + val result = mockClient.fetchTourInfo(params) - private fun expectedAreaBasedListUrl(params: TourParams): String = - UriComponentsBuilder.fromUriString(baseUrl) - .path("/areaBasedList2") - .queryParam("serviceKey", serviceKey) - .queryParam("MobileOS", "WEB") - .queryParam("MobileApp", "KoreaTravelGuide") - .queryParam("_type", "json") - .queryParam("contentTypeId", params.contentTypeId) - .queryParam("areaCode", params.areaCode) - .queryParam("sigunguCode", params.sigunguCode) - .build() - .encode() - .toUriString() + assertTrue(result.items.isEmpty()) } - companion object { - private val SUCCESS_RESPONSE = - """ - { - "response": { - "header": { - "resultCode": "0000", - "resultMsg": "OK" - }, - "body": { - "items": { - "item": [ - { - "contentid": "12345", - "contenttypeid": "12", - "createdtime": "202310010000", - "modifiedtime": "202310020000", - "title": "테스트 타이틀", - "addr1": "서울특별시 종로구", - "areacode": "1", - "firstimage": "https://example.com/image.jpg" - } - ] + private fun expectedAreaBasedListUrl(params: TourParams): String = + UriComponentsBuilder.fromUriString(baseUrl) + .path("/areaBasedList2") + .queryParam("serviceKey", serviceKey) + .queryParam("MobileOS", "WEB") + .queryParam("MobileApp", "KoreaTravelGuide") + .queryParam("_type", "json") + .queryParam("contentTypeId", params.contentTypeId) + .queryParam("areaCode", params.areaCode) + .queryParam("sigunguCode", params.sigunguCode) + .build() + .encode() + .toUriString() + } + + companion object { + private val SUCCESS_RESPONSE = + """ + { + "response": { + "header": { + "resultCode": "0000", + "resultMsg": "OK" + }, + "body": { + "items": { + "item": [ + { + "contentid": "12345", + "contenttypeid": "12", + "createdtime": "202310010000", + "modifiedtime": "202310020000", + "title": "테스트 타이틀", + "addr1": "서울특별시 종로구", + "areacode": "1", + "firstimage": "https://example.com/image.jpg" } - } + ] } } - """.trimIndent() - } + } + } + """.trimIndent() } +}