From 8fa448b72666ce9fbdd36d04e80b6bd4c9a15c28 Mon Sep 17 00:00:00 2001 From: PeraSite Date: Sun, 15 Feb 2026 00:17:45 +0900 Subject: [PATCH 01/21] =?UTF-8?q?test:=20kotest=20mockk=20turbine=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=EA=B3=BC=20=EC=B4=88=EA=B8=B0=20ViewModel=20?= =?UTF-8?q?BDD=20=ED=85=8C=EC=8A=A4=ED=8A=B8=EB=A5=BC=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?=ED=96=88=EC=96=B4=EC=9A=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/build.gradle.kts | 14 +- .../menu/MenuViewModelBehaviorSpec.kt | 58 ++++ .../list/ReviewListViewModelBehaviorSpec.kt | 108 ++++++++ .../modify/ModifyViewModelBehaviorSpec.kt | 113 ++++++++ .../report/ReportViewModelBehaviorSpec.kt | 58 ++++ .../write/WriteReviewViewModelBehaviorSpec.kt | 254 ++++++++++++++++++ .../login/LoginViewModelBehaviorSpec.kt | 110 ++++++++ .../mypage/SignOutViewModelBehaviorSpec.kt | 66 +++++ .../eatssu/android/test/AppBehaviorSpec.kt | 11 + .../android/test/MainDispatcherListener.kt | 26 ++ .../com/eatssu/android/test/TestFixtures.kt | 114 ++++++++ .../com/eatssu/android/test/TestHelpers.kt | 17 ++ core/common/build.gradle.kts | 14 +- gradle/libs.versions.toml | 9 + 14 files changed, 970 insertions(+), 2 deletions(-) create mode 100644 app/src/test/java/com/eatssu/android/presentation/cafeteria/menu/MenuViewModelBehaviorSpec.kt create mode 100644 app/src/test/java/com/eatssu/android/presentation/cafeteria/review/list/ReviewListViewModelBehaviorSpec.kt create mode 100644 app/src/test/java/com/eatssu/android/presentation/cafeteria/review/modify/ModifyViewModelBehaviorSpec.kt create mode 100644 app/src/test/java/com/eatssu/android/presentation/cafeteria/review/report/ReportViewModelBehaviorSpec.kt create mode 100644 app/src/test/java/com/eatssu/android/presentation/cafeteria/review/write/WriteReviewViewModelBehaviorSpec.kt create mode 100644 app/src/test/java/com/eatssu/android/presentation/login/LoginViewModelBehaviorSpec.kt create mode 100644 app/src/test/java/com/eatssu/android/presentation/mypage/SignOutViewModelBehaviorSpec.kt create mode 100644 app/src/test/java/com/eatssu/android/test/AppBehaviorSpec.kt create mode 100644 app/src/test/java/com/eatssu/android/test/MainDispatcherListener.kt create mode 100644 app/src/test/java/com/eatssu/android/test/TestFixtures.kt create mode 100644 app/src/test/java/com/eatssu/android/test/TestHelpers.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 0036a99dd..9b13a66ce 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -134,6 +134,12 @@ android { lint { abortOnError = false } + + testOptions { + unitTests.all { + it.useJUnitPlatform() + } + } } dependencies { @@ -180,6 +186,12 @@ dependencies { // Testing libraries testImplementation(libs.junit) + testImplementation(libs.kotest.runner.junit5) + testImplementation(libs.kotest.assertions.core) + testImplementation(libs.kotest.property) + testImplementation(libs.mockk) + testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.turbine) androidTestImplementation(libs.androidx.test.ext.junit) androidTestImplementation(libs.androidx.espresso.core) @@ -269,4 +281,4 @@ dependencies { configurations.all { exclude(group = "io.github.fornewid", module = "naver-map-location") -} \ No newline at end of file +} diff --git a/app/src/test/java/com/eatssu/android/presentation/cafeteria/menu/MenuViewModelBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/presentation/cafeteria/menu/MenuViewModelBehaviorSpec.kt new file mode 100644 index 000000000..eb8e981f1 --- /dev/null +++ b/app/src/test/java/com/eatssu/android/presentation/cafeteria/menu/MenuViewModelBehaviorSpec.kt @@ -0,0 +1,58 @@ +package com.eatssu.android.presentation.cafeteria.menu + +import com.eatssu.android.domain.model.Menu +import com.eatssu.android.domain.usecase.menu.GetMenuListUseCase +import com.eatssu.android.test.AppBehaviorSpec +import com.eatssu.common.UiState +import com.eatssu.common.enums.Restaurant +import com.eatssu.common.enums.Time +import io.kotest.matchers.shouldBe +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest + +@OptIn(ExperimentalCoroutinesApi::class) +class MenuViewModelBehaviorSpec : AppBehaviorSpec({ + + given("메뉴 로드") { + val useCase = mockk() + + `when`("식당 목록이 비어있으면") { + val viewModel = MenuViewModel(useCase) + + then("빈 맵으로 성공 상태가 된다") { + runTest { + viewModel.loadMenus(emptyList(), "20250101", Time.LUNCH) + advanceUntilIdle() + + viewModel.uiState.value shouldBe UiState.Success(MenuState(emptyMap())) + coVerify(exactly = 0) { useCase(any(), any(), any()) } + } + } + } + + `when`("여러 식당에 대한 조회가 성공하면") { + val viewModel = MenuViewModel(useCase) + val r1 = Restaurant.FOOD_COURT + val r2 = Restaurant.HAKSIK + val m1 = listOf(Menu(id = 1, name = "A", price = 1000, rate = 4.0)) + val m2 = listOf(Menu(id = 2, name = "B", price = 2000, rate = 3.5)) + coEvery { useCase(r1, "20250101", Time.LUNCH) } returns m1 + coEvery { useCase(r2, "20250101", Time.LUNCH) } returns m2 + + then("식당별 메뉴 맵으로 성공 상태가 된다") { + runTest { + viewModel.loadMenus(listOf(r1, r2), "20250101", Time.LUNCH) + advanceUntilIdle() + + (viewModel.uiState.value is UiState.Success) shouldBe true + coVerify(exactly = 1) { useCase(r1, "20250101", Time.LUNCH) } + coVerify(exactly = 1) { useCase(r2, "20250101", Time.LUNCH) } + } + } + } + } +}) diff --git a/app/src/test/java/com/eatssu/android/presentation/cafeteria/review/list/ReviewListViewModelBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/presentation/cafeteria/review/list/ReviewListViewModelBehaviorSpec.kt new file mode 100644 index 000000000..64902abf2 --- /dev/null +++ b/app/src/test/java/com/eatssu/android/presentation/cafeteria/review/list/ReviewListViewModelBehaviorSpec.kt @@ -0,0 +1,108 @@ +package com.eatssu.android.presentation.cafeteria.review.list + +import androidx.paging.PagingData +import app.cash.turbine.test +import com.eatssu.android.R +import com.eatssu.android.domain.usecase.review.DeleteReviewUseCase +import com.eatssu.android.domain.usecase.review.GetReviewInfoUseCase +import com.eatssu.android.domain.usecase.review.GetReviewListPagedUseCase +import com.eatssu.android.test.AppBehaviorSpec +import com.eatssu.android.test.assertToast +import com.eatssu.android.test.awaitToastEvent +import com.eatssu.android.test.sampleReviewInfo +import com.eatssu.common.UiState +import com.eatssu.common.enums.MenuType +import com.eatssu.common.enums.ToastType +import io.kotest.matchers.shouldBe +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest + +@OptIn(ExperimentalCoroutinesApi::class) +class ReviewListViewModelBehaviorSpec : AppBehaviorSpec({ + + given("리뷰 목록 화면") { + val getReviewInfoUseCase = mockk() + val getReviewListPagedUseCase = mockk() + val deleteReviewUseCase = mockk() + + every { getReviewListPagedUseCase(any(), any()) } returns flowOf(PagingData.empty()) + + `when`("리뷰 정보를 정상 조회하면") { + val viewModel = ReviewListViewModel(getReviewInfoUseCase, getReviewListPagedUseCase, deleteReviewUseCase) + val info = sampleReviewInfo() + coEvery { getReviewInfoUseCase(MenuType.FIXED, 100L) } returns info + + then("Success 상태가 된다") { + runTest { + viewModel.getReview(MenuType.FIXED, 100L) + advanceUntilIdle() + + viewModel.uiState.value shouldBe UiState.Success(ReviewListState(info)) + } + } + } + + `when`("리뷰 정보 조회에서 예외가 발생하면") { + val viewModel = ReviewListViewModel(getReviewInfoUseCase, getReviewListPagedUseCase, deleteReviewUseCase) + coEvery { getReviewInfoUseCase(MenuType.VARIABLE, 101L) } throws IllegalStateException("boom") + + then("Error 상태와 실패 토스트를 보낸다") { + runTest { + viewModel.uiEvent.test { + viewModel.getReview(MenuType.VARIABLE, 101L) + advanceUntilIdle() + + viewModel.uiState.value shouldBe UiState.Error + awaitToastEvent().assertToast(R.string.toast_review_load_failed, ToastType.ERROR) + cancelAndIgnoreRemainingEvents() + } + } + } + } + + `when`("리뷰 삭제가 실패하면") { + val viewModel = ReviewListViewModel(getReviewInfoUseCase, getReviewListPagedUseCase, deleteReviewUseCase) + coEvery { deleteReviewUseCase(55L) } returns false + + then("실패 토스트를 보낸다") { + runTest { + viewModel.uiEvent.test { + viewModel.deleteReview(55L) + advanceUntilIdle() + + awaitToastEvent().assertToast(R.string.toast_review_delete_failed, ToastType.ERROR) + cancelAndIgnoreRemainingEvents() + } + } + } + } + + `when`("리뷰 삭제가 성공하면") { + val viewModel = ReviewListViewModel(getReviewInfoUseCase, getReviewListPagedUseCase, deleteReviewUseCase) + coEvery { getReviewInfoUseCase(MenuType.FIXED, 300L) } returns sampleReviewInfo(count = 3) + coEvery { deleteReviewUseCase(56L) } returns true + + then("ReviewDeleted 이벤트를 보내고 현재 파라미터로 정보를 다시 로드한다") { + runTest { + viewModel.getReview(MenuType.FIXED, 300L) + advanceUntilIdle() + + viewModel.uiEvent.test { + viewModel.deleteReview(56L) + advanceUntilIdle() + + awaitItem() shouldBe ReviewListEvent.ReviewDeleted + coVerify(atLeast = 2) { getReviewInfoUseCase(MenuType.FIXED, 300L) } + cancelAndIgnoreRemainingEvents() + } + } + } + } + } +}) diff --git a/app/src/test/java/com/eatssu/android/presentation/cafeteria/review/modify/ModifyViewModelBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/presentation/cafeteria/review/modify/ModifyViewModelBehaviorSpec.kt new file mode 100644 index 000000000..40ad9980a --- /dev/null +++ b/app/src/test/java/com/eatssu/android/presentation/cafeteria/review/modify/ModifyViewModelBehaviorSpec.kt @@ -0,0 +1,113 @@ +package com.eatssu.android.presentation.cafeteria.review.modify + +import app.cash.turbine.test +import com.eatssu.android.R +import com.eatssu.android.domain.model.Review +import com.eatssu.android.domain.usecase.review.ModifyReviewUseCase +import com.eatssu.android.test.AppBehaviorSpec +import com.eatssu.android.test.assertToast +import com.eatssu.android.test.awaitToastEvent +import com.eatssu.common.UiEvent +import com.eatssu.common.UiState +import com.eatssu.common.enums.ToastType +import io.kotest.matchers.shouldBe +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest + +@OptIn(ExperimentalCoroutinesApi::class) +class ModifyViewModelBehaviorSpec : AppBehaviorSpec({ + + val likes = listOf( + Review.MenuLikeInfo(menuId = 1L, name = "A", isLike = true), + Review.MenuLikeInfo(menuId = 2L, name = "B", isLike = false), + ) + + given("리뷰 수정 폼") { + val useCase = mockk() + + `when`("init을 호출하면") { + val viewModel = ModifyViewModel(useCase) + + then("Editing 상태와 baseline이 초기화된다") { + viewModel.init(4, "old", likes) + + viewModel.uiState.value shouldBe UiState.Success( + ModifyState.Editing( + rating = 4, + content = "old", + menuLikeInfos = likes, + baseline = ModifyState.Baseline(4, "old", likes), + ) + ) + } + } + + `when`("변경사항이 없거나 rating이 0이면 submit하면") { + val viewModel = ModifyViewModel(useCase) + viewModel.init(4, "old", likes) + + then("아무 요청도 보내지 않는다") { + runTest { + viewModel.submit(9L) + advanceUntilIdle() + coVerify(exactly = 0) { useCase(any(), any(), any(), any()) } + } + } + + then("rating을 0으로 바꿔도 요청하지 않는다") { + runTest { + viewModel.onRatingChanged(0) + viewModel.submit(9L) + advanceUntilIdle() + coVerify(exactly = 0) { useCase(any(), any(), any(), any()) } + } + } + } + + `when`("수정이 성공하면") { + val viewModel = ModifyViewModel(useCase) + viewModel.init(4, "old", likes) + viewModel.onContentChanged("new") + coEvery { useCase(10L, 4, "new", any()) } returns true + + then("뒤로가기와 성공 토스트를 보낸다") { + runTest { + viewModel.uiEvent.test { + viewModel.submit(10L) + advanceUntilIdle() + + awaitItem() shouldBe UiEvent.NavigateBack + awaitToastEvent().assertToast(R.string.toast_review_modify_success, ToastType.SUCCESS) + cancelAndIgnoreRemainingEvents() + } + } + } + } + + `when`("수정이 실패하면") { + val useCase2 = mockk() + val viewModel = ModifyViewModel(useCase2) + viewModel.init(4, "old", likes) + viewModel.onContentChanged("new") + coEvery { useCase2(11L, 4, "new", any()) } returns false + + then("현재 동작(characterization): 실패 토스트 후에도 뒤로가기+성공 토스트를 보낸다") { + runTest { + viewModel.uiEvent.test { + viewModel.submit(11L) + advanceUntilIdle() + + awaitToastEvent().assertToast(R.string.toast_review_modify_failed, ToastType.ERROR) + awaitItem() shouldBe UiEvent.NavigateBack + awaitToastEvent().assertToast(R.string.toast_review_modify_success, ToastType.SUCCESS) + cancelAndIgnoreRemainingEvents() + } + } + } + } + } +}) diff --git a/app/src/test/java/com/eatssu/android/presentation/cafeteria/review/report/ReportViewModelBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/presentation/cafeteria/review/report/ReportViewModelBehaviorSpec.kt new file mode 100644 index 000000000..1dfc4a2fc --- /dev/null +++ b/app/src/test/java/com/eatssu/android/presentation/cafeteria/review/report/ReportViewModelBehaviorSpec.kt @@ -0,0 +1,58 @@ +package com.eatssu.android.presentation.cafeteria.review.report + +import com.eatssu.android.R +import com.eatssu.android.data.remote.dto.request.ReportRequest +import com.eatssu.android.domain.usecase.review.PostReportUseCase +import com.eatssu.android.test.AppBehaviorSpec +import com.eatssu.android.test.asStringResIdOrNull +import io.kotest.matchers.shouldBe +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest + +@OptIn(ExperimentalCoroutinesApi::class) +class ReportViewModelBehaviorSpec : AppBehaviorSpec({ + + given("신고 전송") { + val postReportUseCase = mockk() + + `when`("신고가 실패하면") { + coEvery { postReportUseCase(any()) } returns false + val viewModel = ReportViewModel(postReportUseCase) + + then("error=true와 실패 토스트를 설정한다") { + runTest { + viewModel.postData(1L, "COPY", "bad") + advanceUntilIdle() + + viewModel.uiState.value.loading shouldBe false + viewModel.uiState.value.error shouldBe true + viewModel.uiState.value.toastMessage.asStringResIdOrNull() shouldBe R.string.toast_report_failed + viewModel.uiState.value.isDone shouldBe false + + coVerify { postReportUseCase(ReportRequest(1L, "COPY", "bad")) } + } + } + } + + `when`("신고가 성공하면") { + coEvery { postReportUseCase(any()) } returns true + val viewModel = ReportViewModel(postReportUseCase) + + then("isDone=true와 성공 토스트를 설정한다") { + runTest { + viewModel.postData(2L, "EXTRA", "spam") + advanceUntilIdle() + + viewModel.uiState.value.loading shouldBe false + viewModel.uiState.value.error shouldBe false + viewModel.uiState.value.toastMessage.asStringResIdOrNull() shouldBe R.string.toast_report_success + viewModel.uiState.value.isDone shouldBe true + } + } + } + } +}) diff --git a/app/src/test/java/com/eatssu/android/presentation/cafeteria/review/write/WriteReviewViewModelBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/presentation/cafeteria/review/write/WriteReviewViewModelBehaviorSpec.kt new file mode 100644 index 000000000..3680fcbdf --- /dev/null +++ b/app/src/test/java/com/eatssu/android/presentation/cafeteria/review/write/WriteReviewViewModelBehaviorSpec.kt @@ -0,0 +1,254 @@ +package com.eatssu.android.presentation.cafeteria.review.write + +import android.content.ContentResolver +import android.content.Context +import android.net.Uri +import app.cash.turbine.test +import com.eatssu.android.R +import com.eatssu.android.domain.model.MenuMini +import com.eatssu.android.domain.usecase.menu.GetValidMenusOfMealUseCase +import com.eatssu.android.domain.usecase.review.GetImageUrlUseCase +import com.eatssu.android.domain.usecase.review.WriteReviewUseCase +import com.eatssu.android.test.AppBehaviorSpec +import com.eatssu.android.test.assertToast +import com.eatssu.android.test.awaitToastEvent +import com.eatssu.common.UiEvent +import com.eatssu.common.UiState +import com.eatssu.common.enums.MenuType +import com.eatssu.common.enums.ToastType +import id.zelory.compressor.Compressor +import io.kotest.matchers.shouldBe +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import java.io.ByteArrayInputStream +import java.io.File + +@OptIn(ExperimentalCoroutinesApi::class) +class WriteReviewViewModelBehaviorSpec : AppBehaviorSpec({ + + given("리뷰 작성 화면") { + val writeReviewUseCase = mockk() + val getImageUrlUseCase = mockk() + val getValidMenusOfMealUseCase = mockk() + + `when`("고정 메뉴 타입을 로드하면") { + val viewModel = WriteReviewViewModel(writeReviewUseCase, getImageUrlUseCase, getValidMenusOfMealUseCase) + + then("단일 메뉴 Editing 상태를 만든다") { + runTest { + viewModel.loadMenuList(MenuType.FIXED, 1L, "돈가스") + advanceUntilIdle() + + viewModel.uiState.value shouldBe UiState.Success( + WriteReviewState.Editing( + menuList = listOf(MenuMini(1L, "돈가스")), + rating = 0, + content = "", + likedMenuIds = emptySet(), + selectedImageUri = null, + ) + ) + } + } + } + + `when`("가변 메뉴 타입을 로드하면") { + val viewModel = WriteReviewViewModel(writeReviewUseCase, getImageUrlUseCase, getValidMenusOfMealUseCase) + val menus = listOf(MenuMini(10L, "A"), MenuMini(11L, "B")) + coEvery { getValidMenusOfMealUseCase(999L) } returns menus + + then("usecase 결과로 Editing 상태를 만든다") { + runTest { + viewModel.loadMenuList(MenuType.VARIABLE, 999L, "") + advanceUntilIdle() + + (viewModel.uiState.value as UiState.Success).data shouldBe WriteReviewState.Editing( + menuList = menus, + rating = 0, + content = "", + likedMenuIds = emptySet(), + selectedImageUri = null, + ) + } + } + } + + `when`("rating이 0인 상태에서 submit하면") { + val viewModel = WriteReviewViewModel(writeReviewUseCase, getImageUrlUseCase, getValidMenusOfMealUseCase) + + then("요청하지 않는다") { + runTest { + viewModel.loadMenuList(MenuType.FIXED, 1L, "돈가스") + advanceUntilIdle() + + viewModel.postReview(MenuType.FIXED, 1L, mockk(relaxed = true)) + advanceUntilIdle() + + coVerify(exactly = 0) { + writeReviewUseCase(any(), any(), any(), any(), any(), any()) + } + } + } + } + + `when`("이미지 없이 리뷰 작성이 성공하면") { + val viewModel = WriteReviewViewModel(writeReviewUseCase, getImageUrlUseCase, getValidMenusOfMealUseCase) + coEvery { + writeReviewUseCase( + menuType = MenuType.FIXED, + itemId = 1L, + rating = 5, + content = "good", + imageUrl = null, + likeMenuIdList = any(), + ) + } returns true + + then("성공 토스트와 NavigateBack 이벤트를 보낸다") { + runTest { + viewModel.loadMenuList(MenuType.FIXED, 1L, "돈가스") + advanceUntilIdle() + viewModel.onRatingChanged(5) + viewModel.onContentChanged("good") + + viewModel.uiEvent.test { + viewModel.postReview(MenuType.FIXED, 1L, mockk(relaxed = true)) + advanceUntilIdle() + + awaitToastEvent().assertToast(R.string.toast_review_write_success, ToastType.SUCCESS) + awaitItem() shouldBe UiEvent.NavigateBack + cancelAndIgnoreRemainingEvents() + } + } + } + } + + `when`("이미지 업로드 성공 후 리뷰 작성이 성공하면") { + val viewModel = WriteReviewViewModel(writeReviewUseCase, getImageUrlUseCase, getValidMenusOfMealUseCase) + val context = mockk() + val resolver = mockk() + val uri = mockk() + val cacheDir = createTempDir(prefix = "write-review") + val compressed = File(cacheDir, "compressed.jpg").apply { writeBytes(byteArrayOf(1, 2, 3)) } + + every { context.contentResolver } returns resolver + every { context.cacheDir } returns cacheDir + every { resolver.openInputStream(uri) } returns ByteArrayInputStream(byteArrayOf(1, 2, 3)) + + mockkObject(Compressor) + coEvery { Compressor.compress(context, any()) } returns compressed + coEvery { getImageUrlUseCase(compressed) } returns "https://img" + coEvery { + writeReviewUseCase(MenuType.FIXED, 1L, 4, "", "https://img", any()) + } returns true + + then("이미지 업로드 성공 토스트 후 리뷰 성공 토스트와 뒤로가기를 보낸다") { + runTest { + viewModel.loadMenuList(MenuType.FIXED, 1L, "돈가스") + advanceUntilIdle() + viewModel.onRatingChanged(4) + viewModel.setSelectedImage(uri) + + viewModel.uiEvent.test { + viewModel.postReview(MenuType.FIXED, 1L, context) + advanceUntilIdle() + + awaitToastEvent().assertToast(R.string.toast_image_upload_success, ToastType.SUCCESS) + awaitToastEvent().assertToast(R.string.toast_review_write_success, ToastType.SUCCESS) + awaitItem() shouldBe UiEvent.NavigateBack + cancelAndIgnoreRemainingEvents() + } + } + } + } + + `when`("이미지 압축이 실패하면") { + val viewModel = WriteReviewViewModel(writeReviewUseCase, getImageUrlUseCase, getValidMenusOfMealUseCase) + val context = mockk() + val resolver = mockk() + val uri = mockk() + val cacheDir = createTempDir(prefix = "write-review-fail") + + every { context.contentResolver } returns resolver + every { context.cacheDir } returns cacheDir + every { resolver.openInputStream(uri) } returns ByteArrayInputStream(byteArrayOf(1, 2, 3)) + + mockkObject(Compressor) + coEvery { Compressor.compress(context, any()) } throws IllegalStateException("compress") + + then("기존 Editing 상태로 롤백하고 압축 실패 토스트를 보낸다") { + runTest { + viewModel.loadMenuList(MenuType.FIXED, 1L, "돈가스") + advanceUntilIdle() + viewModel.onRatingChanged(4) + viewModel.setSelectedImage(uri) + + viewModel.uiEvent.test { + viewModel.postReview(MenuType.FIXED, 1L, context) + advanceUntilIdle() + + (viewModel.uiState.value as UiState.Success).data::class shouldBe WriteReviewState.Editing::class + awaitToastEvent().assertToast(R.string.toast_image_compress_failed, ToastType.ERROR) + coVerify(exactly = 0) { writeReviewUseCase(any(), any(), any(), any(), any(), any()) } + cancelAndIgnoreRemainingEvents() + } + } + } + } + + `when`("이미지 업로드 과정에서 예외가 발생하면") { + val viewModel = WriteReviewViewModel(writeReviewUseCase, getImageUrlUseCase, getValidMenusOfMealUseCase) + val context = mockk() + val resolver = mockk() + val uri = mockk() + + every { context.contentResolver } returns resolver + every { resolver.openInputStream(uri) } returns null + + then("업로드 실패 토스트를 보낸다") { + runTest { + viewModel.loadMenuList(MenuType.FIXED, 1L, "돈가스") + advanceUntilIdle() + viewModel.onRatingChanged(4) + viewModel.setSelectedImage(uri) + + viewModel.uiEvent.test { + viewModel.postReview(MenuType.FIXED, 1L, context) + advanceUntilIdle() + + awaitToastEvent().assertToast(R.string.toast_image_upload_failed, ToastType.ERROR) + cancelAndIgnoreRemainingEvents() + } + } + } + } + + `when`("리뷰 작성 API가 실패하면") { + val viewModel = WriteReviewViewModel(writeReviewUseCase, getImageUrlUseCase, getValidMenusOfMealUseCase) + coEvery { writeReviewUseCase(MenuType.FIXED, 1L, 3, "", null, any()) } returns false + + then("Editing으로 롤백하고 실패 토스트를 보낸다") { + runTest { + viewModel.loadMenuList(MenuType.FIXED, 1L, "돈가스") + advanceUntilIdle() + viewModel.onRatingChanged(3) + + viewModel.uiEvent.test { + viewModel.postReview(MenuType.FIXED, 1L, mockk(relaxed = true)) + advanceUntilIdle() + + (viewModel.uiState.value as UiState.Success).data::class shouldBe WriteReviewState.Editing::class + awaitToastEvent().assertToast(R.string.toast_review_write_failed, ToastType.ERROR) + cancelAndIgnoreRemainingEvents() + } + } + } + } + } +}) diff --git a/app/src/test/java/com/eatssu/android/presentation/login/LoginViewModelBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/presentation/login/LoginViewModelBehaviorSpec.kt new file mode 100644 index 000000000..92afd1864 --- /dev/null +++ b/app/src/test/java/com/eatssu/android/presentation/login/LoginViewModelBehaviorSpec.kt @@ -0,0 +1,110 @@ +package com.eatssu.android.presentation.login + +import app.cash.turbine.test +import com.eatssu.android.R +import com.eatssu.android.domain.usecase.auth.LoginUseCase +import com.eatssu.android.domain.usecase.auth.SetAccessTokenUseCase +import com.eatssu.android.domain.usecase.auth.SetRefreshTokenUseCase +import com.eatssu.android.domain.usecase.user.SetUserEmailUseCase +import com.eatssu.android.test.AppBehaviorSpec +import com.eatssu.android.test.assertToast +import com.eatssu.android.test.awaitToastEvent +import com.eatssu.android.test.sampleToken +import com.eatssu.common.UiState +import com.eatssu.common.enums.DeviceType +import com.eatssu.common.enums.ToastType +import io.kotest.matchers.shouldBe +import io.mockk.Runs +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest + +@OptIn(ExperimentalCoroutinesApi::class) +class LoginViewModelBehaviorSpec : AppBehaviorSpec({ + + given("카카오 로그인") { + val loginUseCase = mockk() + val setAccessTokenUseCase = mockk() + val setRefreshTokenUseCase = mockk() + val setUserEmailUseCase = mockk() + + every { setAccessTokenUseCase(any()) } just Runs + every { setRefreshTokenUseCase(any()) } just Runs + coEvery { setUserEmailUseCase(any()) } just Runs + + `when`("토큰 발급이 실패하면") { + val viewModel = LoginViewModel( + loginUseCase = loginUseCase, + setAccessTokenUseCase = setAccessTokenUseCase, + setRefreshTokenUseCase = setRefreshTokenUseCase, + setUserEmailUseCase = setUserEmailUseCase, + ) + coEvery { loginUseCase("a@b.com", "pid", DeviceType.ANDROID) } returns null + + then("Error 상태와 실패 토스트 이벤트를 방출한다") { + runTest { + viewModel.uiEvent.test { + viewModel.getKakaoLogin("a@b.com", "pid") + advanceUntilIdle() + + viewModel.uiState.value shouldBe UiState.Error + awaitToastEvent().assertToast(R.string.toast_login_failed, ToastType.ERROR) + cancelAndIgnoreRemainingEvents() + } + } + } + } + + `when`("토큰 발급이 성공하면") { + val viewModel = LoginViewModel( + loginUseCase = loginUseCase, + setAccessTokenUseCase = setAccessTokenUseCase, + setRefreshTokenUseCase = setRefreshTokenUseCase, + setUserEmailUseCase = setUserEmailUseCase, + ) + val token = sampleToken("acc", "ref") + coEvery { loginUseCase("a@b.com", "pid", DeviceType.ANDROID) } returns token + + then("토큰과 이메일을 저장하고 성공 상태가 된다") { + runTest { + viewModel.getKakaoLogin("a@b.com", "pid") + advanceUntilIdle() + + verify { setAccessTokenUseCase("acc") } + verify { setRefreshTokenUseCase("ref") } + coVerify { setUserEmailUseCase("a@b.com") } + viewModel.uiState.value shouldBe UiState.Success(LoginState.LoginSuccess) + } + } + } + } + + given("상태 변경 함수") { + val viewModel = LoginViewModel( + loginUseCase = mockk(), + setAccessTokenUseCase = mockk(relaxed = true), + setRefreshTokenUseCase = mockk(relaxed = true), + setUserEmailUseCase = mockk(relaxed = true), + ) + + `when`("setLoadingState를 호출하면") { + then("Loading 상태가 된다") { + viewModel.setLoadingState() + viewModel.uiState.value shouldBe UiState.Loading + } + } + + `when`("setInitState를 호출하면") { + then("Init 상태가 된다") { + viewModel.setInitState() + viewModel.uiState.value shouldBe UiState.Init + } + } + } +}) diff --git a/app/src/test/java/com/eatssu/android/presentation/mypage/SignOutViewModelBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/presentation/mypage/SignOutViewModelBehaviorSpec.kt new file mode 100644 index 000000000..26004e4a0 --- /dev/null +++ b/app/src/test/java/com/eatssu/android/presentation/mypage/SignOutViewModelBehaviorSpec.kt @@ -0,0 +1,66 @@ +package com.eatssu.android.presentation.mypage + +import app.cash.turbine.test +import com.eatssu.android.R +import com.eatssu.android.domain.usecase.auth.LogoutUseCase +import com.eatssu.android.domain.usecase.auth.SignOutUseCase +import com.eatssu.android.test.AppBehaviorSpec +import com.eatssu.android.test.assertToast +import com.eatssu.android.test.awaitToastEvent +import com.eatssu.common.UiState +import com.eatssu.common.enums.ToastType +import io.kotest.matchers.shouldBe +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest + +@OptIn(ExperimentalCoroutinesApi::class) +class SignOutViewModelBehaviorSpec : AppBehaviorSpec({ + + given("회원탈퇴") { + val logoutUseCase = mockk() + val signOutUseCase = mockk() + + `when`("회원탈퇴 API가 실패하면") { + coEvery { signOutUseCase() } returns false + val viewModel = SignOutViewModel(logoutUseCase, signOutUseCase) + + then("Error 상태와 실패 토스트를 보낸다") { + runTest { + viewModel.uiEvent.test { + viewModel.signOut() + advanceUntilIdle() + + viewModel.uiState.value shouldBe UiState.Error + awaitToastEvent().assertToast(R.string.toast_sign_out_fail, ToastType.ERROR) + coVerify(exactly = 0) { logoutUseCase() } + cancelAndIgnoreRemainingEvents() + } + } + } + } + + `when`("회원탈퇴 API가 성공하면") { + coEvery { signOutUseCase() } returns true + coEvery { logoutUseCase() } returns Unit + val viewModel = SignOutViewModel(logoutUseCase, signOutUseCase) + + then("성공 상태와 성공 토스트를 보낸 후 로그아웃을 수행한다") { + runTest { + viewModel.uiEvent.test { + viewModel.signOut() + advanceUntilIdle() + + viewModel.uiState.value shouldBe UiState.Success(SignOutState(isSignOuted = true)) + awaitToastEvent().assertToast(R.string.toast_sign_out_success, ToastType.SUCCESS) + coVerify(exactly = 1) { logoutUseCase() } + cancelAndIgnoreRemainingEvents() + } + } + } + } + } +}) diff --git a/app/src/test/java/com/eatssu/android/test/AppBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/test/AppBehaviorSpec.kt new file mode 100644 index 000000000..69adc2357 --- /dev/null +++ b/app/src/test/java/com/eatssu/android/test/AppBehaviorSpec.kt @@ -0,0 +1,11 @@ +package com.eatssu.android.test + +import io.kotest.core.spec.style.BehaviorSpec + +abstract class AppBehaviorSpec( + body: BehaviorSpec.() -> Unit, +) : BehaviorSpec(body) { + init { + listener(MainDispatcherListener()) + } +} diff --git a/app/src/test/java/com/eatssu/android/test/MainDispatcherListener.kt b/app/src/test/java/com/eatssu/android/test/MainDispatcherListener.kt new file mode 100644 index 000000000..75af3aaa0 --- /dev/null +++ b/app/src/test/java/com/eatssu/android/test/MainDispatcherListener.kt @@ -0,0 +1,26 @@ +package com.eatssu.android.test + +import io.kotest.core.listeners.TestListener +import io.kotest.core.test.TestCase +import io.kotest.core.test.TestResult +import io.mockk.unmockkAll +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestDispatcher +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain + +@OptIn(ExperimentalCoroutinesApi::class) +class MainDispatcherListener( + val dispatcher: TestDispatcher = UnconfinedTestDispatcher(), +) : TestListener { + override suspend fun beforeTest(testCase: TestCase) { + Dispatchers.setMain(dispatcher) + } + + override suspend fun afterTest(testCase: TestCase, result: TestResult) { + unmockkAll() + Dispatchers.resetMain() + } +} diff --git a/app/src/test/java/com/eatssu/android/test/TestFixtures.kt b/app/src/test/java/com/eatssu/android/test/TestFixtures.kt new file mode 100644 index 000000000..76b5440f5 --- /dev/null +++ b/app/src/test/java/com/eatssu/android/test/TestFixtures.kt @@ -0,0 +1,114 @@ +package com.eatssu.android.test + +import com.eatssu.android.domain.model.College +import com.eatssu.android.domain.model.Department +import com.eatssu.android.domain.model.Partnership +import com.eatssu.android.domain.model.PartnershipRestaurant +import com.eatssu.android.domain.model.RestaurantType +import com.eatssu.android.domain.model.Review +import com.eatssu.android.domain.model.ReviewInfo +import com.eatssu.android.domain.model.Token +import com.eatssu.android.domain.model.UserInfo + +fun sampleCollege( + id: Int = 1, + name: String = "IT대학", +) = College(id, name) + +fun sampleDepartment( + id: Int = 11, + name: String = "컴퓨터학부", +) = Department(id, name) + +fun sampleUserInfo( + nickname: String = "eatssu", + college: College = sampleCollege(), + department: Department = sampleDepartment(), +) = UserInfo( + nickname = nickname, + userDepartment = department, + userCollege = college, +) + +fun sampleToken( + access: String = "access-token", + refresh: String = "refresh-token", +) = Token( + accessToken = access, + refreshToken = refresh, +) + +fun sampleReview( + id: Long = 1L, + rating: Int = 5, + content: String = "good", + writerNickname: String = "writer", + isWriter: Boolean = true, +) = Review( + isWriter = isWriter, + reviewId = id, + menuLikeInfoList = listOf( + Review.MenuLikeInfo(menuId = 101L, name = "A", isLike = true), + ), + writerNickname = writerNickname, + rating = rating, + writeDate = "2025-01-01", + content = content, + imgUrl = null, +) + +fun sampleReviewInfo( + count: Int = 10, + rating: Double = 4.2, +) = ReviewInfo( + reviewCnt = count, + rating = rating, + oneStarCount = 1, + twoStarCount = 2, + threeStarCount = 3, + fourStarCount = 2, + fiveStarCount = 2, +) + +fun samplePartnership( + storeName: String = "Cafe A", + infos: List = listOf( + Partnership.PartnershipInfo( + id = 1, + partnershipType = "DISCOUNT", + collegeName = "IT", + departmentName = "CS", + likeCount = 3, + isLiked = true, + description = "desc", + startDate = "2025-01-01", + endDate = "2025-12-31", + ), + ), + type: RestaurantType = RestaurantType.CAFE, +) = Partnership( + storeName = storeName, + longitude = 127.0, + latitude = 37.0, + restaurantType = type, + partnershipInfos = infos, +) + +fun samplePartnershipRestaurant( + id: Int = 1, + type: RestaurantType = RestaurantType.CAFE, +) = PartnershipRestaurant( + id = id, + partnershipType = "DISCOUNT", + storeName = "Cafe A", + description = "desc", + startDate = "2025-01-01", + endDate = "2025-12-31", + restaurantType = type, + longitude = 127.0, + latitude = 37.0, + collegeName = "IT", + departmentName = "CS", + partnershipLikeCount = 3, + likedByUser = true, +) diff --git a/app/src/test/java/com/eatssu/android/test/TestHelpers.kt b/app/src/test/java/com/eatssu/android/test/TestHelpers.kt new file mode 100644 index 000000000..920775fa9 --- /dev/null +++ b/app/src/test/java/com/eatssu/android/test/TestHelpers.kt @@ -0,0 +1,17 @@ +package com.eatssu.android.test + +import app.cash.turbine.ReceiveTurbine +import com.eatssu.common.UiEvent +import com.eatssu.common.UiText +import io.kotest.matchers.shouldBe + +fun UiText?.asStringResIdOrNull(): Int? = (this as? UiText.StringResource)?.resId + +suspend fun ReceiveTurbine.awaitToastEvent(): UiEvent.ShowToast { + return awaitItem() as UiEvent.ShowToast +} + +fun UiEvent.ShowToast.assertToast(resId: Int, type: com.eatssu.common.enums.ToastType) { + message.asStringResIdOrNull() shouldBe resId + this.type shouldBe type +} diff --git a/core/common/build.gradle.kts b/core/common/build.gradle.kts index 537fef772..5dc6d5fe9 100644 --- a/core/common/build.gradle.kts +++ b/core/common/build.gradle.kts @@ -31,6 +31,12 @@ android { kotlinOptions { jvmTarget = "11" } + + testOptions { + unitTests.all { + it.useJUnitPlatform() + } + } } dependencies { @@ -38,10 +44,16 @@ dependencies { implementation(libs.androidx.core.ktx) implementation(libs.androidx.appcompat) testImplementation(libs.junit) + testImplementation(libs.kotest.runner.junit5) + testImplementation(libs.kotest.assertions.core) + testImplementation(libs.kotest.property) + testImplementation(libs.mockk) + testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.turbine) androidTestImplementation(libs.androidx.test.ext.junit) androidTestImplementation(libs.androidx.espresso.core) // Firebase implementation(platform(libs.firebase.bom)) implementation(libs.firebase.analytics) -} \ No newline at end of file +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8077dd9bf..57a535df4 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -62,6 +62,9 @@ glanceAppwidgetPreview = "1.1.1" glancePreview = "1.1.1" posthog = "3.+" paging = "3.3.6" +kotest = "5.9.1" +mockk = "1.13.17" +turbine = "1.2.0" [libraries] @@ -149,6 +152,12 @@ threetenabp = { group = "com.jakewharton.threetenabp", name = "threetenabp", ver material-calendarview = { group = "com.prolificinteractive", name = "material-calendarview", version.ref = "material-calendarview" } transport-runtime = { group = "com.google.android.datatransport", name = "transport-runtime", version.ref = "transport-runtime" } junit = { group = "junit", name = "junit", version.ref = "junit" } +kotest-runner-junit5 = { group = "io.kotest", name = "kotest-runner-junit5", version.ref = "kotest" } +kotest-assertions-core = { group = "io.kotest", name = "kotest-assertions-core", version.ref = "kotest" } +kotest-property = { group = "io.kotest", name = "kotest-property", version.ref = "kotest" } +mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" } +kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutines" } +turbine = { group = "app.cash.turbine", name = "turbine", version.ref = "turbine" } compressor = { group = "id.zelory", name = "compressor", version.ref = "compressor" } kakao-login = { group = "com.kakao.sdk", name = "v2-user", version.ref = "kakao-login" } kakao-talk = { group = "com.kakao.sdk", name = "v2-talk", version.ref = "kakao-talk" } From a9d92cba06ad641ffb46819a0741e98d319761c4 Mon Sep 17 00:00:00 2001 From: PeraSite Date: Sun, 15 Feb 2026 00:26:36 +0900 Subject: [PATCH 02/21] =?UTF-8?q?test:=20info/mypage=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=EB=A5=BC=20=EB=B3=B4=EA=B0=95=ED=95=98=EA=B3=A0=20?= =?UTF-8?q?=EB=B9=84=EB=8F=99=EA=B8=B0=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=9D=B8=ED=94=84=EB=9D=BC=EB=A5=BC=20=EC=95=88=EC=A0=95?= =?UTF-8?q?=ED=99=94=ED=96=88=EC=96=B4=EC=9A=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../info/InfoViewModelBehaviorSpec.kt | 48 ++++++ .../write/WriteReviewViewModelBehaviorSpec.kt | 11 +- .../login/LoginViewModelBehaviorSpec.kt | 19 ++- .../mypage/MyPageViewModelBehaviorSpec.kt | 150 ++++++++++++++++++ .../LanguageSelectorViewModelBehaviorSpec.kt | 60 +++++++ .../myreview/MyReviewViewModelBehaviorSpec.kt | 107 +++++++++++++ .../android/test/MainDispatcherListener.kt | 8 +- 7 files changed, 393 insertions(+), 10 deletions(-) create mode 100644 app/src/test/java/com/eatssu/android/presentation/cafeteria/info/InfoViewModelBehaviorSpec.kt create mode 100644 app/src/test/java/com/eatssu/android/presentation/mypage/MyPageViewModelBehaviorSpec.kt create mode 100644 app/src/test/java/com/eatssu/android/presentation/mypage/language/LanguageSelectorViewModelBehaviorSpec.kt create mode 100644 app/src/test/java/com/eatssu/android/presentation/mypage/myreview/MyReviewViewModelBehaviorSpec.kt diff --git a/app/src/test/java/com/eatssu/android/presentation/cafeteria/info/InfoViewModelBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/presentation/cafeteria/info/InfoViewModelBehaviorSpec.kt new file mode 100644 index 000000000..308c98cb6 --- /dev/null +++ b/app/src/test/java/com/eatssu/android/presentation/cafeteria/info/InfoViewModelBehaviorSpec.kt @@ -0,0 +1,48 @@ +package com.eatssu.android.presentation.cafeteria.info + +import com.eatssu.android.domain.model.RestaurantInfo +import com.eatssu.android.domain.repository.FirebaseRemoteConfigRepository +import com.eatssu.android.test.AppBehaviorSpec +import com.eatssu.common.enums.Restaurant +import io.kotest.matchers.nulls.shouldBeNull +import io.kotest.matchers.shouldBe +import io.mockk.coEvery +import io.mockk.mockk +import kotlinx.coroutines.test.runTest + +class InfoViewModelBehaviorSpec : AppBehaviorSpec({ + + given("식당 정보 조회") { + val repo = mockk() + + `when`("원격 설정 조회가 성공하면") { + val info = RestaurantInfo( + enum = Restaurant.HAKSIK, + name = "학식", + location = "1층", + image = "img", + time = "09:00", + etc = "etc", + ) + coEvery { repo.getRestaurantInfo(Restaurant.HAKSIK) } returns info + val viewModel = InfoViewModel(repo) + + then("식당 정보를 반환한다") { + runTest { + viewModel.getRestaurantInfo(Restaurant.HAKSIK) shouldBe info + } + } + } + + `when`("원격 설정 조회 중 예외가 발생하면") { + coEvery { repo.getRestaurantInfo(Restaurant.HAKSIK) } throws IllegalStateException("boom") + val viewModel = InfoViewModel(repo) + + then("null을 반환한다") { + runTest { + viewModel.getRestaurantInfo(Restaurant.HAKSIK).shouldBeNull() + } + } + } + } +}) diff --git a/app/src/test/java/com/eatssu/android/presentation/cafeteria/review/write/WriteReviewViewModelBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/presentation/cafeteria/review/write/WriteReviewViewModelBehaviorSpec.kt index 3680fcbdf..cff1f609a 100644 --- a/app/src/test/java/com/eatssu/android/presentation/cafeteria/review/write/WriteReviewViewModelBehaviorSpec.kt +++ b/app/src/test/java/com/eatssu/android/presentation/cafeteria/review/write/WriteReviewViewModelBehaviorSpec.kt @@ -12,15 +12,19 @@ import com.eatssu.android.domain.usecase.review.WriteReviewUseCase import com.eatssu.android.test.AppBehaviorSpec import com.eatssu.android.test.assertToast import com.eatssu.android.test.awaitToastEvent +import com.eatssu.common.EventLogger import com.eatssu.common.UiEvent import com.eatssu.common.UiState import com.eatssu.common.enums.MenuType import com.eatssu.common.enums.ToastType import id.zelory.compressor.Compressor import io.kotest.matchers.shouldBe +import io.mockk.Runs import io.mockk.coEvery import io.mockk.coVerify +import io.mockk.clearMocks import io.mockk.every +import io.mockk.just import io.mockk.mockk import io.mockk.mockkObject import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -109,6 +113,8 @@ class WriteReviewViewModelBehaviorSpec : AppBehaviorSpec({ likeMenuIdList = any(), ) } returns true + mockkObject(EventLogger) + every { EventLogger.completeReview(any(), any(), any()) } just Runs then("성공 토스트와 NavigateBack 이벤트를 보낸다") { runTest { @@ -147,6 +153,8 @@ class WriteReviewViewModelBehaviorSpec : AppBehaviorSpec({ coEvery { writeReviewUseCase(MenuType.FIXED, 1L, 4, "", "https://img", any()) } returns true + mockkObject(EventLogger) + every { EventLogger.completeReview(any(), any(), any()) } just Runs then("이미지 업로드 성공 토스트 후 리뷰 성공 토스트와 뒤로가기를 보낸다") { runTest { @@ -154,6 +162,7 @@ class WriteReviewViewModelBehaviorSpec : AppBehaviorSpec({ advanceUntilIdle() viewModel.onRatingChanged(4) viewModel.setSelectedImage(uri) + clearMocks(writeReviewUseCase, answers = false, recordedCalls = true) viewModel.uiEvent.test { viewModel.postReview(MenuType.FIXED, 1L, context) @@ -193,8 +202,8 @@ class WriteReviewViewModelBehaviorSpec : AppBehaviorSpec({ viewModel.postReview(MenuType.FIXED, 1L, context) advanceUntilIdle() - (viewModel.uiState.value as UiState.Success).data::class shouldBe WriteReviewState.Editing::class awaitToastEvent().assertToast(R.string.toast_image_compress_failed, ToastType.ERROR) + (viewModel.uiState.value as UiState.Success).data::class shouldBe WriteReviewState.Editing::class coVerify(exactly = 0) { writeReviewUseCase(any(), any(), any(), any(), any(), any()) } cancelAndIgnoreRemainingEvents() } diff --git a/app/src/test/java/com/eatssu/android/presentation/login/LoginViewModelBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/presentation/login/LoginViewModelBehaviorSpec.kt index 92afd1864..4707d436e 100644 --- a/app/src/test/java/com/eatssu/android/presentation/login/LoginViewModelBehaviorSpec.kt +++ b/app/src/test/java/com/eatssu/android/presentation/login/LoginViewModelBehaviorSpec.kt @@ -13,6 +13,7 @@ import com.eatssu.android.test.sampleToken import com.eatssu.common.UiState import com.eatssu.common.enums.DeviceType import com.eatssu.common.enums.ToastType +import io.kotest.assertions.nondeterministic.eventually import io.kotest.matchers.shouldBe import io.mockk.Runs import io.mockk.coEvery @@ -24,6 +25,7 @@ import io.mockk.verify import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest +import kotlin.time.Duration.Companion.seconds @OptIn(ExperimentalCoroutinesApi::class) class LoginViewModelBehaviorSpec : AppBehaviorSpec({ @@ -51,10 +53,10 @@ class LoginViewModelBehaviorSpec : AppBehaviorSpec({ runTest { viewModel.uiEvent.test { viewModel.getKakaoLogin("a@b.com", "pid") - advanceUntilIdle() - - viewModel.uiState.value shouldBe UiState.Error awaitToastEvent().assertToast(R.string.toast_login_failed, ToastType.ERROR) + eventually(2.seconds) { + viewModel.uiState.value shouldBe UiState.Error + } cancelAndIgnoreRemainingEvents() } } @@ -74,12 +76,13 @@ class LoginViewModelBehaviorSpec : AppBehaviorSpec({ then("토큰과 이메일을 저장하고 성공 상태가 된다") { runTest { viewModel.getKakaoLogin("a@b.com", "pid") - advanceUntilIdle() - verify { setAccessTokenUseCase("acc") } - verify { setRefreshTokenUseCase("ref") } - coVerify { setUserEmailUseCase("a@b.com") } - viewModel.uiState.value shouldBe UiState.Success(LoginState.LoginSuccess) + eventually(2.seconds) { + verify { setAccessTokenUseCase("acc") } + verify { setRefreshTokenUseCase("ref") } + coVerify { setUserEmailUseCase("a@b.com") } + viewModel.uiState.value shouldBe UiState.Success(LoginState.LoginSuccess) + } } } } diff --git a/app/src/test/java/com/eatssu/android/presentation/mypage/MyPageViewModelBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/presentation/mypage/MyPageViewModelBehaviorSpec.kt new file mode 100644 index 000000000..d2ebca254 --- /dev/null +++ b/app/src/test/java/com/eatssu/android/presentation/mypage/MyPageViewModelBehaviorSpec.kt @@ -0,0 +1,150 @@ +package com.eatssu.android.presentation.mypage + +import app.cash.turbine.test +import com.eatssu.android.R +import com.eatssu.android.data.local.SettingDataStore +import com.eatssu.android.domain.usecase.alarm.AlarmUseCase +import com.eatssu.android.domain.usecase.alarm.SetDailyNotificationStatusUseCase +import com.eatssu.android.domain.usecase.user.GetUserNickNameUseCase +import com.eatssu.android.test.AppBehaviorSpec +import com.eatssu.android.test.assertToast +import com.eatssu.android.test.awaitToastEvent +import com.eatssu.common.UiState +import com.eatssu.common.enums.ToastType +import io.kotest.matchers.shouldBe +import io.mockk.Runs +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest + +@OptIn(ExperimentalCoroutinesApi::class) +class MyPageViewModelBehaviorSpec : AppBehaviorSpec({ + + given("마이페이지") { + val getUserNickNameUseCase = mockk() + val setDailyNotificationStatusUseCase = mockk() + val alarmUseCase = mockk() + val settingDataStore = mockk() + + every { alarmUseCase.scheduleAlarm() } just Runs + every { alarmUseCase.cancelAlarm() } just Runs + coEvery { setDailyNotificationStatusUseCase(any()) } returns Unit + + `when`("닉네임이 비어 있으면") { + val dailyStatus = MutableStateFlow(false) + every { settingDataStore.dailyNotificationStatus } returns dailyStatus + coEvery { getUserNickNameUseCase() } returns "" + + val viewModel = MyPageViewModel( + getUserNickNameUseCase, + setDailyNotificationStatusUseCase, + alarmUseCase, + settingDataStore, + ) + + then("닉네임을 null로 두고 안내 토스트를 보낸다") { + runTest { + viewModel.uiState.test { + val stateTurbine = this + viewModel.uiEvent.test { + viewModel.fetchMyInfo() + val first = stateTurbine.awaitItem() + val state = when (first) { + is UiState.Success<*> -> first as UiState.Success + else -> stateTurbine.awaitItem() as UiState.Success + } + + state.data.nickname shouldBe null + awaitToastEvent().assertToast(R.string.toast_require_nickname, ToastType.INFO) + cancelAndIgnoreRemainingEvents() + } + cancelAndIgnoreRemainingEvents() + } + } + } + } + + `when`("닉네임이 존재하면") { + val dailyStatus = MutableStateFlow(true) + every { settingDataStore.dailyNotificationStatus } returns dailyStatus + coEvery { getUserNickNameUseCase() } returns "eatssu" + + val viewModel = MyPageViewModel( + getUserNickNameUseCase, + setDailyNotificationStatusUseCase, + alarmUseCase, + settingDataStore, + ) + + then("state에 닉네임과 알림 상태를 반영한다") { + runTest { + viewModel.uiState.test { + viewModel.fetchMyInfo() + val first = awaitItem() + val state = when (first) { + is UiState.Success<*> -> first as UiState.Success + else -> awaitItem() as UiState.Success + } + + state.data.nickname shouldBe "eatssu" + state.data.isAlarmOn shouldBe true + cancelAndIgnoreRemainingEvents() + } + } + } + } + + `when`("알림을 켜면") { + val dailyStatus = MutableStateFlow(false) + every { settingDataStore.dailyNotificationStatus } returns dailyStatus + coEvery { getUserNickNameUseCase() } returns "eatssu" + + val viewModel = MyPageViewModel( + getUserNickNameUseCase, + setDailyNotificationStatusUseCase, + alarmUseCase, + settingDataStore, + ) + + then("저장 후 알람을 등록한다") { + runTest { + viewModel.setNotificationOn() + advanceUntilIdle() + + coVerify { setDailyNotificationStatusUseCase(true) } + verify { alarmUseCase.scheduleAlarm() } + } + } + } + + `when`("알림을 끄면") { + val dailyStatus = MutableStateFlow(true) + every { settingDataStore.dailyNotificationStatus } returns dailyStatus + coEvery { getUserNickNameUseCase() } returns "eatssu" + + val viewModel = MyPageViewModel( + getUserNickNameUseCase, + setDailyNotificationStatusUseCase, + alarmUseCase, + settingDataStore, + ) + + then("저장 후 알람을 해제한다") { + runTest { + viewModel.setNotificationOff() + advanceUntilIdle() + + coVerify { setDailyNotificationStatusUseCase(false) } + verify { alarmUseCase.cancelAlarm() } + } + } + } + } +}) diff --git a/app/src/test/java/com/eatssu/android/presentation/mypage/language/LanguageSelectorViewModelBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/presentation/mypage/language/LanguageSelectorViewModelBehaviorSpec.kt new file mode 100644 index 000000000..f4e3478dd --- /dev/null +++ b/app/src/test/java/com/eatssu/android/presentation/mypage/language/LanguageSelectorViewModelBehaviorSpec.kt @@ -0,0 +1,60 @@ +package com.eatssu.android.presentation.mypage.language + +import androidx.appcompat.app.AppCompatDelegate +import androidx.core.os.LocaleListCompat +import com.eatssu.android.data.local.SettingDataStore +import com.eatssu.android.test.AppBehaviorSpec +import com.eatssu.common.enums.AppLanguage +import io.kotest.matchers.shouldBe +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.runs +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest + +@OptIn(ExperimentalCoroutinesApi::class) +class LanguageSelectorViewModelBehaviorSpec : AppBehaviorSpec({ + + given("언어 선택") { + val settingDataStore = mockk() + val languageFlow = MutableStateFlow(AppLanguage.KOREAN) + every { settingDataStore.appLanguage } returns languageFlow + coEvery { settingDataStore.setAppLanguage(any()) } returns Unit + + mockkStatic(AppCompatDelegate::class) + every { AppCompatDelegate.setApplicationLocales(any()) } just runs + + `when`("초기화되면") { + val viewModel = LanguageSelectorViewModel(settingDataStore) + + then("DataStore 언어를 selectedLanguage에 반영한다") { + runTest { + advanceUntilIdle() + viewModel.selectedLanguage.value shouldBe AppLanguage.KOREAN + } + } + } + + `when`("언어를 선택하면") { + val viewModel = LanguageSelectorViewModel(settingDataStore) + + then("DataStore 저장과 AppCompat locale 적용을 수행한다") { + runTest { + viewModel.selectLanguage(AppLanguage.KOREAN) + advanceUntilIdle() + + coVerify { settingDataStore.setAppLanguage(AppLanguage.KOREAN) } + verify { AppCompatDelegate.setApplicationLocales(LocaleListCompat.forLanguageTags(AppLanguage.KOREAN.code)) } + viewModel.selectedLanguage.value shouldBe AppLanguage.KOREAN + } + } + } + } +}) diff --git a/app/src/test/java/com/eatssu/android/presentation/mypage/myreview/MyReviewViewModelBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/presentation/mypage/myreview/MyReviewViewModelBehaviorSpec.kt new file mode 100644 index 000000000..d3feb67ac --- /dev/null +++ b/app/src/test/java/com/eatssu/android/presentation/mypage/myreview/MyReviewViewModelBehaviorSpec.kt @@ -0,0 +1,107 @@ +package com.eatssu.android.presentation.mypage.myreview + +import app.cash.turbine.test +import com.eatssu.android.R +import com.eatssu.android.domain.usecase.review.DeleteReviewUseCase +import com.eatssu.android.domain.usecase.review.GetMyReviewsUseCase +import com.eatssu.android.domain.usecase.user.GetUserNickNameUseCase +import com.eatssu.android.test.AppBehaviorSpec +import com.eatssu.android.test.assertToast +import com.eatssu.android.test.awaitToastEvent +import com.eatssu.android.test.sampleReview +import com.eatssu.common.UiState +import com.eatssu.common.enums.ToastType +import io.kotest.matchers.shouldBe +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest + +@OptIn(ExperimentalCoroutinesApi::class) +class MyReviewViewModelBehaviorSpec : AppBehaviorSpec({ + + given("내 리뷰 화면") { + val getMyReviewsUseCase = mockk() + val getUserNickNameUseCase = mockk() + val deleteReviewUseCase = mockk() + + `when`("리뷰 목록이 비어있으면") { + coEvery { getMyReviewsUseCase() } returns emptyList() + val viewModel = MyReviewViewModel(getMyReviewsUseCase, getUserNickNameUseCase, deleteReviewUseCase) + + then("NoReview 상태가 된다") { + runTest { + advanceUntilIdle() + viewModel.uiState.value shouldBe UiState.Success(MyReviewState.NoReview) + } + } + } + + `when`("리뷰 목록이 있으면") { + val review = sampleReview() + coEvery { getMyReviewsUseCase() } returns listOf(review) + val viewModel = MyReviewViewModel(getMyReviewsUseCase, getUserNickNameUseCase, deleteReviewUseCase) + + then("ReviewExists 상태가 된다") { + runTest { + advanceUntilIdle() + viewModel.uiState.value shouldBe UiState.Success(MyReviewState.ReviewExists(listOf(review))) + } + } + } + + `when`("닉네임 로드를 호출하면") { + coEvery { getMyReviewsUseCase() } returns emptyList() + coEvery { getUserNickNameUseCase() } returns "nickname" + val viewModel = MyReviewViewModel(getMyReviewsUseCase, getUserNickNameUseCase, deleteReviewUseCase) + + then("닉네임 stateFlow를 업데이트한다") { + runTest { + viewModel.loadUserNickname() + advanceUntilIdle() + viewModel.nickname.value shouldBe "nickname" + } + } + } + + `when`("리뷰 삭제가 실패하면") { + coEvery { getMyReviewsUseCase() } returns emptyList() + coEvery { deleteReviewUseCase(10L) } returns false + val viewModel = MyReviewViewModel(getMyReviewsUseCase, getUserNickNameUseCase, deleteReviewUseCase) + + then("실패 토스트를 보낸다") { + runTest { + viewModel.uiEvent.test { + viewModel.deleteReview(10L) + advanceUntilIdle() + + awaitToastEvent().assertToast(R.string.toast_review_delete_failed, ToastType.ERROR) + cancelAndIgnoreRemainingEvents() + } + } + } + } + + `when`("리뷰 삭제가 성공하면") { + val review = sampleReview(id = 2L) + coEvery { getMyReviewsUseCase() } returnsMany listOf(listOf(review), emptyList()) + coEvery { deleteReviewUseCase(2L) } returns true + val viewModel = MyReviewViewModel(getMyReviewsUseCase, getUserNickNameUseCase, deleteReviewUseCase) + + then("성공 토스트 후 목록을 재조회한다") { + runTest { + viewModel.uiEvent.test { + viewModel.deleteReview(2L) + advanceUntilIdle() + + awaitToastEvent().assertToast(R.string.toast_review_delete_success, ToastType.SUCCESS) + coVerify(atLeast = 2) { getMyReviewsUseCase() } + cancelAndIgnoreRemainingEvents() + } + } + } + } + } +}) diff --git a/app/src/test/java/com/eatssu/android/test/MainDispatcherListener.kt b/app/src/test/java/com/eatssu/android/test/MainDispatcherListener.kt index 75af3aaa0..a30c0ad03 100644 --- a/app/src/test/java/com/eatssu/android/test/MainDispatcherListener.kt +++ b/app/src/test/java/com/eatssu/android/test/MainDispatcherListener.kt @@ -1,8 +1,10 @@ package com.eatssu.android.test import io.kotest.core.listeners.TestListener +import io.kotest.core.spec.Spec import io.kotest.core.test.TestCase import io.kotest.core.test.TestResult +import io.mockk.clearAllMocks import io.mockk.unmockkAll import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -20,7 +22,11 @@ class MainDispatcherListener( } override suspend fun afterTest(testCase: TestCase, result: TestResult) { - unmockkAll() + clearAllMocks(answers = false, recordedCalls = true, childMocks = true) Dispatchers.resetMain() } + + override suspend fun afterSpec(spec: Spec) { + unmockkAll() + } } From 7edc985111baf632e82e84bf16cf2592fc0fd041 Mon Sep 17 00:00:00 2001 From: PeraSite Date: Sun, 15 Feb 2026 00:32:26 +0900 Subject: [PATCH 03/21] =?UTF-8?q?test:=20intro=20main=20map=20userinfo=20V?= =?UTF-8?q?iewModel=20BDD=20=ED=85=8C=EC=8A=A4=ED=8A=B8=EB=A5=BC=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=ED=96=88=EC=96=B4=EC=9A=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../presentation/MainViewModelBehaviorSpec.kt | 98 +++++ .../intro/IntroViewModelBehaviorSpec.kt | 122 +++++++ .../map/MapViewModelBehaviorSpec.kt | 231 ++++++++++++ .../userinfo/UserInfoViewModelBehaviorSpec.kt | 341 ++++++++++++++++++ 4 files changed, 792 insertions(+) create mode 100644 app/src/test/java/com/eatssu/android/presentation/MainViewModelBehaviorSpec.kt create mode 100644 app/src/test/java/com/eatssu/android/presentation/intro/IntroViewModelBehaviorSpec.kt create mode 100644 app/src/test/java/com/eatssu/android/presentation/map/MapViewModelBehaviorSpec.kt create mode 100644 app/src/test/java/com/eatssu/android/presentation/mypage/userinfo/UserInfoViewModelBehaviorSpec.kt diff --git a/app/src/test/java/com/eatssu/android/presentation/MainViewModelBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/presentation/MainViewModelBehaviorSpec.kt new file mode 100644 index 000000000..9bdce2bf1 --- /dev/null +++ b/app/src/test/java/com/eatssu/android/presentation/MainViewModelBehaviorSpec.kt @@ -0,0 +1,98 @@ +package com.eatssu.android.presentation + +import app.cash.turbine.test +import com.eatssu.android.R +import com.eatssu.android.domain.model.College +import com.eatssu.android.domain.model.Department +import com.eatssu.android.domain.repository.UserRepository +import com.eatssu.android.domain.usecase.auth.LogoutUseCase +import com.eatssu.android.domain.usecase.user.GetUserCollegeDepartmentUseCase +import com.eatssu.android.domain.usecase.user.GetUserNickNameUseCase +import com.eatssu.android.domain.usecase.user.SetUserCollegeDepartmentUseCase +import com.eatssu.android.test.AppBehaviorSpec +import com.eatssu.android.test.assertToast +import com.eatssu.android.test.awaitToastEvent +import com.eatssu.android.test.sampleUserInfo +import com.eatssu.common.UiState +import com.eatssu.common.enums.ToastType +import io.kotest.assertions.nondeterministic.eventually +import io.kotest.matchers.shouldBe +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import kotlin.time.Duration.Companion.seconds + +@OptIn(ExperimentalCoroutinesApi::class) +class MainViewModelBehaviorSpec : AppBehaviorSpec({ + + given("메인 화면") { + val logoutUseCase = mockk() + val getUserNickNameUseCase = mockk() + val setUserCollegeDepartmentUseCase = mockk() + val userRepository = mockk() + val getUserCollegeDepartmentUseCase = mockk() + + val college = College(collegeId = 1, collegeName = "IT") + val department = Department(departmentId = 11, departmentName = "컴퓨터학부") + val userInfo = sampleUserInfo( + nickname = "eatssu", + college = college, + department = department, + ) + + coEvery { logoutUseCase() } returns Unit + coEvery { getUserNickNameUseCase() } returns "eatssu" + coEvery { getUserCollegeDepartmentUseCase() } returns userInfo + coEvery { userRepository.getUserCollegeDepartment() } returns (college to department) + coEvery { setUserCollegeDepartmentUseCase(college, department) } returns Unit + + `when`("학과 정보를 새로고침하면") { + val viewModel = MainViewModel( + logoutUseCase = logoutUseCase, + getUserNickNameUseCase = getUserNickNameUseCase, + setUserCollegeDepartmentUseCase = setUserCollegeDepartmentUseCase, + userRepository = userRepository, + getUserCollegeDepartmentUseCase = getUserCollegeDepartmentUseCase, + ) + + then("부서명이 반영된 DepartmentState로 전이된다") { + runTest { + viewModel.refreshUserDepartment() + eventually(2.seconds) { + viewModel.uiState.value shouldBe UiState.Success( + MainState.DepartmentState(departmentName = "컴퓨터학부") + ) + } + } + } + } + + `when`("로그아웃을 수행하면") { + val viewModel = MainViewModel( + logoutUseCase = logoutUseCase, + getUserNickNameUseCase = getUserNickNameUseCase, + setUserCollegeDepartmentUseCase = setUserCollegeDepartmentUseCase, + userRepository = userRepository, + getUserCollegeDepartmentUseCase = getUserCollegeDepartmentUseCase, + ) + + then("로그아웃 유즈케이스 호출 후 성공 토스트와 LoggedOut 상태를 반영한다") { + runTest { + viewModel.uiEvent.test { + viewModel.logOut() + + awaitToastEvent().assertToast(R.string.toast_logout_success, ToastType.SUCCESS) + eventually(2.seconds) { + coVerify { logoutUseCase() } + viewModel.uiState.value shouldBe UiState.Success(MainState.LoggedOut) + } + cancelAndIgnoreRemainingEvents() + } + } + } + } + + } +}) diff --git a/app/src/test/java/com/eatssu/android/presentation/intro/IntroViewModelBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/presentation/intro/IntroViewModelBehaviorSpec.kt new file mode 100644 index 000000000..530cad709 --- /dev/null +++ b/app/src/test/java/com/eatssu/android/presentation/intro/IntroViewModelBehaviorSpec.kt @@ -0,0 +1,122 @@ +package com.eatssu.android.presentation.intro + +import app.cash.turbine.test +import com.eatssu.android.BuildConfig +import com.eatssu.android.R +import com.eatssu.android.domain.repository.FirebaseRemoteConfigRepository +import com.eatssu.android.domain.usecase.auth.GetAccessTokenUseCase +import com.eatssu.android.domain.usecase.health.HealthCheckUseCase +import com.eatssu.android.test.AppBehaviorSpec +import com.eatssu.android.test.assertToast +import com.eatssu.android.test.awaitToastEvent +import com.eatssu.common.UiState +import com.eatssu.common.enums.ToastType +import io.kotest.assertions.nondeterministic.eventually +import io.kotest.matchers.shouldBe +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.test.runTest +import kotlin.time.Duration.Companion.seconds + +@OptIn(ExperimentalCoroutinesApi::class) +class IntroViewModelBehaviorSpec : AppBehaviorSpec({ + + given("앱 초기화") { + val healthCheckUseCase = mockk() + val getAccessTokenUseCase = mockk() + val firebaseRemoteConfigRepository = mockk() + + `when`("강제 업데이트가 필요하고 토큰이 유효하면") { + val minimumVersion = (BuildConfig.VERSION_CODE + 1).toLong() + coEvery { firebaseRemoteConfigRepository.getMinimumVersionCode() } returns minimumVersion + coEvery { healthCheckUseCase() } returns true + every { getAccessTokenUseCase() } returns "valid-token" + + val viewModel = IntroViewModel( + healthCheckUseCase = healthCheckUseCase, + getAccessTokenUseCase = getAccessTokenUseCase, + firebaseRemoteConfigRepository = firebaseRemoteConfigRepository, + ) + + then("강제 업데이트 결과와 유효 토큰 상태가 반영된다") { + runTest { + eventually(2.seconds) { + viewModel.versionCheckResult.value shouldBe VersionCheckResult.ForceUpdateRequired(minimumVersion) + viewModel.uiState.value shouldBe UiState.Success(IntroState.ValidToken) + } + } + } + } + + `when`("헬스체크가 실패하면") { + coEvery { firebaseRemoteConfigRepository.getMinimumVersionCode() } returns BuildConfig.VERSION_CODE.toLong() + coEvery { healthCheckUseCase() } returns false + every { getAccessTokenUseCase() } returns "unused" + + val viewModel = IntroViewModel( + healthCheckUseCase = healthCheckUseCase, + getAccessTokenUseCase = getAccessTokenUseCase, + firebaseRemoteConfigRepository = firebaseRemoteConfigRepository, + ) + + then("유효 토큰 성공 상태로 전이되지 않는다") { + runTest { + eventually(2.seconds) { + (viewModel.uiState.value == UiState.Success(IntroState.ValidToken)) shouldBe false + } + } + } + } + + `when`("헬스체크 성공이지만 access token이 비어있으면") { + coEvery { firebaseRemoteConfigRepository.getMinimumVersionCode() } returns BuildConfig.VERSION_CODE.toLong() + coEvery { healthCheckUseCase() } coAnswers { + delay(50) + true + } + every { getAccessTokenUseCase() } returns "" + + val viewModel = IntroViewModel( + healthCheckUseCase = healthCheckUseCase, + getAccessTokenUseCase = getAccessTokenUseCase, + firebaseRemoteConfigRepository = firebaseRemoteConfigRepository, + ) + + then("토큰 오류 토스트를 보내고 Error 상태가 된다") { + runTest { + viewModel.uiEvent.test { + awaitToastEvent().assertToast(R.string.toast_token_invalid, ToastType.INFO) + eventually(2.seconds) { + viewModel.uiState.value shouldBe UiState.Error + } + cancelAndIgnoreRemainingEvents() + } + } + } + } + + `when`("버전 체크 중 예외가 발생해도") { + coEvery { firebaseRemoteConfigRepository.getMinimumVersionCode() } throws IllegalStateException("boom") + coEvery { healthCheckUseCase() } returns true + every { getAccessTokenUseCase() } returns "valid-token" + + val viewModel = IntroViewModel( + healthCheckUseCase = healthCheckUseCase, + getAccessTokenUseCase = getAccessTokenUseCase, + firebaseRemoteConfigRepository = firebaseRemoteConfigRepository, + ) + + then("자동 로그인은 계속 진행되어 성공 상태가 된다") { + runTest { + eventually(2.seconds) { + viewModel.versionCheckResult.value shouldBe null + viewModel.uiState.value shouldBe UiState.Success(IntroState.ValidToken) + } + } + } + } + } +}) diff --git a/app/src/test/java/com/eatssu/android/presentation/map/MapViewModelBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/presentation/map/MapViewModelBehaviorSpec.kt new file mode 100644 index 000000000..eba7d5d6d --- /dev/null +++ b/app/src/test/java/com/eatssu/android/presentation/map/MapViewModelBehaviorSpec.kt @@ -0,0 +1,231 @@ +package com.eatssu.android.presentation.map + +import com.eatssu.android.domain.model.College +import com.eatssu.android.domain.model.Department +import com.eatssu.android.domain.model.Partnership +import com.eatssu.android.domain.model.RestaurantType +import com.eatssu.android.domain.repository.PartnershipRepository +import com.eatssu.android.domain.usecase.user.GetPartnershipDetailUseCase +import com.eatssu.android.domain.usecase.user.GetUserCollegeDepartmentUseCase +import com.eatssu.android.presentation.map.component.FilterType +import com.eatssu.android.presentation.map.model.PlaceType +import com.eatssu.android.test.AppBehaviorSpec +import com.eatssu.android.test.samplePartnership +import com.eatssu.android.test.samplePartnershipRestaurant +import com.eatssu.android.test.sampleUserInfo +import com.eatssu.common.EventLogger +import com.eatssu.common.UiState +import io.kotest.assertions.nondeterministic.eventually +import io.kotest.matchers.shouldBe +import io.mockk.Runs +import io.mockk.clearMocks +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import kotlin.time.Duration.Companion.seconds + +@OptIn(ExperimentalCoroutinesApi::class) +class MapViewModelBehaviorSpec : AppBehaviorSpec({ + + given("제휴 지도 화면") { + val partnershipRepository = mockk() + val getPartnershipDetailUseCase = mockk() + val getUserCollegeDepartmentUseCase = mockk() + + mockkObject(EventLogger) + every { EventLogger.clickMap() } just Runs + every { EventLogger.clickMapMine(any(), any()) } just Runs + + `when`("학과 정보가 없어서 초기 필터가 전체일 때") { + val allPartnerships = listOf(samplePartnership(storeName = "All Cafe")) + coEvery { + getUserCollegeDepartmentUseCase() + } returns sampleUserInfo( + nickname = "eatssu", + college = College(collegeId = -1, collegeName = "단과대"), + department = Department(departmentId = -1, departmentName = "학과"), + ) + coEvery { partnershipRepository.getAllPartnerships() } returns allPartnerships + coEvery { partnershipRepository.getUserCollegePartnerships() } returns emptyList() + + val viewModel = MapViewModel( + partnershipRepository = partnershipRepository, + getPartnershipDetailUseCase = getPartnershipDetailUseCase, + getUserCollegeDepartmentUseCase = getUserCollegeDepartmentUseCase, + ) + + then("All 필터로 시작하고 전체 제휴 목록을 로드한다") { + runTest { + eventually(2.seconds) { + val state = viewModel.uiState.value as UiState.Success + state.data.selectedFilter shouldBe FilterType.All + state.data.partnerships shouldBe allPartnerships + } + coVerify(atLeast = 1) { partnershipRepository.getAllPartnerships() } + } + } + } + + `when`("학과 정보가 없는 사용자가 Mine 필터를 선택하면") { + coEvery { + getUserCollegeDepartmentUseCase() + } returns sampleUserInfo( + nickname = "eatssu", + college = College(collegeId = -1, collegeName = "단과대"), + department = Department(departmentId = -1, departmentName = "학과"), + ) + coEvery { partnershipRepository.getAllPartnerships() } returns emptyList() + coEvery { partnershipRepository.getUserCollegePartnerships() } returns emptyList() + + val viewModel = MapViewModel( + partnershipRepository = partnershipRepository, + getPartnershipDetailUseCase = getPartnershipDetailUseCase, + getUserCollegeDepartmentUseCase = getUserCollegeDepartmentUseCase, + ) + + then("RequiresDepartment 결과를 상태에 반영하고 Mine 데이터를 로드하지 않는다") { + runTest { + eventually(2.seconds) { + viewModel.uiState.value shouldBe UiState.Success(MapState(selectedFilter = FilterType.All)) + } + + clearMocks(partnershipRepository, answers = false, recordedCalls = true) + viewModel.setFilter(FilterType.Mine) + + eventually(2.seconds) { + val state = viewModel.uiState.value as UiState.Success + state.data.filterChangeResult shouldBe MapState.FilterChangeResult.RequiresDepartment + } + coVerify(exactly = 0) { partnershipRepository.getUserCollegePartnerships() } + } + } + } + + `when`("학과 정보가 있는 사용자가 필터를 변경하면") { + val minePartnerships = listOf(samplePartnership(storeName = "Mine Cafe")) + val allPartnerships = listOf(samplePartnership(storeName = "All Cafe")) + coEvery { + getUserCollegeDepartmentUseCase() + } returns sampleUserInfo( + nickname = "eatssu", + college = College(collegeId = 1, collegeName = "IT"), + department = Department(departmentId = 11, departmentName = "컴퓨터학부"), + ) + coEvery { partnershipRepository.getUserCollegePartnerships() } returns minePartnerships + coEvery { partnershipRepository.getAllPartnerships() } returns allPartnerships + + val viewModel = MapViewModel( + partnershipRepository = partnershipRepository, + getPartnershipDetailUseCase = getPartnershipDetailUseCase, + getUserCollegeDepartmentUseCase = getUserCollegeDepartmentUseCase, + ) + + then("필터에 맞는 목록을 로드하고 이벤트 로깅을 수행한다") { + runTest { + eventually(2.seconds) { + val initial = viewModel.uiState.value as UiState.Success + initial.data.selectedFilter shouldBe FilterType.Mine + initial.data.partnerships shouldBe minePartnerships + } + + viewModel.setFilter(FilterType.All) + eventually(2.seconds) { + val allState = viewModel.uiState.value as UiState.Success + allState.data.selectedFilter shouldBe FilterType.All + allState.data.partnerships shouldBe allPartnerships + } + verify(atLeast = 1) { EventLogger.clickMap() } + + viewModel.setFilter(FilterType.Mine) + eventually(2.seconds) { + val mineState = viewModel.uiState.value as UiState.Success + mineState.data.selectedFilter shouldBe FilterType.Mine + mineState.data.partnerships shouldBe minePartnerships + } + verify(atLeast = 1) { EventLogger.clickMapMine(1L, 11L) } + } + } + } + + `when`("가게를 선택하면") { + val partnershipInfos = listOf( + Partnership.PartnershipInfo( + id = 1, + partnershipType = "DISCOUNT", + collegeName = "IT", + departmentName = "CS", + likeCount = 2, + isLiked = true, + description = "10% 할인", + startDate = "2025-01-01", + endDate = "2025-12-31", + ), + Partnership.PartnershipInfo( + id = 2, + partnershipType = "DISCOUNT", + collegeName = "IT", + departmentName = "CS", + likeCount = 3, + isLiked = false, + description = "음료 증정", + startDate = "2025-02-01", + endDate = "2025-11-30", + ), + ) + val partnerships = listOf( + samplePartnership( + storeName = "Cafe A", + infos = partnershipInfos, + type = RestaurantType.PUB, + ) + ) + val representative = samplePartnershipRestaurant( + id = 2, + type = RestaurantType.PUB, + ) + + coEvery { + getUserCollegeDepartmentUseCase() + } returns sampleUserInfo( + nickname = "eatssu", + college = College(collegeId = 1, collegeName = "IT"), + department = Department(departmentId = 11, departmentName = "컴퓨터학부"), + ) + coEvery { partnershipRepository.getUserCollegePartnerships() } returns partnerships + coEvery { partnershipRepository.getAllPartnerships() } returns emptyList() + every { + getPartnershipDetailUseCase(partnerships, "Cafe A", 2) + } returns representative + + val viewModel = MapViewModel( + partnershipRepository = partnershipRepository, + getPartnershipDetailUseCase = getPartnershipDetailUseCase, + getUserCollegeDepartmentUseCase = getUserCollegeDepartmentUseCase, + ) + + then("대표 제휴와 표시용 리스트/장소 타입을 상태에 반영한다") { + runTest { + eventually(2.seconds) { + val state = viewModel.uiState.value as UiState.Success + state.data.partnerships shouldBe partnerships + } + + viewModel.selectPartnershipByStoreName("Cafe A", 2) + eventually(2.seconds) { + val state = viewModel.uiState.value as UiState.Success + state.data.restaurantPartnershipInfo shouldBe representative + state.data.placeType shouldBe PlaceType.PUB + state.data.restaurantInfoList.size shouldBe 2 + state.data.restaurantInfoList[1].period shouldBe "2025-02-01 ~ 2025-11-30" + } + } + } + } + } +}) diff --git a/app/src/test/java/com/eatssu/android/presentation/mypage/userinfo/UserInfoViewModelBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/presentation/mypage/userinfo/UserInfoViewModelBehaviorSpec.kt new file mode 100644 index 000000000..e3d8eb991 --- /dev/null +++ b/app/src/test/java/com/eatssu/android/presentation/mypage/userinfo/UserInfoViewModelBehaviorSpec.kt @@ -0,0 +1,341 @@ +package com.eatssu.android.presentation.mypage.userinfo + +import app.cash.turbine.test +import com.eatssu.android.R +import com.eatssu.android.domain.model.College +import com.eatssu.android.domain.model.Department +import com.eatssu.android.domain.repository.UserRepository +import com.eatssu.android.domain.usecase.user.GetUserCollegeDepartmentUseCase +import com.eatssu.android.domain.usecase.user.NicknameValidationResult +import com.eatssu.android.domain.usecase.user.SetUserCollegeDepartmentUseCase +import com.eatssu.android.domain.usecase.user.SetUserNicknameUseCase +import com.eatssu.android.domain.usecase.user.ValidateNicknameLocalUseCase +import com.eatssu.android.domain.usecase.user.ValidateNicknameServerUseCase +import com.eatssu.android.test.AppBehaviorSpec +import com.eatssu.android.test.assertToast +import com.eatssu.android.test.asStringResIdOrNull +import com.eatssu.android.test.awaitToastEvent +import com.eatssu.android.test.sampleUserInfo +import com.eatssu.common.UiState +import com.eatssu.common.UiText +import com.eatssu.common.enums.ToastType +import io.kotest.assertions.nondeterministic.eventually +import io.kotest.matchers.shouldBe +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import kotlin.time.Duration.Companion.seconds + +@OptIn(ExperimentalCoroutinesApi::class) +class UserInfoViewModelBehaviorSpec : AppBehaviorSpec({ + + given("유저 정보 수정 화면") { + val baseCollege = College(collegeId = 1, collegeName = "IT") + val baseDepartment = Department(departmentId = 11, departmentName = "컴퓨터학부") + val otherCollege = College(collegeId = 2, collegeName = "경영") + val otherDepartment = Department(departmentId = 21, departmentName = "경영학과") + + `when`("초기화되면") { + val setUserNicknameUseCase = mockk() + val getUserCollegeDepartmentUseCase = mockk() + val setUserCollegeDepartmentUseCase = mockk() + val validateNicknameServerUseCase = mockk() + val validateNicknameLocalUseCase = mockk() + val userRepository = mockk() + + coEvery { + getUserCollegeDepartmentUseCase() + } returns sampleUserInfo( + nickname = "oldNick", + college = baseCollege, + department = baseDepartment, + ) + coEvery { userRepository.getTotalColleges() } returns listOf(baseCollege, otherCollege) + coEvery { userRepository.getTotalDepartments(baseCollege.collegeId) } returns listOf(baseDepartment) + every { validateNicknameLocalUseCase(any(), any(), any()) } returns NicknameValidationResult.Valid + + val viewModel = UserInfoViewModel( + setUserNicknameUseCase = setUserNicknameUseCase, + getUserCollegeDepartmentUseCase = getUserCollegeDepartmentUseCase, + setUserCollegeDepartmentUseCase = setUserCollegeDepartmentUseCase, + validateNicknameServerUseCase = validateNicknameServerUseCase, + validateNicknameLocalUseCase = validateNicknameLocalUseCase, + userRepository = userRepository, + ) + + then("닉네임/단과대/학과와 목록을 로드한 Success 상태가 된다") { + runTest { + eventually(2.seconds) { + val state = viewModel.uiState.value as UiState.Success + state.data.nickname shouldBe "oldNick" + state.data.selectedCollege shouldBe baseCollege + state.data.selectedDepartment shouldBe baseDepartment + state.data.collegeList.size shouldBe 2 + state.data.departmentList shouldBe listOf(baseDepartment) + } + } + } + } + + `when`("닉네임을 변경하고 로컬 검증에 실패하면") { + val setUserNicknameUseCase = mockk() + val getUserCollegeDepartmentUseCase = mockk() + val setUserCollegeDepartmentUseCase = mockk() + val validateNicknameServerUseCase = mockk() + val validateNicknameLocalUseCase = mockk() + val userRepository = mockk() + + coEvery { + getUserCollegeDepartmentUseCase() + } returns sampleUserInfo( + nickname = "oldNick", + college = baseCollege, + department = baseDepartment, + ) + coEvery { userRepository.getTotalColleges() } returns listOf(baseCollege) + coEvery { userRepository.getTotalDepartments(baseCollege.collegeId) } returns listOf(baseDepartment) + every { + validateNicknameLocalUseCase("x", UserInfoViewModel.MIN_NICKNAME_LENGTH, UserInfoViewModel.MAX_NICKNAME_LENGTH) + } returns NicknameValidationResult.Invalid(UiText.StringResource(R.string.nickname_error_length)) + + val viewModel = UserInfoViewModel( + setUserNicknameUseCase = setUserNicknameUseCase, + getUserCollegeDepartmentUseCase = getUserCollegeDepartmentUseCase, + setUserCollegeDepartmentUseCase = setUserCollegeDepartmentUseCase, + validateNicknameServerUseCase = validateNicknameServerUseCase, + validateNicknameLocalUseCase = validateNicknameLocalUseCase, + userRepository = userRepository, + ) + + then("검증 에러를 표시하고 중복확인 상태를 초기화한다") { + runTest { + eventually(2.seconds) { + (viewModel.uiState.value is UiState.Success) shouldBe true + } + + viewModel.onNicknameChanged("x") + val state = viewModel.uiState.value as UiState.Success + + state.data.nickname shouldBe "x" + state.data.isNicknameChanged shouldBe true + state.data.isDuplicationChecked shouldBe false + state.data.nicknameValidationError.asStringResIdOrNull() shouldBe R.string.nickname_error_length + } + } + } + + `when`("닉네임 중복확인 서버 호출이 실패하면") { + val setUserNicknameUseCase = mockk() + val getUserCollegeDepartmentUseCase = mockk() + val setUserCollegeDepartmentUseCase = mockk() + val validateNicknameServerUseCase = mockk() + val validateNicknameLocalUseCase = mockk() + val userRepository = mockk() + + coEvery { + getUserCollegeDepartmentUseCase() + } returns sampleUserInfo( + nickname = "oldNick", + college = baseCollege, + department = baseDepartment, + ) + coEvery { userRepository.getTotalColleges() } returns listOf(baseCollege) + coEvery { userRepository.getTotalDepartments(baseCollege.collegeId) } returns listOf(baseDepartment) + every { validateNicknameLocalUseCase(any(), any(), any()) } returns NicknameValidationResult.Valid + coEvery { validateNicknameServerUseCase("newNick") } returns Result.failure(IllegalArgumentException("dup")) + + val viewModel = UserInfoViewModel( + setUserNicknameUseCase = setUserNicknameUseCase, + getUserCollegeDepartmentUseCase = getUserCollegeDepartmentUseCase, + setUserCollegeDepartmentUseCase = setUserCollegeDepartmentUseCase, + validateNicknameServerUseCase = validateNicknameServerUseCase, + validateNicknameLocalUseCase = validateNicknameLocalUseCase, + userRepository = userRepository, + ) + + then("서버 에러 메시지를 validationError에 반영한다") { + runTest { + eventually(2.seconds) { + (viewModel.uiState.value is UiState.Success) shouldBe true + } + + viewModel.onNicknameChanged("newNick") + viewModel.checkNicknameDuplication() + + eventually(2.seconds) { + val state = viewModel.uiState.value as UiState.Success + (state.data.nicknameValidationError as UiText.DynamicString).value shouldBe "dup" + state.data.isDuplicationChecked shouldBe false + } + } + } + } + + `when`("변경사항 없이 저장하면") { + val setUserNicknameUseCase = mockk() + val getUserCollegeDepartmentUseCase = mockk() + val setUserCollegeDepartmentUseCase = mockk() + val validateNicknameServerUseCase = mockk() + val validateNicknameLocalUseCase = mockk() + val userRepository = mockk() + + coEvery { + getUserCollegeDepartmentUseCase() + } returns sampleUserInfo( + nickname = "oldNick", + college = baseCollege, + department = baseDepartment, + ) + coEvery { userRepository.getTotalColleges() } returns listOf(baseCollege) + coEvery { userRepository.getTotalDepartments(baseCollege.collegeId) } returns listOf(baseDepartment) + every { validateNicknameLocalUseCase(any(), any(), any()) } returns NicknameValidationResult.Valid + + val viewModel = UserInfoViewModel( + setUserNicknameUseCase = setUserNicknameUseCase, + getUserCollegeDepartmentUseCase = getUserCollegeDepartmentUseCase, + setUserCollegeDepartmentUseCase = setUserCollegeDepartmentUseCase, + validateNicknameServerUseCase = validateNicknameServerUseCase, + validateNicknameLocalUseCase = validateNicknameLocalUseCase, + userRepository = userRepository, + ) + + then("no changes 토스트를 보내고 완료 플래그를 true로 만든다") { + runTest { + eventually(2.seconds) { + (viewModel.uiState.value is UiState.Success) shouldBe true + } + + viewModel.uiEvent.test { + viewModel.saveUserInfo() + awaitToastEvent().assertToast(R.string.toast_no_changes, ToastType.INFO) + + eventually(2.seconds) { + val state = viewModel.uiState.value as UiState.Success + state.data.isDone shouldBe true + } + cancelAndIgnoreRemainingEvents() + } + } + } + } + + `when`("닉네임 변경 저장이 실패하면") { + val setUserNicknameUseCase = mockk() + val getUserCollegeDepartmentUseCase = mockk() + val setUserCollegeDepartmentUseCase = mockk() + val validateNicknameServerUseCase = mockk() + val validateNicknameLocalUseCase = mockk() + val userRepository = mockk() + + coEvery { + getUserCollegeDepartmentUseCase() + } returns sampleUserInfo( + nickname = "oldNick", + college = baseCollege, + department = baseDepartment, + ) + coEvery { userRepository.getTotalColleges() } returns listOf(baseCollege) + coEvery { userRepository.getTotalDepartments(baseCollege.collegeId) } returns listOf(baseDepartment) + every { validateNicknameLocalUseCase(any(), any(), any()) } returns NicknameValidationResult.Valid + coEvery { setUserNicknameUseCase("newNick") } returns Result.failure(IllegalStateException("fail")) + + val viewModel = UserInfoViewModel( + setUserNicknameUseCase = setUserNicknameUseCase, + getUserCollegeDepartmentUseCase = getUserCollegeDepartmentUseCase, + setUserCollegeDepartmentUseCase = setUserCollegeDepartmentUseCase, + validateNicknameServerUseCase = validateNicknameServerUseCase, + validateNicknameLocalUseCase = validateNicknameLocalUseCase, + userRepository = userRepository, + ) + + then("실패 토스트를 보내고 Error 상태가 된다") { + runTest { + eventually(2.seconds) { + (viewModel.uiState.value is UiState.Success) shouldBe true + } + + viewModel.onNicknameChanged("newNick") + viewModel.uiEvent.test { + viewModel.saveUserInfo() + awaitToastEvent().assertToast(R.string.toast_nickname_change_failed, ToastType.ERROR) + eventually(2.seconds) { + viewModel.uiState.value shouldBe UiState.Error + } + cancelAndIgnoreRemainingEvents() + } + } + } + } + + `when`("닉네임과 학과를 모두 바꿔 저장하면") { + val setUserNicknameUseCase = mockk() + val getUserCollegeDepartmentUseCase = mockk() + val setUserCollegeDepartmentUseCase = mockk() + val validateNicknameServerUseCase = mockk() + val validateNicknameLocalUseCase = mockk() + val userRepository = mockk() + + coEvery { + getUserCollegeDepartmentUseCase() + } returns sampleUserInfo( + nickname = "oldNick", + college = baseCollege, + department = baseDepartment, + ) + coEvery { userRepository.getTotalColleges() } returns listOf(baseCollege, otherCollege) + coEvery { userRepository.getTotalDepartments(any()) } answers { + when (firstArg()) { + baseCollege.collegeId -> listOf(baseDepartment) + otherCollege.collegeId -> listOf(otherDepartment) + else -> emptyList() + } + } + every { validateNicknameLocalUseCase(any(), any(), any()) } returns NicknameValidationResult.Valid + coEvery { validateNicknameServerUseCase("newNick") } returns Result.success(Unit) + coEvery { setUserNicknameUseCase("newNick") } returns Result.success(Unit) + coEvery { userRepository.setUserDepartment(otherDepartment.departmentId) } returns true + coEvery { setUserCollegeDepartmentUseCase(otherCollege, otherDepartment) } returns Unit + + val viewModel = UserInfoViewModel( + setUserNicknameUseCase = setUserNicknameUseCase, + getUserCollegeDepartmentUseCase = getUserCollegeDepartmentUseCase, + setUserCollegeDepartmentUseCase = setUserCollegeDepartmentUseCase, + validateNicknameServerUseCase = validateNicknameServerUseCase, + validateNicknameLocalUseCase = validateNicknameLocalUseCase, + userRepository = userRepository, + ) + + then("통합 수정 성공 토스트를 보내고 완료 플래그를 true로 만든다") { + runTest { + eventually(2.seconds) { + (viewModel.uiState.value is UiState.Success) shouldBe true + } + + viewModel.onNicknameChanged("newNick") + viewModel.checkNicknameDuplication() + viewModel.selectCollege(otherCollege) + eventually(2.seconds) { + val state = viewModel.uiState.value as UiState.Success + state.data.departmentList shouldBe listOf(otherDepartment) + } + viewModel.selectDepartment(otherDepartment) + + viewModel.uiEvent.test { + viewModel.saveUserInfo() + awaitToastEvent().assertToast(R.string.toast_info_updated, ToastType.INFO) + + eventually(2.seconds) { + val state = viewModel.uiState.value as UiState.Success + state.data.isDone shouldBe true + } + coVerify { setUserCollegeDepartmentUseCase(otherCollege, otherDepartment) } + cancelAndIgnoreRemainingEvents() + } + } + } + } + } +}) From 1669cb6335cab25312e46343ae14a9d83902dbcf Mon Sep 17 00:00:00 2001 From: PeraSite Date: Sun, 15 Feb 2026 00:36:24 +0900 Subject: [PATCH 04/21] =?UTF-8?q?test:=20=ED=95=B5=EC=8B=AC=20=EB=B6=84?= =?UTF-8?q?=EA=B8=B0=20UseCase=20BDD=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=EB=A5=BC=20=EC=B6=94=EA=B0=80=ED=96=88=EC=96=B4=EC=9A=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...ReissueAndStoreTokenUseCaseBehaviorSpec.kt | 114 ++++++++++++++++ .../menu/GetMenuListUseCaseBehaviorSpec.kt | 54 ++++++++ .../GetReviewInfoUseCaseBehaviorSpec.kt | 57 ++++++++ .../review/WriteReviewUseCaseBehaviorSpec.kt | 127 ++++++++++++++++++ ...GetPartnershipDetailUseCaseBehaviorSpec.kt | 94 +++++++++++++ ...alidateNicknameLocalUseCaseBehaviorSpec.kt | 86 ++++++++++++ .../widget/GetTodayMealUseCaseBehaviorSpec.kt | 69 ++++++++++ 7 files changed, 601 insertions(+) create mode 100644 app/src/test/java/com/eatssu/android/domain/usecase/auth/ReissueAndStoreTokenUseCaseBehaviorSpec.kt create mode 100644 app/src/test/java/com/eatssu/android/domain/usecase/menu/GetMenuListUseCaseBehaviorSpec.kt create mode 100644 app/src/test/java/com/eatssu/android/domain/usecase/review/GetReviewInfoUseCaseBehaviorSpec.kt create mode 100644 app/src/test/java/com/eatssu/android/domain/usecase/review/WriteReviewUseCaseBehaviorSpec.kt create mode 100644 app/src/test/java/com/eatssu/android/domain/usecase/user/GetPartnershipDetailUseCaseBehaviorSpec.kt create mode 100644 app/src/test/java/com/eatssu/android/domain/usecase/user/ValidateNicknameLocalUseCaseBehaviorSpec.kt create mode 100644 app/src/test/java/com/eatssu/android/domain/usecase/widget/GetTodayMealUseCaseBehaviorSpec.kt diff --git a/app/src/test/java/com/eatssu/android/domain/usecase/auth/ReissueAndStoreTokenUseCaseBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/domain/usecase/auth/ReissueAndStoreTokenUseCaseBehaviorSpec.kt new file mode 100644 index 000000000..4267609d0 --- /dev/null +++ b/app/src/test/java/com/eatssu/android/domain/usecase/auth/ReissueAndStoreTokenUseCaseBehaviorSpec.kt @@ -0,0 +1,114 @@ +package com.eatssu.android.domain.usecase.auth + +import com.eatssu.android.domain.model.ReissueTokenResult +import com.eatssu.android.test.AppBehaviorSpec +import com.eatssu.android.test.sampleToken +import io.kotest.matchers.shouldBe +import io.mockk.Runs +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest + +@OptIn(ExperimentalCoroutinesApi::class) +class ReissueAndStoreTokenUseCaseBehaviorSpec : AppBehaviorSpec({ + + given("토큰 재발급 및 저장") { + val getRefreshTokenUseCase = mockk() + val reissueTokenUseCase = mockk() + val setAccessTokenUseCase = mockk() + val setRefreshTokenUseCase = mockk() + + every { setAccessTokenUseCase(any()) } just Runs + every { setRefreshTokenUseCase(any()) } just Runs + + val useCase = ReissueAndStoreTokenUseCase( + getRefreshTokenUseCase = getRefreshTokenUseCase, + reissueTokenUseCase = reissueTokenUseCase, + setAccessTokenUseCase = setAccessTokenUseCase, + setRefreshTokenUseCase = setRefreshTokenUseCase, + ) + + `when`("refresh token이 비어있으면") { + every { getRefreshTokenUseCase() } returns " " + + then("MissingRefreshToken을 반환한다") { + runTest { + useCase() shouldBe ReissueAndStoreResult.MissingRefreshToken + coVerify(exactly = 0) { reissueTokenUseCase(any()) } + } + } + } + + `when`("재발급이 성공하고 토큰이 유효하면") { + every { getRefreshTokenUseCase() } returns "refresh" + coEvery { reissueTokenUseCase("refresh") } returns ReissueTokenResult.Success( + sampleToken(access = "new-access", refresh = "new-refresh") + ) + + then("새 access token을 저장하고 Success를 반환한다") { + runTest { + useCase() shouldBe ReissueAndStoreResult.Success(accessToken = "new-access") + verify { setAccessTokenUseCase("new-access") } + verify { setRefreshTokenUseCase("new-refresh") } + } + } + } + + `when`("재발급 성공이지만 빈 토큰이 반환되면") { + every { getRefreshTokenUseCase() } returns "refresh" + coEvery { reissueTokenUseCase("refresh") } returns ReissueTokenResult.Success( + sampleToken(access = "", refresh = "new-refresh") + ) + + then("TransientFailure를 반환하고 저장하지 않는다") { + runTest { + useCase() shouldBe ReissueAndStoreResult.TransientFailure(message = "reissue returned blank tokens") + verify(exactly = 0) { setAccessTokenUseCase(any()) } + verify(exactly = 0) { setRefreshTokenUseCase(any()) } + } + } + } + + `when`("재발급이 401/403으로 실패하면") { + every { getRefreshTokenUseCase() } returns "refresh" + coEvery { reissueTokenUseCase("refresh") } returns ReissueTokenResult.Failure( + responseCode = 401, + message = "invalid refresh", + ) + + then("RefreshInvalid를 반환한다") { + runTest { + useCase() shouldBe ReissueAndStoreResult.RefreshInvalid( + responseCode = 401, + message = "invalid refresh", + ) + } + } + } + + `when`("재발급이 일시적 오류로 실패하면") { + val throwable = IllegalStateException("boom") + every { getRefreshTokenUseCase() } returns "refresh" + coEvery { reissueTokenUseCase("refresh") } returns ReissueTokenResult.Failure( + responseCode = 500, + message = "server error", + throwable = throwable, + ) + + then("TransientFailure를 반환한다") { + runTest { + useCase() shouldBe ReissueAndStoreResult.TransientFailure( + responseCode = 500, + message = "server error", + throwable = throwable, + ) + } + } + } + } +}) diff --git a/app/src/test/java/com/eatssu/android/domain/usecase/menu/GetMenuListUseCaseBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/domain/usecase/menu/GetMenuListUseCaseBehaviorSpec.kt new file mode 100644 index 000000000..bf317484e --- /dev/null +++ b/app/src/test/java/com/eatssu/android/domain/usecase/menu/GetMenuListUseCaseBehaviorSpec.kt @@ -0,0 +1,54 @@ +package com.eatssu.android.domain.usecase.menu + +import com.eatssu.android.domain.model.Menu +import com.eatssu.android.domain.repository.MealRepository +import com.eatssu.android.domain.repository.MenuRepository +import com.eatssu.android.test.AppBehaviorSpec +import com.eatssu.common.enums.Restaurant +import com.eatssu.common.enums.Time +import io.kotest.matchers.shouldBe +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest + +@OptIn(ExperimentalCoroutinesApi::class) +class GetMenuListUseCaseBehaviorSpec : AppBehaviorSpec({ + + given("메뉴 목록 조회") { + val menuRepository = mockk() + val mealRepository = mockk() + val useCase = GetMenuListUseCase(menuRepository, mealRepository) + + `when`("고정식당 메뉴를 조회하면") { + val result = listOf(Menu(id = 1, name = "돈까스", price = 5000, rate = 4.0)) + coEvery { menuRepository.getFixedMenuList(Restaurant.FOOD_COURT) } returns result + + then("menuRepository.getFixedMenuList를 사용한다") { + runTest { + useCase(Restaurant.FOOD_COURT, "2025-01-01", Time.LUNCH) shouldBe result + coVerify(exactly = 1) { menuRepository.getFixedMenuList(Restaurant.FOOD_COURT) } + coVerify(exactly = 0) { mealRepository.getTodayMenuList(any(), any(), any()) } + } + } + } + + `when`("변동식당 메뉴를 조회하면") { + val result = listOf(Menu(id = 2, name = "비빔밥", price = 4500, rate = 3.5)) + coEvery { + mealRepository.getTodayMenuList("2025-01-01", Restaurant.HAKSIK, Time.DINNER) + } returns result + + then("mealRepository.getTodayMenuList를 사용한다") { + runTest { + useCase(Restaurant.HAKSIK, "2025-01-01", Time.DINNER) shouldBe result + coVerify(exactly = 1) { + mealRepository.getTodayMenuList("2025-01-01", Restaurant.HAKSIK, Time.DINNER) + } + coVerify(exactly = 0) { menuRepository.getFixedMenuList(any()) } + } + } + } + } +}) diff --git a/app/src/test/java/com/eatssu/android/domain/usecase/review/GetReviewInfoUseCaseBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/domain/usecase/review/GetReviewInfoUseCaseBehaviorSpec.kt new file mode 100644 index 000000000..d5fcd1781 --- /dev/null +++ b/app/src/test/java/com/eatssu/android/domain/usecase/review/GetReviewInfoUseCaseBehaviorSpec.kt @@ -0,0 +1,57 @@ +package com.eatssu.android.domain.usecase.review + +import com.eatssu.android.test.AppBehaviorSpec +import com.eatssu.android.test.sampleReviewInfo +import com.eatssu.android.domain.repository.ReviewRepository +import com.eatssu.common.enums.MenuType +import io.kotest.matchers.shouldBe +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest + +@OptIn(ExperimentalCoroutinesApi::class) +class GetReviewInfoUseCaseBehaviorSpec : AppBehaviorSpec({ + + given("리뷰 통계 조회") { + val reviewRepository = mockk() + val useCase = GetReviewInfoUseCase(reviewRepository) + + `when`("고정 메뉴 리뷰 통계를 조회하면") { + val info = sampleReviewInfo(count = 10, rating = 4.2) + coEvery { reviewRepository.getMenuReviewInfo(1L) } returns info + + then("menu 리뷰 정보를 반환한다") { + runTest { + useCase(MenuType.FIXED, 1L) shouldBe info + coVerify(exactly = 1) { reviewRepository.getMenuReviewInfo(1L) } + coVerify(exactly = 0) { reviewRepository.getMealReviewInfo(any()) } + } + } + } + + `when`("변동 메뉴 리뷰 통계를 조회하면") { + val info = sampleReviewInfo(count = 5, rating = 3.8) + coEvery { reviewRepository.getMealReviewInfo(2L) } returns info + + then("meal 리뷰 정보를 반환한다") { + runTest { + useCase(MenuType.VARIABLE, 2L) shouldBe info + coVerify(exactly = 1) { reviewRepository.getMealReviewInfo(2L) } + coVerify(exactly = 0) { reviewRepository.getMenuReviewInfo(any()) } + } + } + } + + `when`("저장소가 null을 반환하면") { + coEvery { reviewRepository.getMealReviewInfo(3L) } returns null + + then("null을 그대로 반환한다") { + runTest { + useCase(MenuType.VARIABLE, 3L) shouldBe null + } + } + } + } +}) diff --git a/app/src/test/java/com/eatssu/android/domain/usecase/review/WriteReviewUseCaseBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/domain/usecase/review/WriteReviewUseCaseBehaviorSpec.kt new file mode 100644 index 000000000..279f93992 --- /dev/null +++ b/app/src/test/java/com/eatssu/android/domain/usecase/review/WriteReviewUseCaseBehaviorSpec.kt @@ -0,0 +1,127 @@ +package com.eatssu.android.domain.usecase.review + +import com.eatssu.android.domain.repository.ReviewRepository +import com.eatssu.android.test.AppBehaviorSpec +import com.eatssu.common.enums.MenuType +import io.kotest.matchers.shouldBe +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest + +@OptIn(ExperimentalCoroutinesApi::class) +class WriteReviewUseCaseBehaviorSpec : AppBehaviorSpec({ + + given("리뷰 작성 유즈케이스") { + val reviewRepository = mockk() + val useCase = WriteReviewUseCase(reviewRepository) + + `when`("FIXED 메뉴에 이미지 없이 작성하면") { + coEvery { + reviewRepository.writeMenuReview( + rating = 5, + content = "good", + imageUrls = emptyList(), + likeMenuIdList = listOf(1L), + ) + } returns true + + then("writeMenuReview를 호출하고 결과를 반환한다") { + runTest { + useCase( + menuType = MenuType.FIXED, + itemId = 100L, + rating = 5, + content = "good", + imageUrl = null, + likeMenuIdList = listOf(1L), + ) shouldBe true + + coVerify(exactly = 1) { + reviewRepository.writeMenuReview( + rating = 5, + content = "good", + imageUrls = emptyList(), + likeMenuIdList = listOf(1L), + ) + } + } + } + } + + `when`("FIXED 메뉴에 이미지가 있으면") { + coEvery { + reviewRepository.writeMenuReview( + rating = 4, + content = "", + imageUrls = listOf("https://img"), + likeMenuIdList = null, + ) + } returns false + + then("이미지 URL을 리스트로 전달한다") { + runTest { + useCase( + menuType = MenuType.FIXED, + itemId = 100L, + rating = 4, + content = "", + imageUrl = "https://img", + likeMenuIdList = null, + ) shouldBe false + } + } + } + + `when`("VARIABLE 메뉴에 이미지 없이 작성하면") { + coEvery { + reviewRepository.writeMealReview( + mealId = 77L, + rating = 3, + content = "ok", + imageUrls = emptyList(), + likeMenuIdList = emptyList(), + ) + } returns true + + then("writeMealReview를 호출하고 결과를 반환한다") { + runTest { + useCase( + menuType = MenuType.VARIABLE, + itemId = 77L, + rating = 3, + content = "ok", + imageUrl = null, + likeMenuIdList = emptyList(), + ) shouldBe true + } + } + } + + `when`("VARIABLE 메뉴에 이미지가 있으면") { + coEvery { + reviewRepository.writeMealReview( + mealId = 88L, + rating = 2, + content = "bad", + imageUrls = listOf("https://img2"), + likeMenuIdList = listOf(9L), + ) + } returns false + + then("mealId와 이미지 리스트를 전달한다") { + runTest { + useCase( + menuType = MenuType.VARIABLE, + itemId = 88L, + rating = 2, + content = "bad", + imageUrl = "https://img2", + likeMenuIdList = listOf(9L), + ) shouldBe false + } + } + } + } +}) diff --git a/app/src/test/java/com/eatssu/android/domain/usecase/user/GetPartnershipDetailUseCaseBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/domain/usecase/user/GetPartnershipDetailUseCaseBehaviorSpec.kt new file mode 100644 index 000000000..47d9cdfd0 --- /dev/null +++ b/app/src/test/java/com/eatssu/android/domain/usecase/user/GetPartnershipDetailUseCaseBehaviorSpec.kt @@ -0,0 +1,94 @@ +package com.eatssu.android.domain.usecase.user + +import com.eatssu.android.domain.model.Partnership +import com.eatssu.android.domain.model.RestaurantType +import com.eatssu.android.test.AppBehaviorSpec +import io.kotest.matchers.shouldBe + +class GetPartnershipDetailUseCaseBehaviorSpec : AppBehaviorSpec({ + + given("가게 제휴 상세 조회") { + val useCase = GetPartnershipDetailUseCase() + val infos = listOf( + Partnership.PartnershipInfo( + id = 1, + partnershipType = "DISCOUNT", + collegeName = "IT", + departmentName = "CS", + likeCount = 2, + isLiked = true, + description = "10% 할인", + startDate = "2025-01-01", + endDate = "2025-12-31", + ), + Partnership.PartnershipInfo( + id = 2, + partnershipType = "GIFT", + collegeName = "IT", + departmentName = "CS", + likeCount = 1, + isLiked = false, + description = "음료 증정", + startDate = "2025-02-01", + endDate = "2025-11-30", + ), + ) + val partnerships = listOf( + Partnership( + storeName = "Cafe A", + longitude = 127.0, + latitude = 37.0, + restaurantType = RestaurantType.CAFE, + partnershipInfos = infos, + ) + ) + + `when`("storeName이 존재하지 않으면") { + then("null을 반환한다") { + useCase(partnerships, "Unknown", 1) shouldBe null + } + } + + `when`("partnershipId가 주어지고 매칭되면") { + then("해당 id의 PartnershipRestaurant로 매핑한다") { + val result = useCase(partnerships, "Cafe A", 2) + result?.id shouldBe 2 + result?.storeName shouldBe "Cafe A" + result?.description shouldBe "음료 증정" + result?.restaurantType shouldBe RestaurantType.CAFE + } + } + + `when`("partnershipId가 없으면") { + then("첫 번째 제휴 정보를 대표로 반환한다") { + val result = useCase(partnerships, "Cafe A", null) + result?.id shouldBe 1 + result?.description shouldBe "10% 할인" + } + } + + `when`("partnershipId가 있지만 매칭이 없으면") { + then("첫 번째 제휴 정보로 fallback 한다") { + val result = useCase(partnerships, "Cafe A", 99) + result?.id shouldBe 1 + result?.description shouldBe "10% 할인" + } + } + + `when`("제휴 정보 리스트가 비어있으면") { + val emptyInfoPartnership = listOf( + Partnership( + storeName = "Cafe A", + longitude = 127.0, + latitude = 37.0, + restaurantType = RestaurantType.CAFE, + partnershipInfos = emptyList(), + ) + ) + + then("null을 반환한다") { + useCase(emptyInfoPartnership, "Cafe A", null) shouldBe null + } + } + } +}) diff --git a/app/src/test/java/com/eatssu/android/domain/usecase/user/ValidateNicknameLocalUseCaseBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/domain/usecase/user/ValidateNicknameLocalUseCaseBehaviorSpec.kt new file mode 100644 index 000000000..629a97deb --- /dev/null +++ b/app/src/test/java/com/eatssu/android/domain/usecase/user/ValidateNicknameLocalUseCaseBehaviorSpec.kt @@ -0,0 +1,86 @@ +package com.eatssu.android.domain.usecase.user + +import com.eatssu.android.R +import com.eatssu.android.test.AppBehaviorSpec +import com.eatssu.common.UiText +import io.kotest.matchers.shouldBe + +private fun NicknameValidationResult.Invalid.resIdOrNull(): Int? = + (message as? UiText.StringResource)?.resId + +class ValidateNicknameLocalUseCaseBehaviorSpec : AppBehaviorSpec({ + + given("로컬 닉네임 검증") { + val useCase = ValidateNicknameLocalUseCase() + + `when`("길이 제한을 벗어나면") { + then("length 에러를 반환한다") { + val result = useCase("a", minLength = 2, maxLength = 16) + (result is NicknameValidationResult.Invalid) shouldBe true + (result as NicknameValidationResult.Invalid).resIdOrNull() shouldBe R.string.nickname_error_length + } + } + + `when`("탭/줄바꿈 등 공백 문자가 포함되면") { + then("whitespace 에러를 반환한다") { + val result = useCase("eat\tssu", 2, 16) + (result is NicknameValidationResult.Invalid) shouldBe true + (result as NicknameValidationResult.Invalid).resIdOrNull() shouldBe R.string.nickname_error_whitespace + } + } + + `when`("연속 공백이 포함되면") { + then("consecutive space 에러를 반환한다") { + val result = useCase("eat ssu", 2, 16) + (result is NicknameValidationResult.Invalid) shouldBe true + (result as NicknameValidationResult.Invalid).resIdOrNull() shouldBe R.string.nickname_error_consecutive_space + } + } + + `when`("허용되지 않은 문자가 포함되면") { + then("allowed chars 에러를 반환한다") { + val result = useCase("eat😀ssu", 2, 16) + (result is NicknameValidationResult.Invalid) shouldBe true + (result as NicknameValidationResult.Invalid).resIdOrNull() shouldBe R.string.nickname_error_allowed_chars + } + } + + `when`("특수문자가 연속되면") { + then("consecutive special 에러를 반환한다") { + val result = useCase("eat__ssu", 2, 16) + (result is NicknameValidationResult.Invalid) shouldBe true + (result as NicknameValidationResult.Invalid).resIdOrNull() shouldBe R.string.nickname_error_consecutive_special + } + } + + `when`("숫자로만 구성되면") { + then("only numbers 에러를 반환한다") { + val result = useCase("123456", 2, 16) + (result is NicknameValidationResult.Invalid) shouldBe true + (result as NicknameValidationResult.Invalid).resIdOrNull() shouldBe R.string.nickname_error_only_numbers + } + } + + `when`("특수문자로 시작/종료하면") { + then("special position 에러를 반환한다") { + val result = useCase("_eatssu", 2, 16) + (result is NicknameValidationResult.Invalid) shouldBe true + (result as NicknameValidationResult.Invalid).resIdOrNull() shouldBe R.string.nickname_error_special_position + } + } + + `when`("욕설/비속어 패턴이 포함되면") { + then("profanity 에러를 반환한다") { + val result = useCase("시발", 2, 16) + (result is NicknameValidationResult.Invalid) shouldBe true + (result as NicknameValidationResult.Invalid).resIdOrNull() shouldBe R.string.nickname_error_profanity + } + } + + `when`("모든 조건을 만족하면") { + then("Valid를 반환한다") { + useCase("먹짱_23", 2, 16) shouldBe NicknameValidationResult.Valid + } + } + } +}) diff --git a/app/src/test/java/com/eatssu/android/domain/usecase/widget/GetTodayMealUseCaseBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/domain/usecase/widget/GetTodayMealUseCaseBehaviorSpec.kt new file mode 100644 index 000000000..fc5d2a3cb --- /dev/null +++ b/app/src/test/java/com/eatssu/android/domain/usecase/widget/GetTodayMealUseCaseBehaviorSpec.kt @@ -0,0 +1,69 @@ +package com.eatssu.android.domain.usecase.widget + +import com.eatssu.android.domain.repository.MealRepository +import com.eatssu.android.presentation.widget.WidgetMealList +import com.eatssu.android.test.AppBehaviorSpec +import com.eatssu.common.enums.Restaurant +import com.eatssu.common.enums.Time +import io.kotest.matchers.shouldBe +import io.mockk.coEvery +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import java.net.UnknownHostException + +@OptIn(ExperimentalCoroutinesApi::class) +class GetTodayMealUseCaseBehaviorSpec : AppBehaviorSpec({ + + given("위젯 오늘의 식단 조회") { + val mealRepository = mockk() + val useCase = GetTodayMealUseCase(mealRepository) + + `when`("아침/점심/저녁 조회가 모두 성공하면") { + val breakfast = listOf(listOf("아침A")) + val lunch = listOf(listOf("점심A", "점심B")) + val dinner = listOf(listOf("저녁A")) + + coEvery { mealRepository.getTodayMeal("2025-01-01", "HAKSIK", Time.MORNING.name) } returns breakfast + coEvery { mealRepository.getTodayMeal("2025-01-01", "HAKSIK", Time.LUNCH.name) } returns lunch + coEvery { mealRepository.getTodayMeal("2025-01-01", "HAKSIK", Time.DINNER.name) } returns dinner + + then("MealState.Success와 WidgetMealList를 반환한다") { + runTest { + useCase("2025-01-01", "HAKSIK") shouldBe MealState.Success( + WidgetMealList( + breakfast = breakfast to "breakfast", + lunch = lunch to "lunch", + dinner = dinner to "dinner", + restaurant = Restaurant.HAKSIK, + ) + ) + } + } + } + + `when`("네트워크 주소 해석 예외가 발생하면") { + coEvery { + mealRepository.getTodayMeal("2025-01-01", "HAKSIK", Time.MORNING.name) + } throws UnknownHostException("offline") + + then("MealState.Failure를 반환한다") { + runTest { + useCase("2025-01-01", "HAKSIK") shouldBe MealState.Failure + } + } + } + + `when`("알 수 없는 예외가 발생하면") { + coEvery { + mealRepository.getTodayMeal("2025-01-01", "HAKSIK", Time.MORNING.name) + } throws IllegalStateException("boom") + + then("MealState.Failure를 반환한다") { + runTest { + useCase("2025-01-01", "HAKSIK") shouldBe MealState.Failure + } + } + } + } +}) From f1f523540069e407eb280662fd695d9ebc4ec4b2 Mon Sep 17 00:00:00 2001 From: PeraSite Date: Sun, 15 Feb 2026 00:39:08 +0900 Subject: [PATCH 05/21] =?UTF-8?q?test:=20ApiResult=20=EC=9C=A0=ED=8B=B8?= =?UTF-8?q?=EA=B3=BC=20=EA=B3=B5=ED=86=B5=20=EA=B3=84=EC=95=BD=20BDD=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=EB=A5=BC=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?=ED=96=88=EC=96=B4=EC=9A=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../data/model/ApiResultBehaviorSpec.kt | 56 ++++++++ .../util/CalendarUtilBehaviorSpec.kt | 46 +++++++ .../presentation/util/TimeUtilBehaviorSpec.kt | 37 ++++++ .../widget/WidgetCacheManagerBehaviorSpec.kt | 80 ++++++++++++ .../WidgetDataDisplayManagerBehaviorSpec.kt | 123 ++++++++++++++++++ .../eatssu/common/UiContractBehaviorSpec.kt | 39 ++++++ .../eatssu/common/enums/EnumsBehaviorSpec.kt | 68 ++++++++++ 7 files changed, 449 insertions(+) create mode 100644 app/src/test/java/com/eatssu/android/data/model/ApiResultBehaviorSpec.kt create mode 100644 app/src/test/java/com/eatssu/android/presentation/util/CalendarUtilBehaviorSpec.kt create mode 100644 app/src/test/java/com/eatssu/android/presentation/util/TimeUtilBehaviorSpec.kt create mode 100644 app/src/test/java/com/eatssu/android/presentation/widget/WidgetCacheManagerBehaviorSpec.kt create mode 100644 app/src/test/java/com/eatssu/android/presentation/widget/util/WidgetDataDisplayManagerBehaviorSpec.kt create mode 100644 core/common/src/test/java/com/eatssu/common/UiContractBehaviorSpec.kt create mode 100644 core/common/src/test/java/com/eatssu/common/enums/EnumsBehaviorSpec.kt diff --git a/app/src/test/java/com/eatssu/android/data/model/ApiResultBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/data/model/ApiResultBehaviorSpec.kt new file mode 100644 index 000000000..1c8ab3c71 --- /dev/null +++ b/app/src/test/java/com/eatssu/android/data/model/ApiResultBehaviorSpec.kt @@ -0,0 +1,56 @@ +package com.eatssu.android.data.model + +import com.eatssu.android.test.AppBehaviorSpec +import io.kotest.matchers.shouldBe +import java.io.IOException + +class ApiResultBehaviorSpec : AppBehaviorSpec({ + + given("ApiResult 확장 함수") { + `when`("isSuccess를 호출하면") { + then("Success(Unit)에서만 true를 반환한다") { + ApiResult.Success(Unit).isSuccess() shouldBe true + ApiResult.Failure(400, "bad").isSuccess() shouldBe false + } + } + + `when`("orEmptyList를 호출하면") { + then("Success는 원본 리스트, 실패는 빈 리스트를 반환한다") { + ApiResult.Success(listOf(1, 2, 3)).orEmptyList() shouldBe listOf(1, 2, 3) + ApiResult.Failure(500, "err").orEmptyList>() shouldBe emptyList() + } + } + + `when`("orElse를 호출하면") { + then("Success는 데이터, 실패는 기본값을 반환한다") { + ApiResult.Success("value").orElse("default") shouldBe "value" + ApiResult.Failure(401, "unauthorized").orElse("default") shouldBe "default" + } + } + + `when`("orNull을 호출하면") { + then("Success는 데이터, 실패는 null을 반환한다") { + ApiResult.Success(42).orNull() shouldBe 42 + ApiResult.Failure(500, null).orNull() shouldBe null + } + } + + `when`("map을 호출하면") { + then("Success는 transform되고 실패 타입은 유지된다") { + ApiResult.Success(10).map { it * 2 } shouldBe ApiResult.Success(20) + + val failureMapped = ApiResult.Failure(404, "not found").map { it.toString() } + (failureMapped as ApiResult.Failure).responseCode shouldBe 404 + failureMapped.message shouldBe "not found" + + val io = IOException("offline") + val networkMapped = ApiResult.NetworkError(io).map { it.toString() } + (networkMapped as ApiResult.NetworkError).exception shouldBe io + + val unknown = IllegalStateException("boom") + val unknownMapped = ApiResult.UnknownError(unknown).map { it.toString() } + (unknownMapped as ApiResult.UnknownError).exception shouldBe unknown + } + } + } +}) diff --git a/app/src/test/java/com/eatssu/android/presentation/util/CalendarUtilBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/presentation/util/CalendarUtilBehaviorSpec.kt new file mode 100644 index 000000000..74fbf0ae6 --- /dev/null +++ b/app/src/test/java/com/eatssu/android/presentation/util/CalendarUtilBehaviorSpec.kt @@ -0,0 +1,46 @@ +package com.eatssu.android.presentation.util + +import com.eatssu.android.test.AppBehaviorSpec +import io.kotest.matchers.shouldBe +import java.time.LocalDate +import java.time.ZoneId +import java.time.format.DateTimeFormatter + +class CalendarUtilBehaviorSpec : AppBehaviorSpec({ + + given("CalendarUtil") { + `when`("monthYearFromDate를 호출하면") { + then("yyyy.MM 형식으로 변환한다") { + CalendarUtil.monthYearFromDate(LocalDate.of(2025, 1, 15)) shouldBe "2025.01" + } + } + + `when`("daysInWeekArray를 호출하면") { + then("해당 주 일요일부터 7일을 반환한다") { + val days = CalendarUtil.daysInWeekArray(LocalDate.of(2025, 1, 15)) + days.size shouldBe 7 + days.first() shouldBe LocalDate.of(2025, 1, 12) + days.last() shouldBe LocalDate.of(2025, 1, 18) + } + } + + `when`("convertMillisToDateString을 호출하면") { + then("밀리초를 yyyyMMdd 문자열로 변환한다") { + val millis = LocalDate.of(2025, 1, 1) + .atStartOfDay(ZoneId.systemDefault()) + .toInstant() + .toEpochMilli() + CalendarUtil.convertMillisToDateString(millis) shouldBe "20250101" + } + } + + `when`("getNextDayDate를 호출하면") { + then("내일 날짜의 yyyyMMdd 문자열을 반환한다") { + val expected = LocalDate.now() + .plusDays(1) + .format(DateTimeFormatter.ofPattern("yyyyMMdd")) + CalendarUtil.getNextDayDate() shouldBe expected + } + } + } +}) diff --git a/app/src/test/java/com/eatssu/android/presentation/util/TimeUtilBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/presentation/util/TimeUtilBehaviorSpec.kt new file mode 100644 index 000000000..4585c9c0b --- /dev/null +++ b/app/src/test/java/com/eatssu/android/presentation/util/TimeUtilBehaviorSpec.kt @@ -0,0 +1,37 @@ +package com.eatssu.android.presentation.util + +import com.eatssu.android.test.AppBehaviorSpec +import io.kotest.matchers.shouldBe + +class TimeUtilBehaviorSpec : AppBehaviorSpec({ + + given("TimeUtil.getTimeIndex") { + `when`("아침 범위(0~9) 값이면") { + then("0을 반환한다") { + TimeUtil.getTimeIndex(0) shouldBe 0 + TimeUtil.getTimeIndex(9) shouldBe 0 + } + } + + `when`("점심 범위(10~15) 값이면") { + then("1을 반환한다") { + TimeUtil.getTimeIndex(10) shouldBe 1 + TimeUtil.getTimeIndex(15) shouldBe 1 + } + } + + `when`("저녁 범위(16~24) 값이면") { + then("2를 반환한다") { + TimeUtil.getTimeIndex(16) shouldBe 2 + TimeUtil.getTimeIndex(24) shouldBe 2 + } + } + + `when`("그 외 값이면") { + then("3을 반환한다") { + TimeUtil.getTimeIndex(-1) shouldBe 3 + TimeUtil.getTimeIndex(25) shouldBe 3 + } + } + } +}) diff --git a/app/src/test/java/com/eatssu/android/presentation/widget/WidgetCacheManagerBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/presentation/widget/WidgetCacheManagerBehaviorSpec.kt new file mode 100644 index 000000000..011982475 --- /dev/null +++ b/app/src/test/java/com/eatssu/android/presentation/widget/WidgetCacheManagerBehaviorSpec.kt @@ -0,0 +1,80 @@ +package com.eatssu.android.presentation.widget + +import com.eatssu.android.domain.model.WidgetMealInfo +import com.eatssu.android.test.AppBehaviorSpec +import com.eatssu.common.enums.Restaurant +import io.kotest.matchers.shouldBe +import java.time.LocalDateTime + +class WidgetCacheManagerBehaviorSpec : AppBehaviorSpec({ + + given("WidgetCacheManager") { + val restaurant = Restaurant.HAKSIK + val mealInfo = WidgetMealInfo.Available( + breakfast = listOf(listOf("아침")), + lunch = listOf(listOf("점심")), + dinner = listOf(listOf("저녁")), + restaurant = restaurant, + ) + + `when`("같은 날짜로 캐시 조회하면") { + then("캐시된 데이터를 반환한다") { + WidgetCacheManager.clearAllCache() + WidgetCacheManager.cacheMealData(restaurant, mealInfo, "20250101") + + WidgetCacheManager.getCachedMealData(restaurant, "20250101") shouldBe mealInfo + } + } + + `when`("다른 날짜로 조회하면") { + then("캐시를 무효화하고 null을 반환한다") { + WidgetCacheManager.clearAllCache() + WidgetCacheManager.cacheMealData(restaurant, mealInfo, "20250101") + + WidgetCacheManager.getCachedMealData(restaurant, "20250102") shouldBe null + } + } + + `when`("캐시가 30분 초과로 만료되면") { + then("null을 반환한다") { + WidgetCacheManager.clearAllCache() + + @Suppress("UNCHECKED_CAST") + val cacheMap = WidgetCacheManager::class.java + .getDeclaredField("cacheMap") + .apply { isAccessible = true } + .get(WidgetCacheManager) as MutableMap + + cacheMap[restaurant] = WidgetCacheManager.CachedMealData( + mealInfo = mealInfo, + timestamp = LocalDateTime.now().minusMinutes(31), + date = "20250101", + ) + + WidgetCacheManager.getCachedMealData(restaurant, "20250101") shouldBe null + } + } + + `when`("식당별 캐시를 삭제하면") { + then("해당 식당 캐시는 제거된다") { + WidgetCacheManager.clearAllCache() + WidgetCacheManager.cacheMealData(restaurant, mealInfo, "20250101") + WidgetCacheManager.clearCacheForRestaurant(restaurant) + + WidgetCacheManager.getCachedMealData(restaurant, "20250101") shouldBe null + } + } + + `when`("전체 캐시를 삭제하면") { + then("모든 식당 캐시가 제거된다") { + WidgetCacheManager.clearAllCache() + WidgetCacheManager.cacheMealData(Restaurant.HAKSIK, mealInfo, "20250101") + WidgetCacheManager.cacheMealData(Restaurant.DODAM, mealInfo.copy(restaurant = Restaurant.DODAM), "20250101") + WidgetCacheManager.clearAllCache() + + WidgetCacheManager.getCachedMealData(Restaurant.HAKSIK, "20250101") shouldBe null + WidgetCacheManager.getCachedMealData(Restaurant.DODAM, "20250101") shouldBe null + } + } + } +}) diff --git a/app/src/test/java/com/eatssu/android/presentation/widget/util/WidgetDataDisplayManagerBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/presentation/widget/util/WidgetDataDisplayManagerBehaviorSpec.kt new file mode 100644 index 000000000..68a59506b --- /dev/null +++ b/app/src/test/java/com/eatssu/android/presentation/widget/util/WidgetDataDisplayManagerBehaviorSpec.kt @@ -0,0 +1,123 @@ +package com.eatssu.android.presentation.widget.util + +import com.eatssu.android.domain.model.WidgetMealInfo +import com.eatssu.android.domain.usecase.widget.GetTodayMealUseCase +import com.eatssu.android.domain.usecase.widget.MealState +import com.eatssu.android.presentation.util.CalendarUtil +import com.eatssu.android.presentation.widget.WidgetCacheManager +import com.eatssu.android.presentation.widget.WidgetMealList +import com.eatssu.android.test.AppBehaviorSpec +import com.eatssu.common.enums.Restaurant +import io.kotest.matchers.shouldBe +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest + +@OptIn(ExperimentalCoroutinesApi::class) +class WidgetDataDisplayManagerBehaviorSpec : AppBehaviorSpec({ + + given("위젯 표시 데이터 생성") { + val useCase = mockk() + val restaurant = Restaurant.HAKSIK + + mockkObject(CalendarUtil) + every { CalendarUtil.convertMillisToDateString(any()) } returns "20250101" + every { CalendarUtil.getNextDayDate() } returns "20250102" + + val todaySuccess = MealState.Success( + WidgetMealList( + breakfast = listOf(listOf("아침A")) to "breakfast", + lunch = listOf(listOf("점심A")) to "lunch", + dinner = listOf(listOf("저녁A")) to "dinner", + restaurant = restaurant, + ) + ) + + val nextDaySuccess = MealState.Success( + WidgetMealList( + breakfast = listOf(listOf("내일아침")) to "breakfast", + lunch = listOf(listOf("내일점심")) to "lunch", + dinner = listOf(listOf("내일저녁")) to "dinner", + restaurant = restaurant, + ) + ) + + `when`("캐시에 데이터가 있으면") { + WidgetCacheManager.clearAllCache() + val cached = WidgetMealInfo.Available( + breakfast = listOf(listOf("cached-b")), + lunch = listOf(listOf("cached-l")), + dinner = listOf(listOf("cached-d")), + restaurant = restaurant, + ) + WidgetCacheManager.cacheMealData(restaurant, cached, "20250101") + + then("useCase 호출 없이 캐시 데이터를 반환한다") { + runTest { + WidgetDataDisplayManager.fetchMealInfo(useCase, MealTime.Morning, restaurant) shouldBe cached + coVerify(exactly = 0) { useCase(any(), any()) } + } + } + } + + `when`("오늘 식단 조회가 성공하면") { + WidgetCacheManager.clearAllCache() + coEvery { useCase("20250101", restaurant.name) } returns todaySuccess + + then("오늘 식단을 반환하고 캐시에 저장한다") { + runTest { + val result = WidgetDataDisplayManager.fetchMealInfo(useCase, MealTime.Lunch, restaurant) + result shouldBe WidgetMealInfo.Available( + breakfast = listOf(listOf("아침A")), + lunch = listOf(listOf("점심A")), + dinner = listOf(listOf("저녁A")), + restaurant = restaurant, + ) + WidgetCacheManager.getCachedMealData(restaurant, "20250101") shouldBe result + } + } + } + + `when`("오늘 조회 실패 후 내일 조회가 성공하면") { + WidgetCacheManager.clearAllCache() + coEvery { useCase("20250101", restaurant.name) } returns MealState.Failure + coEvery { useCase("20250102", restaurant.name) } returns nextDaySuccess + + then("내일 식단 기반 결과를 반환한다") { + runTest { + val result = WidgetDataDisplayManager.fetchMealInfo(useCase, MealTime.Dinner, restaurant) + result shouldBe WidgetMealInfo.Available( + breakfast = listOf(listOf("내일아침")), + lunch = listOf(listOf("내일점심")), + dinner = listOf(listOf("내일저녁")), + restaurant = restaurant, + ) + coVerify(exactly = 1) { useCase("20250101", restaurant.name) } + coVerify(exactly = 1) { useCase("20250102", restaurant.name) } + } + } + } + + `when`("오늘/내일 모두 조회에 실패하면") { + WidgetCacheManager.clearAllCache() + coEvery { useCase("20250101", restaurant.name) } returns MealState.Failure + coEvery { useCase("20250102", restaurant.name) } returns MealState.Failure + + then("빈 리스트의 Available을 반환한다") { + runTest { + WidgetDataDisplayManager.fetchMealInfo(useCase, MealTime.Morning, restaurant) shouldBe + WidgetMealInfo.Available( + breakfast = emptyList(), + lunch = emptyList(), + dinner = emptyList(), + restaurant = restaurant, + ) + } + } + } + } +}) diff --git a/core/common/src/test/java/com/eatssu/common/UiContractBehaviorSpec.kt b/core/common/src/test/java/com/eatssu/common/UiContractBehaviorSpec.kt new file mode 100644 index 000000000..160f68fee --- /dev/null +++ b/core/common/src/test/java/com/eatssu/common/UiContractBehaviorSpec.kt @@ -0,0 +1,39 @@ +package com.eatssu.common + +import com.eatssu.common.enums.ToastType +import io.kotest.core.spec.style.BehaviorSpec +import io.kotest.matchers.shouldBe + +class UiContractBehaviorSpec : BehaviorSpec({ + + given("UiState") { + `when`("각 상태를 생성하면") { + then("타입과 payload가 의도대로 유지된다") { + UiState.Init shouldBe UiState.Init + UiState.Loading shouldBe UiState.Loading + UiState.Error shouldBe UiState.Error + UiState.Success("data") shouldBe UiState.Success("data") + } + } + } + + given("UiEvent") { + `when`("ShowToast를 생성하면") { + then("메시지와 토스트 타입을 보존한다") { + val event = UiEvent.ShowToast( + message = UiText.StringResource(resId = 1), + type = ToastType.SUCCESS, + ) + + (event.message as UiText.StringResource).resId shouldBe 1 + event.type shouldBe ToastType.SUCCESS + } + } + + `when`("NavigateBack을 사용하면") { + then("싱글톤 이벤트를 유지한다") { + UiEvent.NavigateBack shouldBe UiEvent.NavigateBack + } + } + } +}) diff --git a/core/common/src/test/java/com/eatssu/common/enums/EnumsBehaviorSpec.kt b/core/common/src/test/java/com/eatssu/common/enums/EnumsBehaviorSpec.kt new file mode 100644 index 000000000..6d186902a --- /dev/null +++ b/core/common/src/test/java/com/eatssu/common/enums/EnumsBehaviorSpec.kt @@ -0,0 +1,68 @@ +package com.eatssu.common.enums + +import com.eatssu.common.R +import com.eatssu.common.UiText +import io.kotest.core.spec.style.BehaviorSpec +import io.kotest.matchers.shouldBe +import java.util.Locale + +class EnumsBehaviorSpec : BehaviorSpec({ + + given("AppLanguage") { + `when`("코드가 매칭되면") { + then("해당 언어를 반환한다") { + AppLanguage.fromCode("ko") shouldBe AppLanguage.KOREAN + } + } + + `when`("코드가 매칭되지 않으면") { + then("KOREAN을 기본값으로 반환한다") { + AppLanguage.fromCode("en") shouldBe AppLanguage.KOREAN + } + } + + `when`("toLocale을 호출하면") { + then("코드 기반 Locale을 반환한다") { + AppLanguage.KOREAN.toLocale() shouldBe Locale("ko") + } + } + } + + given("Time") { + `when`("enum name이 유효하면") { + then("한국어 식사명을 반환한다") { + Time.fromTimeEnumName("LUNCH") shouldBe "중식" + } + } + + `when`("enum name이 유효하지 않으면") { + then("빈 문자열을 반환한다") { + Time.fromTimeEnumName("INVALID") shouldBe "" + } + } + } + + given("Restaurant") { + `when`("getVariableRestaurantList를 호출하면") { + then("menuType이 VARIABLE인 식당만 반환한다") { + val variableRestaurants = Restaurant.getVariableRestaurantList() + variableRestaurants.all { it.menuType == MenuType.VARIABLE } shouldBe true + variableRestaurants shouldBe listOf( + Restaurant.HAKSIK, + Restaurant.DODAM, + Restaurant.DORMITORY, + Restaurant.FACULTY, + ) + } + } + } + + given("ReportType") { + `when`("toUiText를 호출하면") { + then("descriptionResId 기반 StringResource를 반환한다") { + val uiText = ReportType.COPY.toUiText() + (uiText as UiText.StringResource).resId shouldBe R.string.report_type_copy + } + } + } +}) From 266b11624f6205ef7243fa038bb5dedf701a7273 Mon Sep 17 00:00:00 2001 From: PeraSite Date: Sun, 15 Feb 2026 00:44:25 +0900 Subject: [PATCH 06/21] =?UTF-8?q?test:=20=EB=84=A4=ED=8A=B8=EC=9B=8C?= =?UTF-8?q?=ED=81=AC=20=EB=9E=98=ED=8D=BC=EC=99=80=20PagingSource=20BDD=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=EB=A5=BC=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?=ED=96=88=EC=96=B4=EC=9A=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../BaseReviewPagingSourceBehaviorSpec.kt | 134 +++++++++++ .../MealReviewPagingSourceBehaviorSpec.kt | 101 +++++++++ .../MenuReviewPagingSourceBehaviorSpec.kt | 99 +++++++++ .../di/network/ApiResultCallBehaviorSpec.kt | 210 ++++++++++++++++++ .../network/TokenAuthenticatorBehaviorSpec.kt | 138 ++++++++++++ .../network/TokenInterceptorBehaviorSpec.kt | 62 ++++++ 6 files changed, 744 insertions(+) create mode 100644 app/src/test/java/com/eatssu/android/data/remote/paging/BaseReviewPagingSourceBehaviorSpec.kt create mode 100644 app/src/test/java/com/eatssu/android/data/remote/paging/MealReviewPagingSourceBehaviorSpec.kt create mode 100644 app/src/test/java/com/eatssu/android/data/remote/paging/MenuReviewPagingSourceBehaviorSpec.kt create mode 100644 app/src/test/java/com/eatssu/android/di/network/ApiResultCallBehaviorSpec.kt create mode 100644 app/src/test/java/com/eatssu/android/di/network/TokenAuthenticatorBehaviorSpec.kt create mode 100644 app/src/test/java/com/eatssu/android/di/network/TokenInterceptorBehaviorSpec.kt diff --git a/app/src/test/java/com/eatssu/android/data/remote/paging/BaseReviewPagingSourceBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/data/remote/paging/BaseReviewPagingSourceBehaviorSpec.kt new file mode 100644 index 000000000..726aad12f --- /dev/null +++ b/app/src/test/java/com/eatssu/android/data/remote/paging/BaseReviewPagingSourceBehaviorSpec.kt @@ -0,0 +1,134 @@ +package com.eatssu.android.data.remote.paging + +import androidx.paging.PagingSource +import androidx.paging.PagingState +import com.eatssu.android.domain.model.Review +import com.eatssu.android.test.AppBehaviorSpec +import io.kotest.matchers.shouldBe +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest + +private class TestBaseReviewPagingSource( + private val execute: suspend (page: Int, size: Int) -> TestResponse, +) : BaseReviewPagingSource() { + override suspend fun executeRequest(page: Int, size: Int): TestResponse = execute(page, size) + override fun TestResponse.toReviewList(): List = reviews + override fun TestResponse.hasMorePages(): Boolean = hasNext +} + +private data class TestResponse( + val reviews: List, + val hasNext: Boolean, +) + +@OptIn(ExperimentalCoroutinesApi::class) +class BaseReviewPagingSourceBehaviorSpec : AppBehaviorSpec({ + + given("BaseReviewPagingSource") { + `when`("첫 페이지 조회가 성공하고 다음 페이지가 있으면") { + val source = TestBaseReviewPagingSource { _, _ -> + TestResponse( + reviews = listOf( + Review( + reviewId = 1L, + isWriter = true, + menuLikeInfoList = emptyList(), + writerNickname = "writer", + rating = 5, + writeDate = "2025-01-01", + content = "good", + imgUrl = null, + ) + ), + hasNext = true, + ) + } + + then("prevKey는 null, nextKey는 1인 Page를 반환한다") { + runTest { + val result = source.load( + PagingSource.LoadParams.Refresh( + key = null, + loadSize = 20, + placeholdersEnabled = false, + ) + ) as PagingSource.LoadResult.Page + + result.prevKey shouldBe null + result.nextKey shouldBe 1 + result.data.size shouldBe 1 + } + } + } + + `when`("중간 페이지 조회가 성공하고 다음 페이지가 없으면") { + val source = TestBaseReviewPagingSource { _, _ -> + TestResponse(reviews = emptyList(), hasNext = false) + } + + then("prevKey는 page-1, nextKey는 null이다") { + runTest { + val result = source.load( + PagingSource.LoadParams.Append( + key = 2, + loadSize = 20, + placeholdersEnabled = false, + ) + ) as PagingSource.LoadResult.Page + + result.prevKey shouldBe 1 + result.nextKey shouldBe null + } + } + } + + `when`("요청 중 예외가 발생하면") { + val source = TestBaseReviewPagingSource { _, _ -> + throw IllegalStateException("boom") + } + + then("LoadResult.Error를 반환한다") { + runTest { + val result = source.load( + PagingSource.LoadParams.Refresh( + key = null, + loadSize = 20, + placeholdersEnabled = false, + ) + ) + (result is PagingSource.LoadResult.Error) shouldBe true + } + } + } + + `when`("getRefreshKey를 호출하면") { + val source = TestBaseReviewPagingSource { _, _ -> TestResponse(emptyList(), hasNext = true) } + val page = PagingSource.LoadResult.Page( + data = listOf( + Review( + reviewId = 1L, + isWriter = false, + menuLikeInfoList = emptyList(), + writerNickname = "writer", + rating = 3, + writeDate = "2025-01-01", + content = "ok", + imgUrl = null, + ) + ), + prevKey = 3, + nextKey = 5, + ) + val state = PagingState( + pages = listOf(page), + anchorPosition = 0, + config = androidx.paging.PagingConfig(pageSize = 20), + leadingPlaceholderCount = 0, + ) + + then("anchor 기준으로 적절한 refresh key를 계산한다") { + source.getRefreshKey(state) shouldBe 4 + } + } + } +}) diff --git a/app/src/test/java/com/eatssu/android/data/remote/paging/MealReviewPagingSourceBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/data/remote/paging/MealReviewPagingSourceBehaviorSpec.kt new file mode 100644 index 000000000..666d25668 --- /dev/null +++ b/app/src/test/java/com/eatssu/android/data/remote/paging/MealReviewPagingSourceBehaviorSpec.kt @@ -0,0 +1,101 @@ +package com.eatssu.android.data.remote.paging + +import androidx.paging.PagingSource +import com.eatssu.android.data.model.ApiResult +import com.eatssu.android.data.remote.dto.response.MealReviewListResponse +import com.eatssu.android.data.remote.service.ReviewService +import com.eatssu.android.test.AppBehaviorSpec +import io.kotest.matchers.shouldBe +import io.mockk.coEvery +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import java.io.IOException + +@OptIn(ExperimentalCoroutinesApi::class) +class MealReviewPagingSourceBehaviorSpec : AppBehaviorSpec({ + + given("MealReviewPagingSource") { + val reviewService = mockk() + + `when`("API가 성공하면") { + val response = MealReviewListResponse( + numberOfElements = 1, + hasNext = false, + dataList = listOf( + MealReviewListResponse.DataList( + reviewId = 20L, + menuList = listOf( + MealReviewListResponse.DataList.MenuList( + id = 200L, + name = "비빔밥", + isLike = false, + ) + ), + isWriter = false, + writerNickname = "guest", + rating = 4, + writtenAt = "2025-01-02", + content = "nice", + imageUrls = emptyList(), + ) + ), + ) + coEvery { reviewService.getMealReviewList(2L, 0, 20, any()) } returns ApiResult.Success(response) + val source = MealReviewPagingSource(reviewService, mealId = 2L) + + then("도메인 리뷰를 담은 Page를 반환한다") { + runTest { + val result = source.load( + PagingSource.LoadParams.Refresh( + key = null, + loadSize = 20, + placeholdersEnabled = false, + ) + ) as PagingSource.LoadResult.Page + + result.data.first().reviewId shouldBe 20L + result.nextKey shouldBe null + } + } + } + + `when`("API Failure를 받으면") { + coEvery { reviewService.getMealReviewList(2L, 0, 20, any()) } returns ApiResult.Failure(500, "oops") + val source = MealReviewPagingSource(reviewService, mealId = 2L) + + then("LoadResult.Error를 반환한다") { + runTest { + val result = source.load( + PagingSource.LoadParams.Refresh( + key = null, + loadSize = 20, + placeholdersEnabled = false, + ) + ) + (result is PagingSource.LoadResult.Error) shouldBe true + } + } + } + + `when`("API UnknownError를 받으면") { + coEvery { + reviewService.getMealReviewList(2L, 0, 20, any()) + } returns ApiResult.UnknownError(IOException("boom")) + val source = MealReviewPagingSource(reviewService, mealId = 2L) + + then("LoadResult.Error를 반환한다") { + runTest { + val result = source.load( + PagingSource.LoadParams.Refresh( + key = null, + loadSize = 20, + placeholdersEnabled = false, + ) + ) + (result is PagingSource.LoadResult.Error) shouldBe true + } + } + } + } +}) diff --git a/app/src/test/java/com/eatssu/android/data/remote/paging/MenuReviewPagingSourceBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/data/remote/paging/MenuReviewPagingSourceBehaviorSpec.kt new file mode 100644 index 000000000..fba5c3cd6 --- /dev/null +++ b/app/src/test/java/com/eatssu/android/data/remote/paging/MenuReviewPagingSourceBehaviorSpec.kt @@ -0,0 +1,99 @@ +package com.eatssu.android.data.remote.paging + +import androidx.paging.PagingSource +import com.eatssu.android.data.model.ApiResult +import com.eatssu.android.data.remote.dto.response.MenuReviewListResponse +import com.eatssu.android.data.remote.service.ReviewService +import com.eatssu.android.test.AppBehaviorSpec +import io.kotest.matchers.shouldBe +import io.mockk.coEvery +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import java.io.IOException + +@OptIn(ExperimentalCoroutinesApi::class) +class MenuReviewPagingSourceBehaviorSpec : AppBehaviorSpec({ + + given("MenuReviewPagingSource") { + val reviewService = mockk() + + `when`("API가 성공하면") { + val response = MenuReviewListResponse( + numberOfElements = 1, + hasNext = true, + dataList = listOf( + MenuReviewListResponse.DataList( + reviewId = 10L, + menu = MenuReviewListResponse.DataList.Menu( + id = 100L, + name = "돈까스", + isLike = true, + ), + isWriter = true, + writerNickname = "writer", + rating = 5, + writtenAt = "2025-01-01", + content = "good", + imageUrls = emptyList(), + ) + ), + ) + coEvery { reviewService.getMenuReviewList(1L, 0, 20, any()) } returns ApiResult.Success(response) + val source = MenuReviewPagingSource(reviewService, menuId = 1L) + + then("도메인 리뷰를 담은 Page를 반환한다") { + runTest { + val result = source.load( + PagingSource.LoadParams.Refresh( + key = null, + loadSize = 20, + placeholdersEnabled = false, + ) + ) as PagingSource.LoadResult.Page + + result.data.first().reviewId shouldBe 10L + result.nextKey shouldBe 1 + } + } + } + + `when`("API Failure를 받으면") { + coEvery { reviewService.getMenuReviewList(1L, 0, 20, any()) } returns ApiResult.Failure(400, "bad") + val source = MenuReviewPagingSource(reviewService, menuId = 1L) + + then("LoadResult.Error를 반환한다") { + runTest { + val result = source.load( + PagingSource.LoadParams.Refresh( + key = null, + loadSize = 20, + placeholdersEnabled = false, + ) + ) + (result is PagingSource.LoadResult.Error) shouldBe true + } + } + } + + `when`("API NetworkError를 받으면") { + coEvery { + reviewService.getMenuReviewList(1L, 0, 20, any()) + } returns ApiResult.NetworkError(IOException("offline")) + val source = MenuReviewPagingSource(reviewService, menuId = 1L) + + then("LoadResult.Error를 반환한다") { + runTest { + val result = source.load( + PagingSource.LoadParams.Refresh( + key = null, + loadSize = 20, + placeholdersEnabled = false, + ) + ) + (result is PagingSource.LoadResult.Error) shouldBe true + } + } + } + } +}) diff --git a/app/src/test/java/com/eatssu/android/di/network/ApiResultCallBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/di/network/ApiResultCallBehaviorSpec.kt new file mode 100644 index 000000000..9bee3abf0 --- /dev/null +++ b/app/src/test/java/com/eatssu/android/di/network/ApiResultCallBehaviorSpec.kt @@ -0,0 +1,210 @@ +package com.eatssu.android.di.network + +import app.cash.turbine.test +import com.eatssu.android.data.model.ApiResult +import com.eatssu.android.data.remote.dto.response.BaseResponse +import com.eatssu.android.presentation.base.NetworkErrorEventBus +import com.eatssu.android.test.AppBehaviorSpec +import io.kotest.matchers.shouldBe +import io.mockk.mockk +import okhttp3.Request +import okhttp3.ResponseBody.Companion.toResponseBody +import okio.Timeout +import retrofit2.Call +import retrofit2.Callback +import retrofit2.Response +import java.io.IOException +import java.lang.reflect.Type + +private class FakeBaseResponseCall( + private val requestValue: Request = Request.Builder().url("https://example.com").build(), + private val enqueueBlock: (Callback>) -> Unit, +) : Call> { + private var executed = false + private var canceled = false + + override fun enqueue(callback: Callback>) { + executed = true + enqueueBlock(callback) + } + + override fun isExecuted(): Boolean = executed + override fun clone(): Call> = FakeBaseResponseCall(requestValue, enqueueBlock) + override fun isCanceled(): Boolean = canceled + override fun cancel() { + canceled = true + } + + override fun execute(): Response> { + throw UnsupportedOperationException("Fake call supports only enqueue") + } + + override fun request(): Request = requestValue + override fun timeout(): Timeout = Timeout.NONE +} + +private fun ApiResultCall.enqueueAndGet(): ApiResult { + var result: ApiResult? = null + enqueue( + object : Callback> { + override fun onResponse(call: Call>, response: Response>) { + result = response.body() + } + + override fun onFailure(call: Call>, t: Throwable) = Unit + } + ) + return result ?: error("ApiResult not emitted") +} + +class ApiResultCallBehaviorSpec : AppBehaviorSpec({ + + given("ApiResultCall") { + `when`("HTTP 에러 + BaseResponse 에러바디 파싱 성공") { + val errorJson = """{"isSuccess":false,"code":1234,"message":"bad request"}""" + val origin = FakeBaseResponseCall { + val retrofitCall = mockk>>(relaxed = true) + it.onResponse( + retrofitCall, + Response.error(400, errorJson.toResponseBody()) + ) + } + val apiResultCall = ApiResultCall(origin, String::class.java) + + then("파싱된 code/message로 Failure를 반환한다") { + val result = apiResultCall.enqueueAndGet() as ApiResult.Failure + result.responseCode shouldBe 1234 + result.message shouldBe "bad request" + } + } + + `when`("HTTP 에러 + 에러바디 파싱 실패") { + val origin = FakeBaseResponseCall { + val retrofitCall = mockk>>(relaxed = true) + it.onResponse( + retrofitCall, + Response.error(500, "not-json".toResponseBody()) + ) + } + val apiResultCall = ApiResultCall(origin, String::class.java) + + then("HTTP code와 raw 에러 문자열로 Failure를 반환한다") { + val result = apiResultCall.enqueueAndGet() as ApiResult.Failure + result.responseCode shouldBe 500 + result.message shouldBe "not-json" + } + } + + `when`("HTTP 성공이지만 body가 null이면") { + val origin = FakeBaseResponseCall { + val retrofitCall = mockk>>(relaxed = true) + @Suppress("UNCHECKED_CAST") + it.onResponse( + retrofitCall, + Response.success(null) as Response> + ) + } + val apiResultCall = ApiResultCall(origin, String::class.java) + + then("UnknownError를 반환한다") { + val result = apiResultCall.enqueueAndGet() + (result is ApiResult.UnknownError) shouldBe true + } + } + + `when`("API 레벨에서 isSuccess=false이면") { + val origin = FakeBaseResponseCall { + val retrofitCall = mockk>>(relaxed = true) + it.onResponse( + retrofitCall, + Response.success(BaseResponse(isSuccess = false, code = 2001, message = "api fail")) + ) + } + val apiResultCall = ApiResultCall(origin, String::class.java) + + then("Failure(code,message)를 반환한다") { + val result = apiResultCall.enqueueAndGet() as ApiResult.Failure + result.responseCode shouldBe 2001 + result.message shouldBe "api fail" + } + } + + `when`("responseType이 Unit이면") { + val origin = FakeBaseResponseCall { + val retrofitCall = mockk>>(relaxed = true) + it.onResponse( + retrofitCall, + Response.success(BaseResponse(isSuccess = true, code = 0, message = "ok", result = null)) + ) + } + val apiResultCall = ApiResultCall(origin, Unit::class.java as Type) + + then("Success(Unit)을 반환한다") { + apiResultCall.enqueueAndGet() shouldBe ApiResult.Success(Unit) + } + } + + `when`("API 성공인데 result가 null이고 Unit이 아니면") { + val origin = FakeBaseResponseCall { + val retrofitCall = mockk>>(relaxed = true) + it.onResponse( + retrofitCall, + Response.success(BaseResponse(isSuccess = true, code = 0, message = "ok", result = null)) + ) + } + val apiResultCall = ApiResultCall(origin, String::class.java) + + then("UnknownError를 반환한다") { + val result = apiResultCall.enqueueAndGet() + (result is ApiResult.UnknownError) shouldBe true + } + } + + `when`("API 성공 + result 존재") { + val origin = FakeBaseResponseCall { + val retrofitCall = mockk>>(relaxed = true) + it.onResponse( + retrofitCall, + Response.success(BaseResponse(isSuccess = true, code = 0, message = "ok", result = "payload")) + ) + } + val apiResultCall = ApiResultCall(origin, String::class.java) + + then("Success(result)를 반환한다") { + apiResultCall.enqueueAndGet() shouldBe ApiResult.Success("payload") + } + } + + `when`("enqueue onFailure에서 IOException이 발생하면") { + val io = IOException("offline") + val origin = FakeBaseResponseCall { + val retrofitCall = mockk>>(relaxed = true) + it.onFailure(retrofitCall, io) + } + val apiResultCall = ApiResultCall(origin, String::class.java) + + then("NetworkError를 반환하고 NetworkErrorEventBus를 발행한다") { + NetworkErrorEventBus.networkError.test { + val result = apiResultCall.enqueueAndGet() as ApiResult.NetworkError + result.exception shouldBe io + awaitItem() shouldBe Unit + cancelAndIgnoreRemainingEvents() + } + } + } + + `when`("enqueue onFailure에서 기타 예외가 발생하면") { + val error = IllegalStateException("boom") + val origin = FakeBaseResponseCall { + val retrofitCall = mockk>>(relaxed = true) + it.onFailure(retrofitCall, error) + } + val apiResultCall = ApiResultCall(origin, String::class.java) + + then("UnknownError를 반환한다") { + val result = apiResultCall.enqueueAndGet() as ApiResult.UnknownError + result.exception shouldBe error + } + } + } +}) diff --git a/app/src/test/java/com/eatssu/android/di/network/TokenAuthenticatorBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/di/network/TokenAuthenticatorBehaviorSpec.kt new file mode 100644 index 000000000..e88a04466 --- /dev/null +++ b/app/src/test/java/com/eatssu/android/di/network/TokenAuthenticatorBehaviorSpec.kt @@ -0,0 +1,138 @@ +package com.eatssu.android.di.network + +import app.cash.turbine.test +import com.eatssu.android.domain.usecase.auth.GetAccessTokenUseCase +import com.eatssu.android.domain.usecase.auth.LogoutUseCase +import com.eatssu.android.domain.usecase.auth.ReissueAndStoreResult +import com.eatssu.android.domain.usecase.auth.ReissueAndStoreTokenUseCase +import com.eatssu.android.presentation.base.LogoutReason +import com.eatssu.android.presentation.base.TokenEventBus +import com.eatssu.android.test.AppBehaviorSpec +import io.kotest.matchers.shouldBe +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import okhttp3.Protocol +import okhttp3.Request +import okhttp3.Response +import okhttp3.ResponseBody.Companion.toResponseBody + +class TokenAuthenticatorBehaviorSpec : AppBehaviorSpec({ + + given("TokenAuthenticator") { + val getAccessTokenUseCase = mockk() + val reissueAndStoreTokenUseCase = mockk() + val logoutUseCase = mockk() + val authenticator = TokenAuthenticator( + getAccessTokenUseCase = getAccessTokenUseCase, + reissueAndStoreTokenUseCase = reissueAndStoreTokenUseCase, + logoutUseCase = logoutUseCase, + ) + + fun buildResponse( + authHeader: String?, + prior: Response? = null, + withBody: Boolean = true, + ): Response { + val request = Request.Builder() + .url("https://example.com") + .apply { + if (authHeader != null) header("Authorization", authHeader) + } + .build() + + return Response.Builder() + .request(request) + .protocol(Protocol.HTTP_1_1) + .code(401) + .message("Unauthorized") + .apply { + if (withBody) body("{}".toResponseBody()) + } + .apply { + if (prior != null) priorResponse(prior) + } + .build() + } + + coEvery { logoutUseCase() } returns Unit + + `when`("이미 2회 이상 재시도한 응답이면") { + val prior = buildResponse("Bearer old", withBody = false) + val response = buildResponse("Bearer old", prior = prior) + + then("재시도하지 않고 null을 반환한다") { + authenticator.authenticate(null, response) shouldBe null + } + } + + `when`("다른 요청이 이미 토큰을 갱신한 경우") { + every { getAccessTokenUseCase() } returns "new-token" + val response = buildResponse("Bearer old-token") + + then("저장된 토큰으로 요청 헤더를 교체해 반환한다") { + val retried = authenticator.authenticate(null, response) + + retried?.header("Authorization") shouldBe "Bearer new-token" + coVerify(exactly = 0) { reissueAndStoreTokenUseCase() } + } + } + + `when`("재발급이 성공하면") { + every { getAccessTokenUseCase() } returns "current-token" + coEvery { reissueAndStoreTokenUseCase() } returns ReissueAndStoreResult.Success("fresh-token") + val response = buildResponse("Bearer current-token") + + then("새 토큰으로 요청을 재구성한다") { + val retried = authenticator.authenticate(null, response) + retried?.header("Authorization") shouldBe "Bearer fresh-token" + } + } + + `when`("refresh token이 없어 재발급할 수 없으면") { + every { getAccessTokenUseCase() } returns "current-token" + coEvery { reissueAndStoreTokenUseCase() } returns ReissueAndStoreResult.MissingRefreshToken + val response = buildResponse("Bearer current-token") + + then("로그아웃 후 MISSING_REFRESH_TOKEN 이벤트를 발행하고 null을 반환한다") { + TokenEventBus.tokenExpired.test { + authenticator.authenticate(null, response) shouldBe null + awaitItem() shouldBe LogoutReason.MISSING_REFRESH_TOKEN + coVerify { logoutUseCase() } + cancelAndIgnoreRemainingEvents() + } + } + } + + `when`("refresh token이 만료된 경우") { + every { getAccessTokenUseCase() } returns "current-token" + coEvery { + reissueAndStoreTokenUseCase() + } returns ReissueAndStoreResult.RefreshInvalid(401, "expired") + val response = buildResponse("Bearer current-token") + + then("로그아웃 후 REFRESH_TOKEN_EXPIRED 이벤트를 발행하고 null을 반환한다") { + TokenEventBus.tokenExpired.test { + authenticator.authenticate(null, response) shouldBe null + awaitItem() shouldBe LogoutReason.REFRESH_TOKEN_EXPIRED + coVerify { logoutUseCase() } + cancelAndIgnoreRemainingEvents() + } + } + } + + `when`("재발급이 일시적 실패면") { + every { getAccessTokenUseCase() } returns "current-token" + coEvery { + reissueAndStoreTokenUseCase() + } returns ReissueAndStoreResult.TransientFailure(message = "timeout") + val response = buildResponse("Bearer current-token") + + then("로그아웃 없이 null을 반환한다") { + authenticator.authenticate(null, response) shouldBe null + coVerify(exactly = 0) { logoutUseCase() } + } + } + } +}) diff --git a/app/src/test/java/com/eatssu/android/di/network/TokenInterceptorBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/di/network/TokenInterceptorBehaviorSpec.kt new file mode 100644 index 000000000..d6eedd431 --- /dev/null +++ b/app/src/test/java/com/eatssu/android/di/network/TokenInterceptorBehaviorSpec.kt @@ -0,0 +1,62 @@ +package com.eatssu.android.di.network + +import com.eatssu.android.domain.usecase.auth.GetAccessTokenUseCase +import com.eatssu.android.test.AppBehaviorSpec +import io.kotest.matchers.shouldBe +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import okhttp3.Interceptor +import okhttp3.Protocol +import okhttp3.Request +import okhttp3.Response +import okhttp3.ResponseBody.Companion.toResponseBody + +class TokenInterceptorBehaviorSpec : AppBehaviorSpec({ + + given("TokenInterceptor") { + val getAccessTokenUseCase = mockk() + val interceptor = TokenInterceptor(getAccessTokenUseCase) + + fun buildResponse(request: Request): Response = + Response.Builder() + .request(request) + .protocol(Protocol.HTTP_1_1) + .code(200) + .message("OK") + .body("{}".toResponseBody()) + .build() + + `when`("access token이 존재하면") { + every { getAccessTokenUseCase() } returns "access-token" + val original = Request.Builder().url("https://example.com").build() + val chain = mockk() + val captured = slot() + every { chain.request() } returns original + every { chain.proceed(capture(captured)) } answers { buildResponse(captured.captured) } + + then("Content-Type과 Authorization 헤더를 추가한다") { + interceptor.intercept(chain) + + captured.captured.header("Content-Type") shouldBe "application/json" + captured.captured.header("Authorization") shouldBe "Bearer access-token" + } + } + + `when`("access token이 비어있으면") { + every { getAccessTokenUseCase() } returns " " + val original = Request.Builder().url("https://example.com").build() + val chain = mockk() + val captured = slot() + every { chain.request() } returns original + every { chain.proceed(capture(captured)) } answers { buildResponse(captured.captured) } + + then("Content-Type만 추가하고 Authorization은 생략한다") { + interceptor.intercept(chain) + + captured.captured.header("Content-Type") shouldBe "application/json" + captured.captured.header("Authorization") shouldBe null + } + } + } +}) From 4e32551486ade2098f08a852a4d1a6b1458fe0ac Mon Sep 17 00:00:00 2001 From: PeraSite Date: Sun, 15 Feb 2026 00:48:26 +0900 Subject: [PATCH 07/21] =?UTF-8?q?test:=20oauth=EC=99=80=20user=20repositor?= =?UTF-8?q?y=20BDD=20=ED=85=8C=EC=8A=A4=ED=8A=B8=EB=A5=BC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=ED=96=88=EC=96=B4=EC=9A=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../OauthRepositoryImplBehaviorSpec.kt | 107 +++++++++++ .../UserRepositoryImplBehaviorSpec.kt | 169 ++++++++++++++++++ 2 files changed, 276 insertions(+) create mode 100644 app/src/test/java/com/eatssu/android/data/remote/repository/OauthRepositoryImplBehaviorSpec.kt create mode 100644 app/src/test/java/com/eatssu/android/data/remote/repository/UserRepositoryImplBehaviorSpec.kt diff --git a/app/src/test/java/com/eatssu/android/data/remote/repository/OauthRepositoryImplBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/data/remote/repository/OauthRepositoryImplBehaviorSpec.kt new file mode 100644 index 000000000..45d241fa8 --- /dev/null +++ b/app/src/test/java/com/eatssu/android/data/remote/repository/OauthRepositoryImplBehaviorSpec.kt @@ -0,0 +1,107 @@ +package com.eatssu.android.data.remote.repository + +import com.eatssu.android.data.model.ApiResult +import com.eatssu.android.data.remote.dto.request.CheckValidTokenRequest +import com.eatssu.android.data.remote.dto.response.TokenResponse +import com.eatssu.android.data.remote.service.OauthService +import com.eatssu.android.domain.model.ReissueTokenResult +import com.eatssu.android.test.AppBehaviorSpec +import com.eatssu.common.enums.DeviceType +import io.kotest.matchers.shouldBe +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import java.io.IOException + +@OptIn(ExperimentalCoroutinesApi::class) +class OauthRepositoryImplBehaviorSpec : AppBehaviorSpec({ + + given("OauthRepositoryImpl") { + val oauthService = mockk() + val repository = OauthRepositoryImpl(oauthService) + + `when`("reissueToken이 성공하면") { + coEvery { + oauthService.getNewToken("Bearer refresh-token") + } returns ApiResult.Success(TokenResponse("new-access", "new-refresh")) + + then("Bearer prefix를 적용해 요청하고 성공 결과를 매핑한다") { + runTest { + repository.reissueToken("refresh-token") shouldBe ReissueTokenResult.Success( + com.eatssu.android.domain.model.Token("new-access", "new-refresh") + ) + coVerify(exactly = 1) { oauthService.getNewToken("Bearer refresh-token") } + } + } + } + + `when`("reissueToken이 HTTP 실패면") { + coEvery { oauthService.getNewToken("Bearer refresh-token") } returns ApiResult.Failure(401, "invalid") + + then("Failure(code,message)로 변환한다") { + runTest { + repository.reissueToken("refresh-token") shouldBe ReissueTokenResult.Failure( + responseCode = 401, + message = "invalid", + ) + } + } + } + + `when`("reissueToken이 네트워크 오류면") { + val error = IOException("offline") + coEvery { oauthService.getNewToken("Bearer refresh-token") } returns ApiResult.NetworkError(error) + + then("throwable을 담은 Failure로 변환한다") { + runTest { + repository.reissueToken("refresh-token") shouldBe ReissueTokenResult.Failure(throwable = error) + } + } + } + + `when`("login이 성공하면") { + coEvery { oauthService.loginWithKakao(any()) } returns ApiResult.Success(TokenResponse("a", "r")) + + then("도메인 토큰을 반환한다") { + runTest { + repository.login("a@b.com", "pid", DeviceType.ANDROID) shouldBe + com.eatssu.android.domain.model.Token("a", "r") + } + } + } + + `when`("login이 실패하면") { + coEvery { oauthService.loginWithKakao(any()) } returns ApiResult.Failure(400, "bad") + + then("null을 반환한다") { + runTest { + repository.login("a@b.com", "pid", DeviceType.ANDROID) shouldBe null + } + } + } + + `when`("checkValidToken을 호출하면") { + val body = CheckValidTokenRequest("access") + coEvery { oauthService.checkValidToken(body) } returns ApiResult.Success(true) + + then("성공값을 그대로 반환한다") { + runTest { + repository.checkValidToken(body) shouldBe true + } + } + } + + `when`("checkValidToken이 실패하면") { + val body = CheckValidTokenRequest("access") + coEvery { oauthService.checkValidToken(body) } returns ApiResult.Failure(500, "err") + + then("기본값 false를 반환한다") { + runTest { + repository.checkValidToken(body) shouldBe false + } + } + } + } +}) diff --git a/app/src/test/java/com/eatssu/android/data/remote/repository/UserRepositoryImplBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/data/remote/repository/UserRepositoryImplBehaviorSpec.kt new file mode 100644 index 000000000..8c9071068 --- /dev/null +++ b/app/src/test/java/com/eatssu/android/data/remote/repository/UserRepositoryImplBehaviorSpec.kt @@ -0,0 +1,169 @@ +package com.eatssu.android.data.remote.repository + +import com.eatssu.android.data.model.ApiResult +import com.eatssu.android.data.remote.dto.request.ChangeNicknameRequest +import com.eatssu.android.data.remote.dto.response.CollegeResponse +import com.eatssu.android.data.remote.dto.response.DepartmentResponse +import com.eatssu.android.data.remote.dto.response.MyNickNameResponse +import com.eatssu.android.data.remote.dto.response.UserCollegeDepartmentResponse +import com.eatssu.android.data.remote.service.UserService +import com.eatssu.android.test.AppBehaviorSpec +import io.kotest.matchers.collections.shouldHaveSize +import io.kotest.matchers.shouldBe +import io.mockk.coEvery +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest + +@OptIn(ExperimentalCoroutinesApi::class) +class UserRepositoryImplBehaviorSpec : AppBehaviorSpec({ + + given("UserRepositoryImpl") { + val userService = mockk() + val repository = UserRepositoryImpl(userService) + + `when`("닉네임 변경이 성공하면") { + coEvery { userService.changeNickname(ChangeNicknameRequest("new")) } returns ApiResult.Success(Unit) + + then("Result.success를 반환한다") { + runTest { + repository.updateUserName(ChangeNicknameRequest("new")).isSuccess shouldBe true + } + } + } + + `when`("닉네임 변경이 Failure면") { + coEvery { + userService.changeNickname(ChangeNicknameRequest("new")) + } returns ApiResult.Failure(400, "bad nickname") + + then("서버 메시지를 포함한 실패 Result를 반환한다") { + runTest { + val result = repository.updateUserName(ChangeNicknameRequest("new")) + result.isFailure shouldBe true + result.exceptionOrNull()?.message shouldBe "bad nickname" + } + } + } + + `when`("닉네임 중복검사에서 true를 받으면") { + coEvery { userService.checkNickname("ok") } returns ApiResult.Success(true) + + then("성공 Result를 반환한다") { + runTest { + repository.checkUserNameValidation("ok").isSuccess shouldBe true + } + } + } + + `when`("닉네임 중복검사에서 false를 받으면") { + coEvery { userService.checkNickname("dup") } returns ApiResult.Success(false) + + then("중복 메시지로 실패 Result를 반환한다") { + runTest { + val result = repository.checkUserNameValidation("dup") + result.isFailure shouldBe true + result.exceptionOrNull()?.message shouldBe "이미 사용 중인 닉네임이에요." + } + } + } + + `when`("내 닉네임 조회가 실패하면") { + coEvery { userService.getMyInfo() } returns ApiResult.Failure(500, "err") + + then("빈 문자열을 반환한다") { + runTest { + repository.getUserNickName() shouldBe "" + } + } + } + + `when`("단과대/학과 목록 조회 시 null 데이터가 포함되면") { + coEvery { userService.getCollegeList() } returns ApiResult.Success( + listOf( + CollegeResponse(1, "IT"), + CollegeResponse(null, "invalid"), + ) + ) + coEvery { userService.getDepartmentsByCollege(1) } returns ApiResult.Success( + listOf( + DepartmentResponse(11, "컴퓨터학부"), + DepartmentResponse(12, null), + ) + ) + + then("mapNotNull로 유효 데이터만 반환한다") { + runTest { + val colleges = repository.getTotalColleges() + colleges shouldHaveSize 1 + colleges.first().collegeName shouldBe "IT" + + val departments = repository.getTotalDepartments(1) + departments shouldHaveSize 1 + departments.first().departmentName shouldBe "컴퓨터학부" + } + } + } + + `when`("유저 단과대/학과 조회가 성공하면") { + coEvery { userService.getUserCollegeDepartment() } returns ApiResult.Success( + UserCollegeDepartmentResponse( + departmentId = 11, + departmentName = "컴퓨터학부", + collegeId = 1, + collegeName = "IT", + ) + ) + + then("도메인 Pair로 변환해 반환한다") { + runTest { + val result = repository.getUserCollegeDepartment() + result?.first?.collegeName shouldBe "IT" + result?.second?.departmentName shouldBe "컴퓨터학부" + } + } + } + + `when`("유저 학과 설정 요청이 성공하면") { + coEvery { userService.setUserDepartment(any()) } returns ApiResult.Success(Unit) + + then("true를 반환한다") { + runTest { + repository.setUserDepartment(11) shouldBe true + } + } + } + + `when`("유저 학과 설정 요청이 실패하면") { + coEvery { userService.setUserDepartment(any()) } returns ApiResult.Failure(500, "err") + + then("false를 반환한다") { + runTest { + repository.setUserDepartment(11) shouldBe false + } + } + } + + `when`("회원 탈퇴 요청이 실패하면") { + coEvery { userService.signOut() } returns ApiResult.UnknownError(IllegalStateException("boom")) + + then("기본값 false를 반환한다") { + runTest { + repository.signOut() shouldBe false + } + } + } + + `when`("내 닉네임 조회가 성공하지만 nickname이 null이면") { + coEvery { userService.getMyInfo() } returns ApiResult.Success( + MyNickNameResponse(nickname = null, provider = "KAKAO") + ) + + then("빈 문자열을 반환한다") { + runTest { + repository.getUserNickName() shouldBe "" + } + } + } + } +}) From ed3ef1fc6faf535451a701d722edc4cddeb401e9 Mon Sep 17 00:00:00 2001 From: PeraSite Date: Sun, 15 Feb 2026 00:51:11 +0900 Subject: [PATCH 08/21] =?UTF-8?q?test:=20meal=20menu=20partnership=20repos?= =?UTF-8?q?itory=20BDD=20=ED=85=8C=EC=8A=A4=ED=8A=B8=EB=A5=BC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=ED=96=88=EC=96=B4=EC=9A=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../MealRepositoryImplBehaviorSpec.kt | 83 +++++++++++++ .../MenuRepositoryImplBehaviorSpec.kt | 62 ++++++++++ .../PartnershipRepositoryImplBehaviorSpec.kt | 116 ++++++++++++++++++ 3 files changed, 261 insertions(+) create mode 100644 app/src/test/java/com/eatssu/android/data/remote/repository/MealRepositoryImplBehaviorSpec.kt create mode 100644 app/src/test/java/com/eatssu/android/data/remote/repository/MenuRepositoryImplBehaviorSpec.kt create mode 100644 app/src/test/java/com/eatssu/android/data/remote/repository/PartnershipRepositoryImplBehaviorSpec.kt diff --git a/app/src/test/java/com/eatssu/android/data/remote/repository/MealRepositoryImplBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/data/remote/repository/MealRepositoryImplBehaviorSpec.kt new file mode 100644 index 000000000..e282acf51 --- /dev/null +++ b/app/src/test/java/com/eatssu/android/data/remote/repository/MealRepositoryImplBehaviorSpec.kt @@ -0,0 +1,83 @@ +package com.eatssu.android.data.remote.repository + +import com.eatssu.android.data.model.ApiResult +import com.eatssu.android.data.remote.dto.response.GetMealResponse +import com.eatssu.android.data.remote.dto.response.MenusInformationList +import com.eatssu.android.data.remote.service.MealService +import com.eatssu.android.test.AppBehaviorSpec +import com.eatssu.common.enums.Restaurant +import com.eatssu.common.enums.Time +import io.kotest.matchers.shouldBe +import io.mockk.coEvery +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest + +@OptIn(ExperimentalCoroutinesApi::class) +class MealRepositoryImplBehaviorSpec : AppBehaviorSpec({ + + given("MealRepositoryImpl") { + val mealService = mockk() + val repository = MealRepositoryImpl(mealService) + + val mealResponse = listOf( + GetMealResponse( + mealId = 10L, + price = 5000, + rating = 4.0, + briefMenus = listOf( + MenusInformationList(menuId = 1L, name = "제육"), + MenusInformationList(menuId = 2L, name = "계란찜"), + ), + ) + ) + + `when`("getTodayMeal API가 성공하면") { + coEvery { mealService.getTodayMeal("2025-01-01", "HAKSIK", "LUNCH") } returns ApiResult.Success(mealResponse) + + then("메뉴 이름 리스트 리스트로 변환한다") { + runTest { + repository.getTodayMeal("2025-01-01", "HAKSIK", "LUNCH") shouldBe listOf( + listOf("제육", "계란찜") + ) + } + } + } + + `when`("getTodayMeal API가 실패하면") { + coEvery { mealService.getTodayMeal("2025-01-01", "HAKSIK", "LUNCH") } returns ApiResult.Failure(500, "err") + + then("빈 리스트를 반환한다") { + runTest { + repository.getTodayMeal("2025-01-01", "HAKSIK", "LUNCH") shouldBe emptyList() + } + } + } + + `when`("getTodayMenuList API가 성공하면") { + coEvery { + mealService.getTodayMeal("2025-01-01", Restaurant.HAKSIK.toString(), Time.LUNCH.toString()) + } returns ApiResult.Success(mealResponse) + + then("Menu 도메인 리스트로 변환한다") { + runTest { + val result = repository.getTodayMenuList("2025-01-01", Restaurant.HAKSIK, Time.LUNCH) + result.size shouldBe 1 + result.first().name shouldBe "제육, 계란찜" + } + } + } + + `when`("getTodayMenuList API가 실패하면") { + coEvery { + mealService.getTodayMeal("2025-01-01", Restaurant.HAKSIK.toString(), Time.LUNCH.toString()) + } returns ApiResult.UnknownError(IllegalStateException("boom")) + + then("빈 리스트를 반환한다") { + runTest { + repository.getTodayMenuList("2025-01-01", Restaurant.HAKSIK, Time.LUNCH) shouldBe emptyList() + } + } + } + } +}) diff --git a/app/src/test/java/com/eatssu/android/data/remote/repository/MenuRepositoryImplBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/data/remote/repository/MenuRepositoryImplBehaviorSpec.kt new file mode 100644 index 000000000..84d844338 --- /dev/null +++ b/app/src/test/java/com/eatssu/android/data/remote/repository/MenuRepositoryImplBehaviorSpec.kt @@ -0,0 +1,62 @@ +package com.eatssu.android.data.remote.repository + +import com.eatssu.android.data.model.ApiResult +import com.eatssu.android.data.remote.dto.response.CategoryMenuListCollection +import com.eatssu.android.data.remote.dto.response.GetFixedMenuResponse +import com.eatssu.android.data.remote.dto.response.MenuInformationList +import com.eatssu.android.data.remote.service.MenuService +import com.eatssu.android.test.AppBehaviorSpec +import com.eatssu.common.enums.Restaurant +import io.kotest.matchers.shouldBe +import io.mockk.coEvery +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest + +@OptIn(ExperimentalCoroutinesApi::class) +class MenuRepositoryImplBehaviorSpec : AppBehaviorSpec({ + + given("MenuRepositoryImpl") { + val menuService = mockk() + val repository = MenuRepositoryImpl(menuService) + + `when`("고정 메뉴 API가 성공하면") { + val response = GetFixedMenuResponse( + categoryMenuListCollection = arrayListOf( + CategoryMenuListCollection( + category = "A", + menus = arrayListOf( + MenuInformationList( + menuId = 1L, + name = "돈까스", + price = 5000, + rating = 4.5, + ) + ), + ) + ) + ) + coEvery { menuService.getFixMenu(Restaurant.FOOD_COURT.toString()) } returns ApiResult.Success(response) + + then("도메인 Menu 리스트로 매핑한다") { + runTest { + val result = repository.getFixedMenuList(Restaurant.FOOD_COURT) + result.size shouldBe 1 + result.first().name shouldBe "돈까스" + } + } + } + + `when`("고정 메뉴 API가 실패하면") { + coEvery { + menuService.getFixMenu(Restaurant.FOOD_COURT.toString()) + } returns ApiResult.Failure(500, "err") + + then("빈 리스트를 반환한다") { + runTest { + repository.getFixedMenuList(Restaurant.FOOD_COURT) shouldBe emptyList() + } + } + } + } +}) diff --git a/app/src/test/java/com/eatssu/android/data/remote/repository/PartnershipRepositoryImplBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/data/remote/repository/PartnershipRepositoryImplBehaviorSpec.kt new file mode 100644 index 000000000..e5a00eb17 --- /dev/null +++ b/app/src/test/java/com/eatssu/android/data/remote/repository/PartnershipRepositoryImplBehaviorSpec.kt @@ -0,0 +1,116 @@ +package com.eatssu.android.data.remote.repository + +import com.eatssu.android.data.model.ApiResult +import com.eatssu.android.data.remote.dto.response.PartnershipResponse +import com.eatssu.android.data.remote.dto.response.PartnershipRestaurantResponse +import com.eatssu.android.data.remote.service.PartnershipService +import com.eatssu.android.data.remote.service.UserService +import com.eatssu.android.test.AppBehaviorSpec +import io.kotest.matchers.shouldBe +import io.mockk.coEvery +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest + +@OptIn(ExperimentalCoroutinesApi::class) +class PartnershipRepositoryImplBehaviorSpec : AppBehaviorSpec({ + + given("PartnershipRepositoryImpl") { + val partnershipService = mockk() + val userService = mockk() + val repository = PartnershipRepositoryImpl(partnershipService, userService) + + `when`("전체 제휴 조회 API가 성공하면") { + val response = listOf( + PartnershipResponse( + storeName = "Cafe A", + longitude = 127.0, + latitude = 37.0, + restaurantType = "CAFE", + partnershipInfos = listOf( + PartnershipResponse.PartnershipInfo( + id = 1, + partnershipType = "DISCOUNT", + collegeName = "IT", + departmentName = "CS", + likeCount = 1, + isLiked = true, + description = "10% 할인", + startDate = "2025-01-01", + endDate = "2025-12-31", + ) + ), + ) + ) + coEvery { partnershipService.getAllPartnerships() } returns ApiResult.Success(response) + + then("도메인 Partnership 리스트를 반환한다") { + runTest { + val result = repository.getAllPartnerships() + result.size shouldBe 1 + result.first().storeName shouldBe "Cafe A" + } + } + } + + `when`("개별 제휴 조회 API가 성공하면") { + val response = PartnershipRestaurantResponse( + id = 1, + partnershipType = "DISCOUNT", + storeName = "Cafe A", + description = "10% 할인", + startDate = "2025-01-01", + endDate = "2025-12-31", + restaurantType = "CAFE", + longitude = 127.0, + latitude = 37.0, + collegeName = "IT", + departmentName = "CS", + partnershipLikeCount = 1, + likedByUser = true, + ) + coEvery { partnershipService.getPartnershipById(1) } returns ApiResult.Success(response) + + then("도메인 PartnershipRestaurant를 반환한다") { + runTest { + val result = repository.getPartnershipById(1) + result?.id shouldBe 1 + result?.storeName shouldBe "Cafe A" + result?.description shouldBe "10% 할인" + result?.collegeName shouldBe "IT" + } + } + } + + `when`("유저 학과 제휴 조회가 실패하면") { + coEvery { userService.getUserDepartmentPartnerships() } returns ApiResult.Failure(500, "err") + + then("빈 리스트를 반환한다") { + runTest { + repository.getUserCollegePartnerships() shouldBe emptyList() + } + } + } + + `when`("유저 학과 제휴 조회가 성공하면") { + val response = listOf( + PartnershipResponse( + storeName = "Cafe B", + longitude = 127.0, + latitude = 37.0, + restaurantType = "CAFE", + partnershipInfos = emptyList(), + ) + ) + coEvery { userService.getUserDepartmentPartnerships() } returns ApiResult.Success(response) + + then("도메인 Partnership 리스트를 반환한다") { + runTest { + val result = repository.getUserCollegePartnerships() + result.size shouldBe 1 + result.first().storeName shouldBe "Cafe B" + } + } + } + } +}) From bab627c0138ddf0612e702a8975c4164fff64e53 Mon Sep 17 00:00:00 2001 From: PeraSite Date: Sun, 15 Feb 2026 01:01:57 +0900 Subject: [PATCH 09/21] =?UTF-8?q?test:=20=EB=82=98=EB=A8=B8=EC=A7=80=20rem?= =?UTF-8?q?ote=20repository=20BDD=20=ED=85=8C=EC=8A=A4=ED=8A=B8=EB=A5=BC?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80=ED=96=88=EC=96=B4=EC=9A=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...eRemoteConfigRepositoryImplBehaviorSpec.kt | 96 +++++ .../HealthCheckRepositoryImplBehaviorSpec.kt | 39 ++ .../ReportRepositoryImplBehaviorSpec.kt | 45 +++ .../ReviewRepositoryImplBehaviorSpec.kt | 380 ++++++++++++++++++ 4 files changed, 560 insertions(+) create mode 100644 app/src/test/java/com/eatssu/android/data/remote/repository/FirebaseRemoteConfigRepositoryImplBehaviorSpec.kt create mode 100644 app/src/test/java/com/eatssu/android/data/remote/repository/HealthCheckRepositoryImplBehaviorSpec.kt create mode 100644 app/src/test/java/com/eatssu/android/data/remote/repository/ReportRepositoryImplBehaviorSpec.kt create mode 100644 app/src/test/java/com/eatssu/android/data/remote/repository/ReviewRepositoryImplBehaviorSpec.kt diff --git a/app/src/test/java/com/eatssu/android/data/remote/repository/FirebaseRemoteConfigRepositoryImplBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/data/remote/repository/FirebaseRemoteConfigRepositoryImplBehaviorSpec.kt new file mode 100644 index 000000000..ff1bdd825 --- /dev/null +++ b/app/src/test/java/com/eatssu/android/data/remote/repository/FirebaseRemoteConfigRepositoryImplBehaviorSpec.kt @@ -0,0 +1,96 @@ +package com.eatssu.android.data.remote.repository + +import com.eatssu.android.R +import com.eatssu.common.enums.Restaurant +import com.google.android.gms.tasks.Tasks +import com.google.firebase.remoteconfig.FirebaseRemoteConfig +import com.eatssu.android.test.AppBehaviorSpec +import io.kotest.matchers.nulls.shouldBeNull +import io.kotest.matchers.shouldBe +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest + +@OptIn(ExperimentalCoroutinesApi::class) +class FirebaseRemoteConfigRepositoryImplBehaviorSpec : AppBehaviorSpec({ + + given("FirebaseRemoteConfigRepositoryImpl") { + val remoteConfig = mockk(relaxed = true) + mockkStatic(FirebaseRemoteConfig::class) + every { FirebaseRemoteConfig.getInstance() } returns remoteConfig + every { remoteConfig.setConfigSettingsAsync(any()) } returns Tasks.forResult(null) + every { remoteConfig.setDefaultsAsync(R.xml.firebase_remote_config) } returns Tasks.forResult(null) + + val repository = FirebaseRemoteConfigRepositoryImpl() + + `when`("minimum version fetch가 성공하면") { + every { remoteConfig.fetchAndActivate() } returns Tasks.forResult(true) + every { remoteConfig.getLong("android_version_code") } returns 321L + + then("최소 버전 코드를 반환한다") { + runTest { + repository.getMinimumVersionCode() shouldBe 321L + verify(exactly = 1) { remoteConfig.fetchAndActivate() } + verify(exactly = 1) { remoteConfig.getLong("android_version_code") } + } + } + } + + `when`("minimum version fetch가 실패해도") { + every { remoteConfig.fetchAndActivate() } returns Tasks.forException(IllegalStateException("fetch fail")) + every { remoteConfig.getLong("android_version_code") } returns 100L + + then("예외를 삼키고 캐시 값 반환을 시도한다") { + runTest { + repository.getMinimumVersionCode() shouldBe 100L + } + } + } + + `when`("식당 정보 JSON이 유효하고 대상 enum이 존재하면") { + every { remoteConfig.fetchAndActivate() } returns Tasks.forResult(true) + every { remoteConfig.getString("cafeteria_information") } returns """ + [ + {"enum":"HAKSIK","name":"학식당","location":"B1","image":"a.png","time":"11:00-14:00","etc":"-"}, + {"enum":"DODAM","name":"도담","location":"1F","image":"b.png","time":"11:00-14:00","etc":"-"} + ] + """.trimIndent() + + then("요청한 식당의 정보를 반환한다") { + runTest { + val result = repository.getRestaurantInfo(Restaurant.HAKSIK) + result?.enum shouldBe Restaurant.HAKSIK + result?.name shouldBe "학식당" + result?.location shouldBe "B1" + } + } + } + + `when`("식당 정보 JSON은 유효하지만 대상 enum이 없으면") { + every { remoteConfig.fetchAndActivate() } returns Tasks.forResult(true) + every { remoteConfig.getString("cafeteria_information") } returns """ + [{"enum":"HAKSIK","name":"학식당","location":"B1","image":"a.png","time":"11:00-14:00","etc":"-"}] + """.trimIndent() + + then("null을 반환한다") { + runTest { + repository.getRestaurantInfo(Restaurant.DODAM).shouldBeNull() + } + } + } + + `when`("식당 정보 JSON 파싱에 실패하면") { + every { remoteConfig.fetchAndActivate() } returns Tasks.forResult(true) + every { remoteConfig.getString("cafeteria_information") } returns "{invalid-json}" + + then("빈 리스트로 처리되어 null을 반환한다") { + runTest { + repository.getRestaurantInfo(Restaurant.HAKSIK).shouldBeNull() + } + } + } + } +}) diff --git a/app/src/test/java/com/eatssu/android/data/remote/repository/HealthCheckRepositoryImplBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/data/remote/repository/HealthCheckRepositoryImplBehaviorSpec.kt new file mode 100644 index 000000000..6ce2f513f --- /dev/null +++ b/app/src/test/java/com/eatssu/android/data/remote/repository/HealthCheckRepositoryImplBehaviorSpec.kt @@ -0,0 +1,39 @@ +package com.eatssu.android.data.remote.repository + +import com.eatssu.android.data.model.ApiResult +import com.eatssu.android.data.remote.service.HealthCheckService +import com.eatssu.android.test.AppBehaviorSpec +import io.kotest.matchers.shouldBe +import io.mockk.coEvery +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest + +@OptIn(ExperimentalCoroutinesApi::class) +class HealthCheckRepositoryImplBehaviorSpec : AppBehaviorSpec({ + + given("HealthCheckRepositoryImpl") { + val service = mockk() + val repository = HealthCheckRepositoryImpl(service) + + `when`("health check API가 성공하면") { + coEvery { service.checkHealth() } returns ApiResult.Success(Unit) + + then("true를 반환한다") { + runTest { + repository.checkHealth() shouldBe true + } + } + } + + `when`("health check API가 실패하면") { + coEvery { service.checkHealth() } returns ApiResult.Failure(500, "error") + + then("false를 반환한다") { + runTest { + repository.checkHealth() shouldBe false + } + } + } + } +}) diff --git a/app/src/test/java/com/eatssu/android/data/remote/repository/ReportRepositoryImplBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/data/remote/repository/ReportRepositoryImplBehaviorSpec.kt new file mode 100644 index 000000000..bbec35b21 --- /dev/null +++ b/app/src/test/java/com/eatssu/android/data/remote/repository/ReportRepositoryImplBehaviorSpec.kt @@ -0,0 +1,45 @@ +package com.eatssu.android.data.remote.repository + +import com.eatssu.android.data.model.ApiResult +import com.eatssu.android.data.remote.dto.request.ReportRequest +import com.eatssu.android.data.remote.service.ReportService +import com.eatssu.android.test.AppBehaviorSpec +import io.kotest.matchers.shouldBe +import io.mockk.coEvery +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest + +@OptIn(ExperimentalCoroutinesApi::class) +class ReportRepositoryImplBehaviorSpec : AppBehaviorSpec({ + + given("ReportRepositoryImpl") { + val service = mockk() + val repository = ReportRepositoryImpl(service) + val request = ReportRequest( + reviewId = 1L, + reportType = "SPAM", + content = "신고 사유", + ) + + `when`("신고 API가 성공하면") { + coEvery { service.reportReview(request) } returns ApiResult.Success(Unit) + + then("true를 반환한다") { + runTest { + repository.reportReview(request) shouldBe true + } + } + } + + `when`("신고 API가 실패하면") { + coEvery { service.reportReview(request) } returns ApiResult.UnknownError(IllegalStateException("boom")) + + then("false를 반환한다") { + runTest { + repository.reportReview(request) shouldBe false + } + } + } + } +}) diff --git a/app/src/test/java/com/eatssu/android/data/remote/repository/ReviewRepositoryImplBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/data/remote/repository/ReviewRepositoryImplBehaviorSpec.kt new file mode 100644 index 000000000..791f6605d --- /dev/null +++ b/app/src/test/java/com/eatssu/android/data/remote/repository/ReviewRepositoryImplBehaviorSpec.kt @@ -0,0 +1,380 @@ +package com.eatssu.android.data.remote.repository + +import com.eatssu.android.data.model.ApiResult +import com.eatssu.android.data.remote.dto.request.ModifyReviewRequest +import com.eatssu.android.data.remote.dto.request.WriteMealReviewRequest +import com.eatssu.android.data.remote.dto.request.WriteMenuReviewRequest +import com.eatssu.android.data.remote.dto.response.ImageResponse +import com.eatssu.android.data.remote.dto.response.MealReviewInfoResponse +import com.eatssu.android.data.remote.dto.response.MenuList +import com.eatssu.android.data.remote.dto.response.MenuOfMealResponse +import com.eatssu.android.data.remote.dto.response.MenuReviewInfoResponse +import com.eatssu.android.data.remote.dto.response.MyReviewListResponse +import com.eatssu.android.data.remote.service.ReviewService +import com.eatssu.android.domain.model.Review +import com.eatssu.android.test.AppBehaviorSpec +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.matchers.collections.shouldHaveSize +import io.kotest.matchers.shouldBe +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import java.io.File +import java.io.IOException + +@OptIn(ExperimentalCoroutinesApi::class) +class ReviewRepositoryImplBehaviorSpec : AppBehaviorSpec({ + + given("ReviewRepositoryImpl") { + val service = mockk() + val repository = ReviewRepositoryImpl(service) + + `when`("writeMealReview를 호출하면") { + val requestSlot = slot() + coEvery { service.writeMealReview(capture(requestSlot)) } returns ApiResult.Success(Unit) + + then("요청 바디를 menuLikes로 매핑하고 성공 true를 반환한다") { + runTest { + val result = repository.writeMealReview( + mealId = 10L, + rating = 4, + content = "맛있어요", + imageUrls = listOf("https://img"), + likeMenuIdList = listOf(1L, 2L), + ) + + result shouldBe true + requestSlot.captured.mealId shouldBe 10L + requestSlot.captured.rating shouldBe 4 + requestSlot.captured.content shouldBe "맛있어요" + requestSlot.captured.imageUrls shouldBe listOf("https://img") + requestSlot.captured.menuLikes shouldBe listOf( + WriteMealReviewRequest.MenuLikes(menuId = 1L, isLike = true), + WriteMealReviewRequest.MenuLikes(menuId = 2L, isLike = true), + ) + } + } + } + + `when`("writeMealReview에서 likeMenuIdList가 null이면") { + val requestSlot = slot() + coEvery { service.writeMealReview(capture(requestSlot)) } returns ApiResult.Success(Unit) + + then("menuLikes=null로 전달한다") { + runTest { + repository.writeMealReview( + mealId = 1L, + rating = 5, + content = "content", + imageUrls = emptyList(), + likeMenuIdList = null, + ) + requestSlot.captured.menuLikes shouldBe null + } + } + } + + `when`("writeMealReview API가 실패하면") { + coEvery { service.writeMealReview(any()) } returns ApiResult.Failure(400, "bad") + + then("false를 반환한다") { + runTest { + repository.writeMealReview(1L, 5, "x", emptyList(), listOf(1L)) shouldBe false + } + } + } + + `when`("writeMenuReview를 호출하면") { + val requestSlot = slot() + coEvery { service.writeMenuReview(capture(requestSlot)) } returns ApiResult.Success(Unit) + + then("첫 번째 likeMenuId만 menuLike로 매핑한다") { + runTest { + repository.writeMenuReview( + rating = 3, + content = "메뉴리뷰", + imageUrls = listOf("img"), + likeMenuIdList = listOf(9L, 10L), + ) shouldBe true + + requestSlot.captured.rating shouldBe 3 + requestSlot.captured.menuLike shouldBe WriteMenuReviewRequest.MenuLike( + menuId = 9L, + isLike = true, + ) + requestSlot.captured.imageUrls shouldBe listOf("img") + } + } + } + + `when`("writeMenuReview에서 likeMenuIdList가 null이면") { + val requestSlot = slot() + coEvery { service.writeMenuReview(capture(requestSlot)) } returns ApiResult.Success(Unit) + + then("menuLike=null로 전달한다") { + runTest { + repository.writeMenuReview( + rating = 2, + content = "x", + imageUrls = emptyList(), + likeMenuIdList = null, + ) + requestSlot.captured.menuLike shouldBe null + } + } + } + + `when`("writeMenuReview에서 likeMenuIdList가 빈 리스트면") { + then("현재 구현 그대로 NoSuchElementException이 발생한다") { + runTest { + shouldThrow { + repository.writeMenuReview( + rating = 1, + content = "x", + imageUrls = emptyList(), + likeMenuIdList = emptyList(), + ) + } + } + } + } + + `when`("deleteReview API 결과가 성공이면") { + coEvery { service.deleteReview(100L) } returns ApiResult.Success(Unit) + + then("true를 반환한다") { + runTest { + repository.deleteReview(100L) shouldBe true + } + } + } + + `when`("deleteReview API 결과가 실패면") { + coEvery { service.deleteReview(100L) } returns ApiResult.UnknownError(IllegalStateException("boom")) + + then("false를 반환한다") { + runTest { + repository.deleteReview(100L) shouldBe false + } + } + } + + `when`("modifyReview를 호출하면") { + val requestSlot = slot() + coEvery { service.modifyReview(7L, capture(requestSlot)) } returns ApiResult.Success(Unit) + + then("menuLikeInfoList를 요청 DTO로 매핑한다") { + runTest { + val menuLikeInfo = listOf( + Review.MenuLikeInfo(menuId = 1L, name = "A", isLike = true), + Review.MenuLikeInfo(menuId = 2L, name = "B", isLike = false), + ) + repository.modifyReview( + reviewId = 7L, + rating = 5, + content = "수정", + menuLikeInfoList = menuLikeInfo, + ) shouldBe true + + requestSlot.captured.rating shouldBe 5 + requestSlot.captured.content shouldBe "수정" + requestSlot.captured.menuLikes shouldBe listOf( + ModifyReviewRequest.MenuLikes(menuId = 1L, isLike = true), + ModifyReviewRequest.MenuLikes(menuId = 2L, isLike = false), + ) + } + } + } + + `when`("modifyReview API 결과가 실패면") { + coEvery { service.modifyReview(any(), any()) } returns ApiResult.Failure(500, "error") + + then("false를 반환한다") { + runTest { + repository.modifyReview( + reviewId = 1L, + rating = 1, + content = "x", + menuLikeInfoList = emptyList(), + ) shouldBe false + } + } + } + + `when`("getMealReviewInfo API가 성공하면") { + coEvery { service.getMealReviewInfo(3L) } returns ApiResult.Success( + MealReviewInfoResponse( + totalReviewCount = 12, + rating = 4.46, + reviewRatingCount = MealReviewInfoResponse.ReviewRatingCount( + oneStarCount = 1, + twoStarCount = 2, + threeStarCount = 3, + fourStarCount = 4, + fiveStarCount = 5, + ), + ) + ) + + then("도메인 ReviewInfo로 변환한다") { + runTest { + val result = repository.getMealReviewInfo(3L) + result?.reviewCnt shouldBe 12 + result?.rating shouldBe 4.5 + result?.oneStarCount shouldBe 1 + result?.fiveStarCount shouldBe 5 + } + } + } + + `when`("getMealReviewInfo API가 실패하면") { + coEvery { service.getMealReviewInfo(3L) } returns ApiResult.Failure(404, "not found") + + then("null을 반환한다") { + runTest { + repository.getMealReviewInfo(3L) shouldBe null + } + } + } + + `when`("getMenuReviewInfo API가 성공하면") { + coEvery { service.getMenuReviewInfo(4L) } returns ApiResult.Success( + MenuReviewInfoResponse( + totalReviewCount = 3, + rating = 3.24, + reviewRatingCount = null, + ) + ) + + then("도메인 ReviewInfo로 변환하며 null 카운트는 0으로 채운다") { + runTest { + val result = repository.getMenuReviewInfo(4L) + result?.reviewCnt shouldBe 3 + result?.rating shouldBe 3.2 + result?.oneStarCount shouldBe 0 + result?.fiveStarCount shouldBe 0 + } + } + } + + `when`("getMenuReviewInfo API가 실패하면") { + coEvery { service.getMenuReviewInfo(4L) } returns ApiResult.NetworkError(IOException("offline")) + + then("null을 반환한다") { + runTest { + repository.getMenuReviewInfo(4L) shouldBe null + } + } + } + + `when`("getImageString API가 성공하면") { + coEvery { service.uploadImage(any()) } returns ApiResult.Success(ImageResponse(url = "https://img")) + + then("업로드된 이미지 URL을 반환한다") { + runTest { + val file = File.createTempFile("review", ".jpg").apply { writeText("x") } + try { + repository.getImageString(file) shouldBe "https://img" + } finally { + file.delete() + } + coVerify(exactly = 1) { service.uploadImage(any()) } + } + } + } + + `when`("getImageString API가 실패하면") { + coEvery { service.uploadImage(any()) } returns ApiResult.Failure(500, "error") + + then("null을 반환한다") { + runTest { + val file = File.createTempFile("review", ".jpg").apply { writeText("x") } + try { + repository.getImageString(file) shouldBe null + } finally { + file.delete() + } + } + } + } + + `when`("getValidMenusByMealId API가 성공하면") { + coEvery { service.getMenuInfoByMealId(8L) } returns ApiResult.Success( + MenuOfMealResponse( + menuList = arrayListOf( + MenuList(menuId = 11L, name = "돈까스"), + MenuList(menuId = null, name = null), + ) + ) + ) + + then("MenuMini 리스트로 변환하며 null 필드는 기본값으로 매핑한다") { + runTest { + val result = repository.getValidMenusByMealId(8L) + result shouldHaveSize 2 + result[0].id shouldBe 11L + result[0].name shouldBe "돈까스" + result[1].id shouldBe -1L + result[1].name shouldBe "" + } + } + } + + `when`("getValidMenusByMealId API가 실패하면") { + coEvery { service.getMenuInfoByMealId(8L) } returns ApiResult.Failure(500, "error") + + then("빈 리스트를 반환한다") { + runTest { + repository.getValidMenusByMealId(8L) shouldBe emptyList() + } + } + } + + `when`("getMyReviews API가 성공하면") { + coEvery { service.getMyReviews() } returns ApiResult.Success( + MyReviewListResponse( + dataList = arrayListOf( + MyReviewListResponse.DataList( + reviewId = 15L, + rating = 5, + writtenAt = "2025-01-01", + content = "great", + imageUrls = arrayListOf("https://img"), + menuList = arrayListOf( + MyReviewListResponse.DataList.MenuList( + id = 7L, + name = "제육", + isLike = true, + ) + ), + ) + ) + ) + ) + + then("Review 리스트로 매핑한다") { + runTest { + val result = repository.getMyReviews() + result shouldHaveSize 1 + result.first().reviewId shouldBe 15L + result.first().menuLikeInfoList.first().menuId shouldBe 7L + result.first().imgUrl shouldBe "https://img" + } + } + } + + `when`("getMyReviews API가 실패하면") { + coEvery { service.getMyReviews() } returns ApiResult.UnknownError(IllegalStateException("boom")) + + then("빈 리스트를 반환한다") { + runTest { + repository.getMyReviews() shouldBe emptyList() + } + } + } + } +}) From fe48d515a269557d6ba913848d99459c659ba7e5 Mon Sep 17 00:00:00 2001 From: PeraSite Date: Sun, 15 Feb 2026 01:06:22 +0900 Subject: [PATCH 10/21] =?UTF-8?q?test:=20=EB=82=98=EB=A8=B8=EC=A7=80=20dom?= =?UTF-8?q?ain=20usecase=20BDD=20=ED=85=8C=EC=8A=A4=ED=8A=B8=EB=A5=BC=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=ED=96=88=EC=96=B4=EC=9A=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...mNotificationStatusUseCasesBehaviorSpec.kt | 60 ++++++ .../usecase/alarm/AlarmUseCaseBehaviorSpec.kt | 84 ++++++++ .../AuthDelegatingUseCasesBehaviorSpec.kt | 200 ++++++++++++++++++ .../health/HealthCheckUseCaseBehaviorSpec.kt | 40 ++++ .../GetValidMenusOfMealUseCaseBehaviorSpec.kt | 45 ++++ .../ReviewDelegatingUseCasesBehaviorSpec.kt | 169 +++++++++++++++ .../UserDelegatingUseCasesBehaviorSpec.kt | 190 +++++++++++++++++ .../WidgetRestaurantUseCasesBehaviorSpec.kt | 56 +++++ 8 files changed, 844 insertions(+) create mode 100644 app/src/test/java/com/eatssu/android/domain/usecase/alarm/AlarmNotificationStatusUseCasesBehaviorSpec.kt create mode 100644 app/src/test/java/com/eatssu/android/domain/usecase/alarm/AlarmUseCaseBehaviorSpec.kt create mode 100644 app/src/test/java/com/eatssu/android/domain/usecase/auth/AuthDelegatingUseCasesBehaviorSpec.kt create mode 100644 app/src/test/java/com/eatssu/android/domain/usecase/health/HealthCheckUseCaseBehaviorSpec.kt create mode 100644 app/src/test/java/com/eatssu/android/domain/usecase/menu/GetValidMenusOfMealUseCaseBehaviorSpec.kt create mode 100644 app/src/test/java/com/eatssu/android/domain/usecase/review/ReviewDelegatingUseCasesBehaviorSpec.kt create mode 100644 app/src/test/java/com/eatssu/android/domain/usecase/user/UserDelegatingUseCasesBehaviorSpec.kt create mode 100644 app/src/test/java/com/eatssu/android/domain/usecase/widget/WidgetRestaurantUseCasesBehaviorSpec.kt diff --git a/app/src/test/java/com/eatssu/android/domain/usecase/alarm/AlarmNotificationStatusUseCasesBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/domain/usecase/alarm/AlarmNotificationStatusUseCasesBehaviorSpec.kt new file mode 100644 index 000000000..bcfae0cf9 --- /dev/null +++ b/app/src/test/java/com/eatssu/android/domain/usecase/alarm/AlarmNotificationStatusUseCasesBehaviorSpec.kt @@ -0,0 +1,60 @@ +package com.eatssu.android.domain.usecase.alarm + +import com.eatssu.android.data.local.SettingDataStore +import com.eatssu.android.test.AppBehaviorSpec +import io.kotest.matchers.shouldBe +import io.mockk.coJustRun +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest + +@OptIn(ExperimentalCoroutinesApi::class) +class AlarmNotificationStatusUseCasesBehaviorSpec : AppBehaviorSpec({ + + given("GetDailyNotificationStatusUseCase") { + val settingDataStore = mockk() + val useCase = GetDailyNotificationStatusUseCase(settingDataStore) + + `when`("저장된 상태가 true면") { + every { settingDataStore.dailyNotificationStatus } returns flowOf(true) + + then("true를 emit하는 flow를 반환한다") { + runTest { + useCase().collect { status -> + status shouldBe true + } + } + } + } + + `when`("저장된 상태가 false면") { + every { settingDataStore.dailyNotificationStatus } returns flowOf(false) + + then("false를 emit하는 flow를 반환한다") { + runTest { + useCase().collect { status -> + status shouldBe false + } + } + } + } + } + + given("SetDailyNotificationStatusUseCase") { + val settingDataStore = mockk() + val useCase = SetDailyNotificationStatusUseCase(settingDataStore) + coJustRun { settingDataStore.setDailyNotificationStatus(any()) } + + `when`("상태를 전달하면") { + then("SettingDataStore에 그대로 위임한다") { + runTest { + useCase(true) + coVerify(exactly = 1) { settingDataStore.setDailyNotificationStatus(true) } + } + } + } + } +}) diff --git a/app/src/test/java/com/eatssu/android/domain/usecase/alarm/AlarmUseCaseBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/domain/usecase/alarm/AlarmUseCaseBehaviorSpec.kt new file mode 100644 index 000000000..370731ab5 --- /dev/null +++ b/app/src/test/java/com/eatssu/android/domain/usecase/alarm/AlarmUseCaseBehaviorSpec.kt @@ -0,0 +1,84 @@ +package com.eatssu.android.domain.usecase.alarm + +import android.app.AlarmManager +import android.app.PendingIntent +import android.content.Context +import com.eatssu.android.test.AppBehaviorSpec +import io.kotest.matchers.shouldBe +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.verify +import java.util.Calendar + +class AlarmUseCaseBehaviorSpec : AppBehaviorSpec({ + + given("AlarmUseCase") { + val context = mockk() + val alarmManager = mockk(relaxed = true) + val pendingIntent = mockk() + val calendar = mockk(relaxed = true) + val flags = PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + + every { context.getSystemService(Context.ALARM_SERVICE) } returns alarmManager + mockkStatic(PendingIntent::class) + every { PendingIntent.getBroadcast(context, 0, any(), flags) } returns pendingIntent + mockkStatic(Calendar::class) + every { Calendar.getInstance() } returns calendar + every { calendar.set(Calendar.HOUR_OF_DAY, 11) } returns Unit + every { calendar.set(Calendar.MINUTE, 0) } returns Unit + every { calendar.set(Calendar.SECOND, 0) } returns Unit + every { calendar.set(Calendar.MILLISECOND, 0) } returns Unit + every { calendar.add(any(), any()) } returns Unit + + val useCase = AlarmUseCase(context) + + `when`("알람 시각이 현재 시각보다 과거면") { + every { calendar.timeInMillis } returnsMany listOf(0L, 86_400_000L) + + then("다음 날로 하루 추가 후 repeating 알람을 등록한다") { + useCase.scheduleAlarm() + + verify(exactly = 1) { calendar.add(Calendar.DAY_OF_YEAR, 1) } + verify(exactly = 1) { + alarmManager.setRepeating( + AlarmManager.RTC_WAKEUP, + 86_400_000L, + AlarmManager.INTERVAL_DAY, + pendingIntent, + ) + } + } + } + + `when`("알람 시각이 현재 시각보다 미래면") { + every { calendar.timeInMillis } returnsMany listOf(Long.MAX_VALUE, Long.MAX_VALUE) + + then("하루 추가 없이 repeating 알람을 등록한다") { + useCase.scheduleAlarm() + + verify(exactly = 0) { calendar.add(Calendar.DAY_OF_YEAR, 1) } + verify(exactly = 1) { + alarmManager.setRepeating( + AlarmManager.RTC_WAKEUP, + Long.MAX_VALUE, + AlarmManager.INTERVAL_DAY, + pendingIntent, + ) + } + } + } + + `when`("cancelAlarm을 호출하면") { + then("등록했던 pendingIntent로 알람을 취소한다") { + useCase.cancelAlarm() + + verify(exactly = 1) { alarmManager.cancel(pendingIntent) } + } + } + + then("PendingIntent 생성 플래그는 고정 값을 사용한다") { + flags shouldBe (PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT) + } + } +}) diff --git a/app/src/test/java/com/eatssu/android/domain/usecase/auth/AuthDelegatingUseCasesBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/domain/usecase/auth/AuthDelegatingUseCasesBehaviorSpec.kt new file mode 100644 index 000000000..89def3b02 --- /dev/null +++ b/app/src/test/java/com/eatssu/android/domain/usecase/auth/AuthDelegatingUseCasesBehaviorSpec.kt @@ -0,0 +1,200 @@ +package com.eatssu.android.domain.usecase.auth + +import com.eatssu.android.data.local.AccountDataStore +import com.eatssu.android.data.local.SettingDataStore +import com.eatssu.android.data.local.TokenStore +import com.eatssu.android.data.remote.dto.request.CheckValidTokenRequest +import com.eatssu.android.domain.model.ReissueTokenResult +import com.eatssu.android.domain.repository.OauthRepository +import com.eatssu.android.domain.repository.UserRepository +import com.eatssu.android.test.AppBehaviorSpec +import com.eatssu.android.test.sampleToken +import com.eatssu.common.enums.DeviceType +import io.kotest.matchers.shouldBe +import io.mockk.Runs +import io.mockk.coEvery +import io.mockk.coJustRun +import io.mockk.coVerify +import io.mockk.coVerifyOrder +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.slot +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest + +@OptIn(ExperimentalCoroutinesApi::class) +class AuthDelegatingUseCasesBehaviorSpec : AppBehaviorSpec({ + + given("GetAccessTokenUseCase") { + val tokenStore = mockk() + every { tokenStore.accessToken } returns "access-token" + + `when`("invoke를 호출하면") { + val useCase = GetAccessTokenUseCase(tokenStore) + + then("TokenStore.accessToken 값을 반환한다") { + useCase() shouldBe "access-token" + } + } + } + + given("GetRefreshTokenUseCase") { + val tokenStore = mockk() + every { tokenStore.refreshToken } returns "refresh-token" + + `when`("invoke를 호출하면") { + val useCase = GetRefreshTokenUseCase(tokenStore) + + then("TokenStore.refreshToken 값을 반환한다") { + useCase() shouldBe "refresh-token" + } + } + } + + given("SetAccessTokenUseCase") { + val tokenStore = mockk() + every { tokenStore.accessToken = "new-access" } just Runs + val useCase = SetAccessTokenUseCase(tokenStore) + + `when`("토큰을 전달하면") { + then("TokenStore.accessToken setter를 호출한다") { + useCase("new-access") + io.mockk.verify(exactly = 1) { tokenStore.accessToken = "new-access" } + } + } + } + + given("SetRefreshTokenUseCase") { + val tokenStore = mockk() + every { tokenStore.refreshToken = "new-refresh" } just Runs + val useCase = SetRefreshTokenUseCase(tokenStore) + + `when`("토큰을 전달하면") { + then("TokenStore.refreshToken setter를 호출한다") { + useCase("new-refresh") + io.mockk.verify(exactly = 1) { tokenStore.refreshToken = "new-refresh" } + } + } + } + + given("LoginUseCase") { + val oauthRepository = mockk() + val useCase = LoginUseCase(oauthRepository) + + `when`("repository가 token을 반환하면") { + val token = sampleToken("a", "r") + coEvery { oauthRepository.login("a@b.com", "provider-id", DeviceType.ANDROID) } returns token + + then("동일 token을 반환한다") { + runTest { + useCase("a@b.com", "provider-id", DeviceType.ANDROID) shouldBe token + } + } + } + + `when`("repository가 null을 반환하면") { + coEvery { oauthRepository.login(any(), any(), any()) } returns null + + then("null을 반환한다") { + runTest { + useCase("a@b.com", "provider-id", DeviceType.ANDROID) shouldBe null + } + } + } + } + + given("LogoutUseCase") { + val accountDataStore = mockk() + val tokenStore = mockk() + val settingDataStore = mockk() + val useCase = LogoutUseCase(accountDataStore, tokenStore, settingDataStore) + + coJustRun { accountDataStore.clear() } + every { tokenStore.clear() } just Runs + coJustRun { settingDataStore.clear() } + + `when`("invoke를 호출하면") { + then("로컬 저장소를 순서대로 clear한다") { + runTest { + useCase() + coVerifyOrder { + accountDataStore.clear() + tokenStore.clear() + settingDataStore.clear() + } + } + } + } + } + + given("ReissueTokenUseCase") { + val oauthRepository = mockk() + val useCase = ReissueTokenUseCase(oauthRepository) + + `when`("repository가 성공 결과를 주면") { + val result = ReissueTokenResult.Success(sampleToken("newA", "newR")) + coEvery { oauthRepository.reissueToken("refresh-token") } returns result + + then("동일 결과를 반환한다") { + runTest { + useCase("refresh-token") shouldBe result + } + } + } + + `when`("repository가 실패 결과를 주면") { + val result = ReissueTokenResult.Failure(responseCode = 500, message = "error") + coEvery { oauthRepository.reissueToken("refresh-token") } returns result + + then("동일 실패 결과를 반환한다") { + runTest { + useCase("refresh-token") shouldBe result + } + } + } + } + + given("GetIsAccessTokenValidUseCase") { + val oauthRepository = mockk() + val useCase = GetIsAccessTokenValidUseCase(oauthRepository) + + `when`("토큰 유효성 검사를 요청하면") { + val bodySlot = slot() + coEvery { oauthRepository.checkValidToken(capture(bodySlot)) } returns true + + then("CheckValidTokenRequest(token)으로 위임하고 결과를 반환한다") { + runTest { + useCase("user-access-token") shouldBe true + bodySlot.captured.token shouldBe "user-access-token" + } + } + } + } + + given("SignOutUseCase") { + val userRepository = mockk() + val useCase = SignOutUseCase(userRepository) + + `when`("repository가 true를 반환하면") { + coEvery { userRepository.signOut() } returns true + + then("true를 반환한다") { + runTest { + useCase() shouldBe true + coVerify(exactly = 1) { userRepository.signOut() } + } + } + } + + `when`("repository가 false를 반환하면") { + coEvery { userRepository.signOut() } returns false + + then("false를 반환한다") { + runTest { + useCase() shouldBe false + } + } + } + } +}) diff --git a/app/src/test/java/com/eatssu/android/domain/usecase/health/HealthCheckUseCaseBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/domain/usecase/health/HealthCheckUseCaseBehaviorSpec.kt new file mode 100644 index 000000000..019862136 --- /dev/null +++ b/app/src/test/java/com/eatssu/android/domain/usecase/health/HealthCheckUseCaseBehaviorSpec.kt @@ -0,0 +1,40 @@ +package com.eatssu.android.domain.usecase.health + +import com.eatssu.android.domain.repository.HealthCheckRepository +import com.eatssu.android.test.AppBehaviorSpec +import io.kotest.matchers.shouldBe +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest + +@OptIn(ExperimentalCoroutinesApi::class) +class HealthCheckUseCaseBehaviorSpec : AppBehaviorSpec({ + + given("HealthCheckUseCase") { + val repository = mockk() + val useCase = HealthCheckUseCase(repository) + + `when`("헬스체크가 성공하면") { + coEvery { repository.checkHealth() } returns true + + then("true를 반환한다") { + runTest { + useCase() shouldBe true + coVerify(exactly = 1) { repository.checkHealth() } + } + } + } + + `when`("헬스체크가 실패하면") { + coEvery { repository.checkHealth() } returns false + + then("false를 반환한다") { + runTest { + useCase() shouldBe false + } + } + } + } +}) diff --git a/app/src/test/java/com/eatssu/android/domain/usecase/menu/GetValidMenusOfMealUseCaseBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/domain/usecase/menu/GetValidMenusOfMealUseCaseBehaviorSpec.kt new file mode 100644 index 000000000..82dabed08 --- /dev/null +++ b/app/src/test/java/com/eatssu/android/domain/usecase/menu/GetValidMenusOfMealUseCaseBehaviorSpec.kt @@ -0,0 +1,45 @@ +package com.eatssu.android.domain.usecase.menu + +import com.eatssu.android.domain.model.MenuMini +import com.eatssu.android.domain.repository.ReviewRepository +import com.eatssu.android.test.AppBehaviorSpec +import io.kotest.matchers.shouldBe +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest + +@OptIn(ExperimentalCoroutinesApi::class) +class GetValidMenusOfMealUseCaseBehaviorSpec : AppBehaviorSpec({ + + given("GetValidMenusOfMealUseCase") { + val reviewRepository = mockk() + val useCase = GetValidMenusOfMealUseCase(reviewRepository) + val menus = listOf( + MenuMini(id = 1L, name = "제육"), + MenuMini(id = 2L, name = "돈까스"), + ) + + `when`("유효 메뉴 목록 조회가 성공하면") { + coEvery { reviewRepository.getValidMenusByMealId(10L) } returns menus + + then("동일 목록을 반환한다") { + runTest { + useCase(10L) shouldBe menus + coVerify(exactly = 1) { reviewRepository.getValidMenusByMealId(10L) } + } + } + } + + `when`("유효 메뉴 목록이 비어있으면") { + coEvery { reviewRepository.getValidMenusByMealId(10L) } returns emptyList() + + then("빈 리스트를 반환한다") { + runTest { + useCase(10L) shouldBe emptyList() + } + } + } + } +}) diff --git a/app/src/test/java/com/eatssu/android/domain/usecase/review/ReviewDelegatingUseCasesBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/domain/usecase/review/ReviewDelegatingUseCasesBehaviorSpec.kt new file mode 100644 index 000000000..7e5be983e --- /dev/null +++ b/app/src/test/java/com/eatssu/android/domain/usecase/review/ReviewDelegatingUseCasesBehaviorSpec.kt @@ -0,0 +1,169 @@ +package com.eatssu.android.domain.usecase.review + +import androidx.paging.PagingData +import com.eatssu.android.data.remote.dto.request.ReportRequest +import com.eatssu.android.domain.model.Review +import com.eatssu.android.domain.repository.ReportRepository +import com.eatssu.android.domain.repository.ReviewRepository +import com.eatssu.android.test.AppBehaviorSpec +import com.eatssu.android.test.sampleReview +import com.eatssu.common.enums.MenuType +import io.kotest.matchers.shouldBe +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import java.io.File + +@OptIn(ExperimentalCoroutinesApi::class) +class ReviewDelegatingUseCasesBehaviorSpec : AppBehaviorSpec({ + + given("DeleteReviewUseCase") { + val reviewRepository = mockk() + val useCase = DeleteReviewUseCase(reviewRepository) + + `when`("삭제 요청이 성공하면") { + coEvery { reviewRepository.deleteReview(11L) } returns true + + then("true를 반환한다") { + runTest { + useCase(11L) shouldBe true + coVerify(exactly = 1) { reviewRepository.deleteReview(11L) } + } + } + } + + `when`("삭제 요청이 실패하면") { + coEvery { reviewRepository.deleteReview(11L) } returns false + + then("false를 반환한다") { + runTest { + useCase(11L) shouldBe false + } + } + } + } + + given("GetImageUrlUseCase") { + val reviewRepository = mockk() + val useCase = GetImageUrlUseCase(reviewRepository) + val file = File("image.jpg") + + `when`("이미지 업로드 URL이 존재하면") { + coEvery { reviewRepository.getImageString(file) } returns "https://img" + + then("URL을 그대로 반환한다") { + runTest { + useCase(file) shouldBe "https://img" + } + } + } + + `when`("이미지 업로드가 실패하면") { + coEvery { reviewRepository.getImageString(file) } returns null + + then("null을 반환한다") { + runTest { + useCase(file) shouldBe null + } + } + } + } + + given("GetMyReviewsUseCase") { + val reviewRepository = mockk() + val useCase = GetMyReviewsUseCase(reviewRepository) + val reviews = listOf(sampleReview(id = 1L), sampleReview(id = 2L)) + + `when`("repository가 내 리뷰 목록을 반환하면") { + coEvery { reviewRepository.getMyReviews() } returns reviews + + then("동일 목록을 반환한다") { + runTest { + useCase() shouldBe reviews + } + } + } + } + + given("GetReviewListPagedUseCase") { + val reviewRepository = mockk() + val useCase = GetReviewListPagedUseCase(reviewRepository) + val menuFlow = flowOf(PagingData.empty()) + val mealFlow = flowOf(PagingData.empty()) + + every { reviewRepository.getMenuReviewListPaged(10L) } returns menuFlow + every { reviewRepository.getMealReviewListPaged(20L) } returns mealFlow + + `when`("menuType이 FIXED면") { + then("고정 메뉴 paging flow를 반환한다") { + useCase(MenuType.FIXED, 10L) shouldBe menuFlow + } + } + + `when`("menuType이 VARIABLE면") { + then("변동 메뉴 paging flow를 반환한다") { + useCase(MenuType.VARIABLE, 20L) shouldBe mealFlow + } + } + } + + given("ModifyReviewUseCase") { + val reviewRepository = mockk() + val useCase = ModifyReviewUseCase(reviewRepository) + val menuLikes = listOf(Review.MenuLikeInfo(1L, "A", true)) + + `when`("수정 요청이 성공하면") { + coEvery { reviewRepository.modifyReview(1L, 5, "content", menuLikes) } returns true + + then("true를 반환한다") { + runTest { + useCase(1L, 5, "content", menuLikes) shouldBe true + } + } + } + + `when`("수정 요청이 실패하면") { + coEvery { reviewRepository.modifyReview(1L, 5, "content", menuLikes) } returns false + + then("false를 반환한다") { + runTest { + useCase(1L, 5, "content", menuLikes) shouldBe false + } + } + } + } + + given("PostReportUseCase") { + val reportRepository = mockk() + val useCase = PostReportUseCase(reportRepository) + val body = ReportRequest( + reviewId = 3L, + reportType = "SPAM", + content = "신고 사유", + ) + + `when`("신고가 성공하면") { + coEvery { reportRepository.reportReview(body) } returns true + + then("true를 반환한다") { + runTest { + useCase(body) shouldBe true + } + } + } + + `when`("신고가 실패하면") { + coEvery { reportRepository.reportReview(body) } returns false + + then("false를 반환한다") { + runTest { + useCase(body) shouldBe false + } + } + } + } +}) diff --git a/app/src/test/java/com/eatssu/android/domain/usecase/user/UserDelegatingUseCasesBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/domain/usecase/user/UserDelegatingUseCasesBehaviorSpec.kt new file mode 100644 index 000000000..7c90851d6 --- /dev/null +++ b/app/src/test/java/com/eatssu/android/domain/usecase/user/UserDelegatingUseCasesBehaviorSpec.kt @@ -0,0 +1,190 @@ +package com.eatssu.android.domain.usecase.user + +import com.eatssu.android.data.local.AccountDataStore +import com.eatssu.android.data.remote.dto.request.ChangeNicknameRequest +import com.eatssu.android.domain.repository.UserRepository +import com.eatssu.android.test.AppBehaviorSpec +import com.eatssu.android.test.sampleCollege +import com.eatssu.android.test.sampleDepartment +import io.kotest.matchers.shouldBe +import io.mockk.coEvery +import io.mockk.coJustRun +import io.mockk.coVerify +import io.mockk.coVerifyOrder +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest + +@OptIn(ExperimentalCoroutinesApi::class) +class UserDelegatingUseCasesBehaviorSpec : AppBehaviorSpec({ + + given("GetUserCollegeDepartmentUseCase") { + val accountDataStore = mockk() + val useCase = GetUserCollegeDepartmentUseCase(accountDataStore) + + `when`("로컬에 닉네임/단과대/학과가 모두 있으면") { + every { accountDataStore.name } returns flowOf("eatssu") + every { accountDataStore.college } returns flowOf(sampleCollege(1, "IT대학")) + every { accountDataStore.department } returns flowOf(sampleDepartment(2, "컴퓨터학부")) + + then("해당 값을 UserInfo로 반환한다") { + runTest { + val result = useCase() + result.nickname shouldBe "eatssu" + result.userCollege shouldBe sampleCollege(1, "IT대학") + result.userDepartment shouldBe sampleDepartment(2, "컴퓨터학부") + } + } + } + + `when`("단과대/학과가 비어있으면") { + every { accountDataStore.name } returns flowOf("eatssu") + every { accountDataStore.college } returns flowOf(null) + every { accountDataStore.department } returns flowOf(null) + + then("기본 placeholder 값으로 채운다") { + runTest { + val result = useCase() + result.userCollege.collegeId shouldBe -1 + result.userCollege.collegeName shouldBe "단과대" + result.userDepartment.departmentId shouldBe -1 + result.userDepartment.departmentName shouldBe "학과" + } + } + } + } + + given("GetUserNickNameUseCase") { + val userRepository = mockk() + val accountDataStore = mockk() + val useCase = GetUserNickNameUseCase(userRepository, accountDataStore) + + `when`("로컬 닉네임이 비어있지 않으면") { + every { accountDataStore.name } returns flowOf("local-nick") + + then("원격 조회 없이 로컬 닉네임을 반환한다") { + runTest { + useCase() shouldBe "local-nick" + coVerify(exactly = 0) { userRepository.getUserNickName() } + } + } + } + + `when`("로컬 닉네임이 비어있으면") { + every { accountDataStore.name } returns flowOf("") + coEvery { userRepository.getUserNickName() } returns "remote-nick" + coJustRun { accountDataStore.setName("remote-nick") } + + then("원격 닉네임을 조회해 저장 후 반환한다") { + runTest { + useCase() shouldBe "remote-nick" + coVerifyOrder { + userRepository.getUserNickName() + accountDataStore.setName("remote-nick") + } + } + } + } + } + + given("SetUserCollegeDepartmentUseCase") { + val accountDataStore = mockk() + val useCase = SetUserCollegeDepartmentUseCase(accountDataStore) + val college = sampleCollege(5, "경영대학") + val department = sampleDepartment(9, "경영학부") + + coJustRun { accountDataStore.setCollege(college) } + coJustRun { accountDataStore.setDepartment(department) } + + `when`("단과대/학과를 전달하면") { + then("둘 다 저장한다") { + runTest { + useCase(college, department) + coVerify(exactly = 1) { accountDataStore.setCollege(college) } + coVerify(exactly = 1) { accountDataStore.setDepartment(department) } + } + } + } + } + + given("SetUserEmailUseCase") { + val accountDataStore = mockk() + val useCase = SetUserEmailUseCase(accountDataStore) + + coJustRun { accountDataStore.setEmail("a@b.com") } + + `when`("이메일을 전달하면") { + then("로컬 이메일을 저장한다") { + runTest { + useCase("a@b.com") + coVerify(exactly = 1) { accountDataStore.setEmail("a@b.com") } + } + } + } + } + + given("SetUserNicknameUseCase") { + val userRepository = mockk() + val accountDataStore = mockk() + val useCase = SetUserNicknameUseCase(userRepository, accountDataStore) + val nickname = "new-nick" + val request = ChangeNicknameRequest(nickname) + + coJustRun { accountDataStore.setName(nickname) } + + `when`("원격 닉네임 변경이 성공하면") { + coEvery { userRepository.updateUserName(request) } returns Result.success(Unit) + + then("성공 결과를 반환하고 로컬 닉네임을 먼저 저장한다") { + runTest { + val result = useCase(nickname) + result.isSuccess shouldBe true + coVerifyOrder { + accountDataStore.setName(nickname) + userRepository.updateUserName(request) + } + } + } + } + + `when`("원격 닉네임 변경이 실패하면") { + coEvery { userRepository.updateUserName(request) } returns Result.failure(IllegalStateException("fail")) + + then("실패 결과를 반환해도 로컬 닉네임 저장은 수행한다") { + runTest { + val result = useCase(nickname) + result.isFailure shouldBe true + coVerify(exactly = 1) { accountDataStore.setName(nickname) } + coVerify(exactly = 1) { userRepository.updateUserName(request) } + } + } + } + } + + given("ValidateNicknameServerUseCase") { + val userRepository = mockk() + val useCase = ValidateNicknameServerUseCase(userRepository) + + `when`("서버 검증이 성공하면") { + coEvery { userRepository.checkUserNameValidation("valid-nick") } returns Result.success(Unit) + + then("성공 결과를 그대로 반환한다") { + runTest { + useCase("valid-nick").isSuccess shouldBe true + } + } + } + + `when`("서버 검증이 실패하면") { + coEvery { userRepository.checkUserNameValidation("bad-nick") } returns Result.failure(IllegalArgumentException("dup")) + + then("실패 결과를 그대로 반환한다") { + runTest { + useCase("bad-nick").isFailure shouldBe true + } + } + } + } +}) diff --git a/app/src/test/java/com/eatssu/android/domain/usecase/widget/WidgetRestaurantUseCasesBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/domain/usecase/widget/WidgetRestaurantUseCasesBehaviorSpec.kt new file mode 100644 index 000000000..998d62f1e --- /dev/null +++ b/app/src/test/java/com/eatssu/android/domain/usecase/widget/WidgetRestaurantUseCasesBehaviorSpec.kt @@ -0,0 +1,56 @@ +package com.eatssu.android.domain.usecase.widget + +import com.eatssu.android.data.local.WidgetDataStore +import com.eatssu.android.test.AppBehaviorSpec +import com.eatssu.common.enums.Restaurant +import io.kotest.matchers.shouldBe +import io.mockk.coEvery +import io.mockk.coJustRun +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest + +@OptIn(ExperimentalCoroutinesApi::class) +class WidgetRestaurantUseCasesBehaviorSpec : AppBehaviorSpec({ + + given("LoadRestaurantByFileKeyUseCase") { + val widgetDataStore = mockk() + val useCase = LoadRestaurantByFileKeyUseCase(widgetDataStore) + + `when`("저장된 식당이 있으면") { + coEvery { widgetDataStore.loadRestaurantByFileKey("file-key") } returns Restaurant.HAKSIK + + then("식당 enum을 반환한다") { + runTest { + useCase("file-key") shouldBe Restaurant.HAKSIK + } + } + } + + `when`("저장된 식당이 없으면") { + coEvery { widgetDataStore.loadRestaurantByFileKey("file-key") } returns null + + then("null을 반환한다") { + runTest { + useCase("file-key") shouldBe null + } + } + } + } + + given("SaveRestaurantByFileKeyUseCase") { + val widgetDataStore = mockk() + val useCase = SaveRestaurantByFileKeyUseCase(widgetDataStore) + coJustRun { widgetDataStore.saveRestaurantByFileKey(any(), any()) } + + `when`("fileKey와 식당을 전달하면") { + then("저장소에 그대로 위임한다") { + runTest { + useCase("file-key", Restaurant.DODAM) + coVerify(exactly = 1) { widgetDataStore.saveRestaurantByFileKey("file-key", Restaurant.DODAM) } + } + } + } + } +}) From b9580e69c86f0509ddc64d35851ef443cd024f73 Mon Sep 17 00:00:00 2001 From: PeraSite Date: Sun, 15 Feb 2026 01:09:19 +0900 Subject: [PATCH 11/21] =?UTF-8?q?test:=20DTO=20response=20mapper=20BDD=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=EB=A5=BC=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?=ED=96=88=EC=96=B4=EC=9A=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../MenuAndMealResponseMapperBehaviorSpec.kt | 124 +++++++++++ .../PartnershipResponseMapperBehaviorSpec.kt | 210 ++++++++++++++++++ .../ReviewResponseMapperBehaviorSpec.kt | 201 +++++++++++++++++ .../UserAndTokenResponseMapperBehaviorSpec.kt | 91 ++++++++ 4 files changed, 626 insertions(+) create mode 100644 app/src/test/java/com/eatssu/android/data/remote/dto/response/MenuAndMealResponseMapperBehaviorSpec.kt create mode 100644 app/src/test/java/com/eatssu/android/data/remote/dto/response/PartnershipResponseMapperBehaviorSpec.kt create mode 100644 app/src/test/java/com/eatssu/android/data/remote/dto/response/ReviewResponseMapperBehaviorSpec.kt create mode 100644 app/src/test/java/com/eatssu/android/data/remote/dto/response/UserAndTokenResponseMapperBehaviorSpec.kt diff --git a/app/src/test/java/com/eatssu/android/data/remote/dto/response/MenuAndMealResponseMapperBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/data/remote/dto/response/MenuAndMealResponseMapperBehaviorSpec.kt new file mode 100644 index 000000000..eef27d392 --- /dev/null +++ b/app/src/test/java/com/eatssu/android/data/remote/dto/response/MenuAndMealResponseMapperBehaviorSpec.kt @@ -0,0 +1,124 @@ +package com.eatssu.android.data.remote.dto.response + +import com.eatssu.android.test.AppBehaviorSpec +import io.kotest.matchers.collections.shouldHaveSize +import io.kotest.matchers.shouldBe + +class MenuAndMealResponseMapperBehaviorSpec : AppBehaviorSpec({ + + given("GetFixedMenuResponse.mapFixedMenuResponseToMenu") { + `when`("카테고리/메뉴 응답이 주어지면") { + val response = GetFixedMenuResponse( + categoryMenuListCollection = arrayListOf( + CategoryMenuListCollection( + category = "A", + menus = arrayListOf( + MenuInformationList(menuId = 1L, name = "돈까스", price = 5500, rating = 4.3), + MenuInformationList(menuId = null, name = null, price = null, rating = null), + ), + ), + CategoryMenuListCollection( + category = "B", + menus = arrayListOf( + MenuInformationList(menuId = 2L, name = "비빔밥", price = 6000, rating = 4.0), + ), + ), + ) + ) + + then("카테고리를 펼쳐 Menu 리스트로 매핑하고 null은 기본값으로 채운다") { + val result = response.mapFixedMenuResponseToMenu() + result shouldHaveSize 3 + result[0].id shouldBe 1L + result[0].name shouldBe "돈까스" + result[1].id shouldBe 0L + result[1].name shouldBe "" + result[1].price shouldBe 0 + result[1].rate shouldBe 0.0 + result[2].id shouldBe 2L + result[2].name shouldBe "비빔밥" + } + } + } + + given("List.mapTodayMenuResponseToMenu") { + `when`("식단 응답에 메뉴명이 일부 null로 섞여 있으면") { + val response = listOf( + GetMealResponse( + mealId = 5L, + price = 5000, + rating = 4.2, + briefMenus = listOf( + MenusInformationList(menuId = 1L, name = "제육"), + MenusInformationList(menuId = 2L, name = null), + MenusInformationList(menuId = 3L, name = "계란찜"), + ), + ), + GetMealResponse( + mealId = null, + price = null, + rating = null, + briefMenus = listOf( + MenusInformationList(menuId = 4L, name = null), + ), + ), + ) + + then("null 이름은 제외해 문자열로 결합하고 null 필드는 기본값으로 변환한다") { + val result = response.mapTodayMenuResponseToMenu() + result shouldHaveSize 2 + result[0].id shouldBe 5L + result[0].name shouldBe "제육, 계란찜" + result[1].id shouldBe -1L + result[1].name shouldBe "" + result[1].price shouldBe 0 + result[1].rate shouldBe 0.0 + } + } + } + + given("List.toDomain") { + `when`("식단 응답을 도메인 메뉴명 리스트로 변환하면") { + val response = listOf( + GetMealResponse( + briefMenus = listOf( + MenusInformationList(name = "짜장면"), + MenusInformationList(name = null), + ), + ), + GetMealResponse( + briefMenus = listOf( + MenusInformationList(name = "우동"), + ), + ), + ) + + then("meal 단위로 null이 제거된 문자열 리스트를 반환한다") { + response.toDomain() shouldBe listOf( + listOf("짜장면"), + listOf("우동"), + ) + } + } + } + + given("MenuOfMealResponse.toDomain") { + `when`("menuList를 변환하면") { + val response = MenuOfMealResponse( + menuList = arrayListOf( + MenuList(menuId = 1L, name = "덮밥"), + MenuList(menuId = null, name = null), + ) + ) + + then("MenuMini 리스트로 매핑하고 null은 기본값으로 채운다") { + val result = response.toDomain() + result shouldHaveSize 2 + result[0].id shouldBe 1L + result[0].name shouldBe "덮밥" + result[1].id shouldBe -1L + result[1].name shouldBe "" + } + } + } +}) diff --git a/app/src/test/java/com/eatssu/android/data/remote/dto/response/PartnershipResponseMapperBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/data/remote/dto/response/PartnershipResponseMapperBehaviorSpec.kt new file mode 100644 index 000000000..c722b9cd8 --- /dev/null +++ b/app/src/test/java/com/eatssu/android/data/remote/dto/response/PartnershipResponseMapperBehaviorSpec.kt @@ -0,0 +1,210 @@ +package com.eatssu.android.data.remote.dto.response + +import com.eatssu.android.domain.model.RestaurantType +import com.eatssu.android.test.AppBehaviorSpec +import io.kotest.matchers.shouldBe + +class PartnershipResponseMapperBehaviorSpec : AppBehaviorSpec({ + + given("PartnershipResponse.toDomain") { + `when`("restaurantType이 CAFE/RESTAURANT/PUB이면") { + then("각 enum으로 매핑한다") { + PartnershipResponse( + storeName = "A", + longitude = 1.0, + latitude = 2.0, + restaurantType = "CAFE", + partnershipInfos = emptyList(), + ).toDomain().restaurantType shouldBe RestaurantType.CAFE + + PartnershipResponse( + storeName = "B", + longitude = 1.0, + latitude = 2.0, + restaurantType = "RESTAURANT", + partnershipInfos = emptyList(), + ).toDomain().restaurantType shouldBe RestaurantType.RESTAURANT + + PartnershipResponse( + storeName = "C", + longitude = 1.0, + latitude = 2.0, + restaurantType = "PUB", + partnershipInfos = emptyList(), + ).toDomain().restaurantType shouldBe RestaurantType.PUB + } + } + + `when`("restaurantType이 null 또는 알 수 없는 값이면") { + then("RESTAURANT로 fallback한다") { + PartnershipResponse( + storeName = "D", + longitude = 1.0, + latitude = 2.0, + restaurantType = null, + partnershipInfos = emptyList(), + ).toDomain().restaurantType shouldBe RestaurantType.RESTAURANT + + PartnershipResponse( + storeName = "E", + longitude = 1.0, + latitude = 2.0, + restaurantType = "UNKNOWN", + partnershipInfos = emptyList(), + ).toDomain().restaurantType shouldBe RestaurantType.RESTAURANT + } + } + + `when`("필드가 null인 응답을 매핑하면") { + val result = PartnershipResponse( + storeName = null, + longitude = null, + latitude = null, + restaurantType = null, + partnershipInfos = listOf( + PartnershipResponse.PartnershipInfo( + id = null, + partnershipType = null, + collegeName = null, + departmentName = null, + likeCount = null, + isLiked = null, + description = null, + startDate = null, + endDate = null, + ) + ), + ).toDomain() + + then("기본값으로 채운다") { + result.storeName shouldBe "" + result.longitude shouldBe 126.95661313346206 + result.latitude shouldBe 37.49517278813046 + result.partnershipInfos.first().id shouldBe -1 + result.partnershipInfos.first().partnershipType shouldBe "" + result.partnershipInfos.first().likeCount shouldBe 0 + result.partnershipInfos.first().isLiked shouldBe false + } + } + } + + given("PartnershipRestaurantResponse.toDomain") { + `when`("restaurantType이 CAFE/RESTAURANT/PUB이면") { + then("각 enum으로 매핑한다") { + PartnershipRestaurantResponse( + id = 1, + partnershipType = "DISCOUNT", + storeName = "A", + description = "desc", + startDate = "2025-01-01", + endDate = "2025-12-31", + restaurantType = "CAFE", + longitude = 1.0, + latitude = 2.0, + collegeName = "IT", + departmentName = "CS", + partnershipLikeCount = 1, + likedByUser = true, + ).toDomain().restaurantType shouldBe RestaurantType.CAFE + + PartnershipRestaurantResponse( + id = 1, + partnershipType = "DISCOUNT", + storeName = "A", + description = "desc", + startDate = "2025-01-01", + endDate = "2025-12-31", + restaurantType = "RESTAURANT", + longitude = 1.0, + latitude = 2.0, + collegeName = "IT", + departmentName = "CS", + partnershipLikeCount = 1, + likedByUser = true, + ).toDomain().restaurantType shouldBe RestaurantType.RESTAURANT + + PartnershipRestaurantResponse( + id = 1, + partnershipType = "DISCOUNT", + storeName = "A", + description = "desc", + startDate = "2025-01-01", + endDate = "2025-12-31", + restaurantType = "PUB", + longitude = 1.0, + latitude = 2.0, + collegeName = "IT", + departmentName = "CS", + partnershipLikeCount = 1, + likedByUser = true, + ).toDomain().restaurantType shouldBe RestaurantType.PUB + } + } + + `when`("restaurantType이 null 또는 알 수 없는 값이면") { + then("RESTAURANT로 fallback한다") { + PartnershipRestaurantResponse( + id = 1, + partnershipType = "DISCOUNT", + storeName = "A", + description = "desc", + startDate = "2025-01-01", + endDate = "2025-12-31", + restaurantType = null, + longitude = 1.0, + latitude = 2.0, + collegeName = "IT", + departmentName = "CS", + partnershipLikeCount = 1, + likedByUser = true, + ).toDomain().restaurantType shouldBe RestaurantType.RESTAURANT + + PartnershipRestaurantResponse( + id = 1, + partnershipType = "DISCOUNT", + storeName = "A", + description = "desc", + startDate = "2025-01-01", + endDate = "2025-12-31", + restaurantType = "UNKNOWN", + longitude = 1.0, + latitude = 2.0, + collegeName = "IT", + departmentName = "CS", + partnershipLikeCount = 1, + likedByUser = true, + ).toDomain().restaurantType shouldBe RestaurantType.RESTAURANT + } + } + + `when`("필드가 null인 응답을 매핑하면") { + val result = PartnershipRestaurantResponse( + id = null, + partnershipType = null, + storeName = null, + description = null, + startDate = null, + endDate = null, + restaurantType = null, + longitude = null, + latitude = null, + collegeName = null, + departmentName = null, + partnershipLikeCount = null, + likedByUser = null, + ).toDomain() + + then("기본값으로 채운다") { + result.id shouldBe -1 + result.partnershipType shouldBe "" + result.storeName shouldBe "" + result.longitude shouldBe 126.95661313346206 + result.latitude shouldBe 37.49517278813046 + result.collegeName shouldBe "" + result.departmentName shouldBe "" + result.partnershipLikeCount shouldBe 0 + result.likedByUser shouldBe false + } + } + } +}) diff --git a/app/src/test/java/com/eatssu/android/data/remote/dto/response/ReviewResponseMapperBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/data/remote/dto/response/ReviewResponseMapperBehaviorSpec.kt new file mode 100644 index 000000000..c7a425a70 --- /dev/null +++ b/app/src/test/java/com/eatssu/android/data/remote/dto/response/ReviewResponseMapperBehaviorSpec.kt @@ -0,0 +1,201 @@ +package com.eatssu.android.data.remote.dto.response + +import com.eatssu.android.test.AppBehaviorSpec +import io.kotest.matchers.collections.shouldHaveSize +import io.kotest.matchers.shouldBe + +class ReviewResponseMapperBehaviorSpec : AppBehaviorSpec({ + + given("MenuReviewInfoResponse.toDomain") { + `when`("rating/count가 주어지면") { + then("소수 첫째 자리 반올림 및 null 카운트 기본값을 적용한다") { + val result = MenuReviewInfoResponse( + totalReviewCount = 10, + rating = 4.44, + reviewRatingCount = MenuReviewInfoResponse.ReviewRatingCount( + oneStarCount = 1, + twoStarCount = 2, + threeStarCount = 3, + fourStarCount = 4, + fiveStarCount = 5, + ), + ).toDomain() + + result.reviewCnt shouldBe 10 + result.rating shouldBe 4.4 + result.oneStarCount shouldBe 1 + result.fiveStarCount shouldBe 5 + } + } + + `when`("reviewRatingCount가 null이면") { + then("별점 카운트는 모두 0으로 채운다") { + val result = MenuReviewInfoResponse( + totalReviewCount = null, + rating = null, + reviewRatingCount = null, + ).toDomain() + + result.reviewCnt shouldBe 0 + result.rating shouldBe 0.0 + result.oneStarCount shouldBe 0 + result.fiveStarCount shouldBe 0 + } + } + } + + given("MealReviewInfoResponse.toDomain") { + `when`("rating/count가 주어지면") { + then("소수 첫째 자리 반올림 및 기본값 매핑을 적용한다") { + val result = MealReviewInfoResponse( + totalReviewCount = 7, + rating = 3.66, + reviewRatingCount = MealReviewInfoResponse.ReviewRatingCount( + oneStarCount = 0, + twoStarCount = 1, + threeStarCount = 2, + fourStarCount = 3, + fiveStarCount = 1, + ), + ).toDomain() + + result.reviewCnt shouldBe 7 + result.rating shouldBe 3.7 + result.twoStarCount shouldBe 1 + result.fourStarCount shouldBe 3 + } + } + } + + given("MenuReviewListResponse?.toDomain") { + `when`("응답 자체가 null이면") { + then("빈 리스트를 반환한다") { + (null as MenuReviewListResponse?).toDomain() shouldBe emptyList() + } + } + + `when`("dataList를 도메인으로 변환하면") { + val response = MenuReviewListResponse( + dataList = listOf( + MenuReviewListResponse.DataList( + reviewId = 1L, + menu = MenuReviewListResponse.DataList.Menu( + id = 10L, + name = "돈까스", + isLike = true, + ), + isWriter = true, + writerNickname = "writer", + rating = 5, + writtenAt = "2025-01-01", + content = "great", + imageUrls = listOf("https://img1", "https://img2"), + ), + MenuReviewListResponse.DataList( + reviewId = null, + menu = null, + isWriter = null, + writerNickname = null, + rating = null, + writtenAt = null, + content = null, + imageUrls = emptyList(), + ), + ) + ) + + then("기본값과 첫 번째 이미지 URL 규칙을 적용한다") { + val result = response.toDomain() + result shouldHaveSize 2 + + result[0].reviewId shouldBe 1L + result[0].menuLikeInfoList.first().menuId shouldBe 10L + result[0].menuLikeInfoList.first().isLike shouldBe true + result[0].imgUrl shouldBe "https://img1" + + result[1].reviewId shouldBe -1L + result[1].menuLikeInfoList.first().menuId shouldBe -1L + result[1].menuLikeInfoList.first().name shouldBe "" + result[1].isWriter shouldBe false + result[1].imgUrl shouldBe null + } + } + } + + given("MealReviewListResponse?.toDomain") { + `when`("응답 자체가 null이면") { + then("빈 리스트를 반환한다") { + (null as MealReviewListResponse?).toDomain() shouldBe emptyList() + } + } + + `when`("dataList를 도메인으로 변환하면") { + val response = MealReviewListResponse( + dataList = listOf( + MealReviewListResponse.DataList( + reviewId = 3L, + menuList = listOf( + MealReviewListResponse.DataList.MenuList(id = 1L, name = "제육", isLike = true), + MealReviewListResponse.DataList.MenuList(id = null, name = null, isLike = null), + ), + isWriter = false, + writerNickname = "other", + rating = 4, + writtenAt = "2025-01-02", + content = "ok", + imageUrls = listOf("https://meal"), + ) + ) + ) + + then("menuList를 포함해 도메인 Review로 매핑한다") { + val result = response.toDomain() + result shouldHaveSize 1 + result.first().reviewId shouldBe 3L + result.first().menuLikeInfoList shouldHaveSize 2 + result.first().menuLikeInfoList[0].name shouldBe "제육" + result.first().menuLikeInfoList[1].menuId shouldBe -1L + result.first().menuLikeInfoList[1].isLike shouldBe false + result.first().imgUrl shouldBe "https://meal" + } + } + } + + given("MyReviewListResponse?.toDomain") { + `when`("응답 자체가 null이면") { + then("빈 리스트를 반환한다") { + (null as MyReviewListResponse?).toDomain() shouldBe emptyList() + } + } + + `when`("dataList를 도메인으로 변환하면") { + val response = MyReviewListResponse( + dataList = arrayListOf( + MyReviewListResponse.DataList( + reviewId = 100L, + rating = 5, + writtenAt = "2025-01-03", + content = "best", + imageUrls = arrayListOf("https://my"), + menuList = arrayListOf( + MyReviewListResponse.DataList.MenuList(id = 9L, name = "라면", isLike = true), + MyReviewListResponse.DataList.MenuList(id = null, name = null, isLike = null), + ), + ) + ) + ) + + then("isWriter=true로 고정하고 기본값 매핑을 적용한다") { + val result = response.toDomain() + result shouldHaveSize 1 + result.first().isWriter shouldBe true + result.first().reviewId shouldBe 100L + result.first().menuLikeInfoList shouldHaveSize 2 + result.first().menuLikeInfoList[0].name shouldBe "라면" + result.first().menuLikeInfoList[1].menuId shouldBe -1L + result.first().writerNickname shouldBe "" + result.first().imgUrl shouldBe "https://my" + } + } + } +}) diff --git a/app/src/test/java/com/eatssu/android/data/remote/dto/response/UserAndTokenResponseMapperBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/data/remote/dto/response/UserAndTokenResponseMapperBehaviorSpec.kt new file mode 100644 index 000000000..88597392d --- /dev/null +++ b/app/src/test/java/com/eatssu/android/data/remote/dto/response/UserAndTokenResponseMapperBehaviorSpec.kt @@ -0,0 +1,91 @@ +package com.eatssu.android.data.remote.dto.response + +import com.eatssu.android.test.AppBehaviorSpec +import io.kotest.matchers.nulls.shouldBeNull +import io.kotest.matchers.shouldBe + +class UserAndTokenResponseMapperBehaviorSpec : AppBehaviorSpec({ + + given("CollegeResponse.toDomain") { + `when`("collegeId가 null이면") { + then("null을 반환한다") { + CollegeResponse(collegeId = null, collegeName = "IT").toDomain().shouldBeNull() + } + } + + `when`("collegeName이 null이면") { + then("null을 반환한다") { + CollegeResponse(collegeId = 1, collegeName = null).toDomain().shouldBeNull() + } + } + + `when`("id/name이 모두 존재하면") { + then("College로 매핑한다") { + val result = CollegeResponse(collegeId = 1, collegeName = "IT").toDomain() + result?.collegeId shouldBe 1 + result?.collegeName shouldBe "IT" + } + } + } + + given("DepartmentResponse.toDomain") { + `when`("departmentId가 null이면") { + then("null을 반환한다") { + DepartmentResponse(departmentId = null, departmentName = "컴퓨터학부").toDomain().shouldBeNull() + } + } + + `when`("departmentName이 null이면") { + then("null을 반환한다") { + DepartmentResponse(departmentId = 10, departmentName = null).toDomain().shouldBeNull() + } + } + + `when`("id/name이 모두 존재하면") { + then("Department로 매핑한다") { + val result = DepartmentResponse(departmentId = 10, departmentName = "컴퓨터학부").toDomain() + result?.departmentId shouldBe 10 + result?.departmentName shouldBe "컴퓨터학부" + } + } + } + + given("UserCollegeDepartmentResponse.toDomain") { + `when`("필수 필드 중 하나라도 null이면") { + then("null을 반환한다") { + UserCollegeDepartmentResponse( + departmentId = 1, + departmentName = "컴퓨터학부", + collegeId = null, + collegeName = "IT", + ).toDomain().shouldBeNull() + } + } + + `when`("필수 필드가 모두 존재하면") { + then("College/Department Pair를 반환한다") { + val result = UserCollegeDepartmentResponse( + departmentId = 3, + departmentName = "산업공학과", + collegeId = 2, + collegeName = "공과대학", + ).toDomain() + + result?.first?.collegeId shouldBe 2 + result?.first?.collegeName shouldBe "공과대학" + result?.second?.departmentId shouldBe 3 + result?.second?.departmentName shouldBe "산업공학과" + } + } + } + + given("TokenResponse.toDomain") { + `when`("access/refresh 토큰이 주어지면") { + then("도메인 Token으로 매핑한다") { + val result = TokenResponse(accessToken = "a", refreshToken = "r").toDomain() + result.accessToken shouldBe "a" + result.refreshToken shouldBe "r" + } + } + } +}) From 4bef13ca6139dabd0abfcc7a1f5bed869754e7b1 Mon Sep 17 00:00:00 2001 From: PeraSite Date: Sun, 15 Feb 2026 01:11:28 +0900 Subject: [PATCH 12/21] =?UTF-8?q?test:=20alarm=20usecase=20=EC=8A=A4?= =?UTF-8?q?=ED=8E=99=EC=9D=84=20=EC=A0=84=EC=B2=B4=20=EC=8A=A4=EC=9C=84?= =?UTF-8?q?=ED=8A=B8=20=EA=B8=B0=EC=A4=80=EC=9C=BC=EB=A1=9C=20=EC=95=88?= =?UTF-8?q?=EC=A0=95=ED=99=94=ED=96=88=EC=96=B4=EC=9A=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../usecase/alarm/AlarmUseCaseBehaviorSpec.kt | 37 ++----------------- 1 file changed, 3 insertions(+), 34 deletions(-) diff --git a/app/src/test/java/com/eatssu/android/domain/usecase/alarm/AlarmUseCaseBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/domain/usecase/alarm/AlarmUseCaseBehaviorSpec.kt index 370731ab5..b7890edcf 100644 --- a/app/src/test/java/com/eatssu/android/domain/usecase/alarm/AlarmUseCaseBehaviorSpec.kt +++ b/app/src/test/java/com/eatssu/android/domain/usecase/alarm/AlarmUseCaseBehaviorSpec.kt @@ -9,7 +9,6 @@ import io.mockk.every import io.mockk.mockk import io.mockk.mockkStatic import io.mockk.verify -import java.util.Calendar class AlarmUseCaseBehaviorSpec : AppBehaviorSpec({ @@ -17,51 +16,22 @@ class AlarmUseCaseBehaviorSpec : AppBehaviorSpec({ val context = mockk() val alarmManager = mockk(relaxed = true) val pendingIntent = mockk() - val calendar = mockk(relaxed = true) val flags = PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT every { context.getSystemService(Context.ALARM_SERVICE) } returns alarmManager mockkStatic(PendingIntent::class) every { PendingIntent.getBroadcast(context, 0, any(), flags) } returns pendingIntent - mockkStatic(Calendar::class) - every { Calendar.getInstance() } returns calendar - every { calendar.set(Calendar.HOUR_OF_DAY, 11) } returns Unit - every { calendar.set(Calendar.MINUTE, 0) } returns Unit - every { calendar.set(Calendar.SECOND, 0) } returns Unit - every { calendar.set(Calendar.MILLISECOND, 0) } returns Unit - every { calendar.add(any(), any()) } returns Unit val useCase = AlarmUseCase(context) - `when`("알람 시각이 현재 시각보다 과거면") { - every { calendar.timeInMillis } returnsMany listOf(0L, 86_400_000L) - - then("다음 날로 하루 추가 후 repeating 알람을 등록한다") { + `when`("scheduleAlarm을 호출하면") { + then("repeating 알람을 등록한다") { useCase.scheduleAlarm() - verify(exactly = 1) { calendar.add(Calendar.DAY_OF_YEAR, 1) } verify(exactly = 1) { alarmManager.setRepeating( AlarmManager.RTC_WAKEUP, - 86_400_000L, - AlarmManager.INTERVAL_DAY, - pendingIntent, - ) - } - } - } - - `when`("알람 시각이 현재 시각보다 미래면") { - every { calendar.timeInMillis } returnsMany listOf(Long.MAX_VALUE, Long.MAX_VALUE) - - then("하루 추가 없이 repeating 알람을 등록한다") { - useCase.scheduleAlarm() - - verify(exactly = 0) { calendar.add(Calendar.DAY_OF_YEAR, 1) } - verify(exactly = 1) { - alarmManager.setRepeating( - AlarmManager.RTC_WAKEUP, - Long.MAX_VALUE, + any(), AlarmManager.INTERVAL_DAY, pendingIntent, ) @@ -72,7 +42,6 @@ class AlarmUseCaseBehaviorSpec : AppBehaviorSpec({ `when`("cancelAlarm을 호출하면") { then("등록했던 pendingIntent로 알람을 취소한다") { useCase.cancelAlarm() - verify(exactly = 1) { alarmManager.cancel(pendingIntent) } } } From b66d8907b4dabd5c14f68aeb32eaa38209fe6db2 Mon Sep 17 00:00:00 2001 From: PeraSite Date: Sun, 15 Feb 2026 01:59:57 +0900 Subject: [PATCH 13/21] =?UTF-8?q?feat:=20Clock=EC=9D=98=20=EC=9D=98?= =?UTF-8?q?=EC=A1=B4=EC=84=B1=20=EC=A3=BC=EC=9E=85=EC=9D=84=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=ED=96=88=EC=96=B4=EC=9A=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/eatssu/android/di/AppModule.kt | 5 ++ .../domain/usecase/alarm/AlarmUsecase.kt | 5 +- .../android/presentation/util/CalendarUtil.kt | 7 +- .../presentation/widget/WidgetCacheManager.kt | 28 ++++++-- .../widget/util/WidgetDataDisplayManager.kt | 22 ++++--- .../usecase/alarm/AlarmUseCaseBehaviorSpec.kt | 65 ++++++++++++++++-- .../util/CalendarUtilBehaviorSpec.kt | 12 ++-- .../widget/WidgetCacheManagerBehaviorSpec.kt | 52 ++++++++------- .../WidgetDataDisplayManagerBehaviorSpec.kt | 66 +++++++++++++++---- 9 files changed, 194 insertions(+), 68 deletions(-) diff --git a/app/src/main/java/com/eatssu/android/di/AppModule.kt b/app/src/main/java/com/eatssu/android/di/AppModule.kt index 90a3b0bbe..0fe627a97 100644 --- a/app/src/main/java/com/eatssu/android/di/AppModule.kt +++ b/app/src/main/java/com/eatssu/android/di/AppModule.kt @@ -6,6 +6,7 @@ import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent +import java.time.Clock import javax.inject.Singleton @Module @@ -17,4 +18,8 @@ object AppModule { fun provideContext(application: Application): Context { return application.applicationContext } + + @Provides + @Singleton + fun provideClock(): Clock = Clock.systemDefaultZone() } diff --git a/app/src/main/java/com/eatssu/android/domain/usecase/alarm/AlarmUsecase.kt b/app/src/main/java/com/eatssu/android/domain/usecase/alarm/AlarmUsecase.kt index 254357aa7..ec641a3b5 100644 --- a/app/src/main/java/com/eatssu/android/domain/usecase/alarm/AlarmUsecase.kt +++ b/app/src/main/java/com/eatssu/android/domain/usecase/alarm/AlarmUsecase.kt @@ -7,10 +7,12 @@ import android.content.Intent import com.eatssu.android.alarm.NotificationReceiver import dagger.hilt.android.qualifiers.ApplicationContext import java.util.Calendar +import java.time.Clock import javax.inject.Inject class AlarmUseCase @Inject constructor( @ApplicationContext private val context: Context, + private val clock: Clock, ) { fun scheduleAlarm() { @@ -21,13 +23,14 @@ class AlarmUseCase @Inject constructor( ) val calendar = Calendar.getInstance().apply { + timeInMillis = clock.millis() set(Calendar.HOUR_OF_DAY, 11) set(Calendar.MINUTE, 0) set(Calendar.SECOND, 0) set(Calendar.MILLISECOND, 0) } - if (calendar.timeInMillis <= System.currentTimeMillis()) { + if (calendar.timeInMillis <= clock.millis()) { calendar.add(Calendar.DAY_OF_YEAR, 1) } diff --git a/app/src/main/java/com/eatssu/android/presentation/util/CalendarUtil.kt b/app/src/main/java/com/eatssu/android/presentation/util/CalendarUtil.kt index 756d0780e..2d8a1fc64 100644 --- a/app/src/main/java/com/eatssu/android/presentation/util/CalendarUtil.kt +++ b/app/src/main/java/com/eatssu/android/presentation/util/CalendarUtil.kt @@ -2,6 +2,7 @@ package com.eatssu.android.presentation.util import java.text.SimpleDateFormat import java.time.DayOfWeek +import java.time.Clock import java.time.LocalDate import java.time.format.DateTimeFormatter import java.util.Date @@ -43,9 +44,9 @@ object CalendarUtil { return formatter.format(date) } - fun getNextDayDate(): String { - val nextDay = LocalDate.now().plusDays(1) + fun getNextDayDate(clock: Clock = Clock.systemDefaultZone()): String { + val nextDay = LocalDate.now(clock).plusDays(1) val formatter = DateTimeFormatter.ofPattern("yyyyMMdd", Locale.getDefault()) return nextDay.format(formatter) } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/eatssu/android/presentation/widget/WidgetCacheManager.kt b/app/src/main/java/com/eatssu/android/presentation/widget/WidgetCacheManager.kt index 8691dca3c..50302769a 100644 --- a/app/src/main/java/com/eatssu/android/presentation/widget/WidgetCacheManager.kt +++ b/app/src/main/java/com/eatssu/android/presentation/widget/WidgetCacheManager.kt @@ -4,6 +4,7 @@ package com.eatssu.android.presentation.widget import com.eatssu.android.domain.model.WidgetMealInfo import com.eatssu.common.enums.Restaurant import timber.log.Timber +import java.time.Clock import java.time.LocalDateTime /** @@ -27,8 +28,12 @@ object WidgetCacheManager { /** * 캐시된 데이터가 유효한지 확인 */ - private fun isCacheValid(cachedData: CachedMealData, currentDate: String): Boolean { - val now = LocalDateTime.now() + private fun isCacheValid( + cachedData: CachedMealData, + currentDate: String, + clock: Clock, + ): Boolean { + val now = LocalDateTime.now(clock) val timeDiff = java.time.Duration.between(cachedData.timestamp, now) return cachedData.date == currentDate && @@ -38,10 +43,14 @@ object WidgetCacheManager { /** * 캐시에서 식당별 메뉴 데이터 조회 */ - fun getCachedMealData(restaurant: Restaurant, currentDate: String): WidgetMealInfo? { + fun getCachedMealData( + restaurant: Restaurant, + currentDate: String, + clock: Clock = Clock.systemDefaultZone(), + ): WidgetMealInfo? { val cachedData = cacheMap[restaurant] ?: return null - return if (isCacheValid(cachedData, currentDate)) { + return if (isCacheValid(cachedData, currentDate, clock)) { Timber.d("Cache hit for ${restaurant.name} on $currentDate") cachedData.mealInfo } else { @@ -54,10 +63,15 @@ object WidgetCacheManager { /** * 식당별 메뉴 데이터를 캐시에 저장 */ - fun cacheMealData(restaurant: Restaurant, mealInfo: WidgetMealInfo, date: String) { + fun cacheMealData( + restaurant: Restaurant, + mealInfo: WidgetMealInfo, + date: String, + clock: Clock = Clock.systemDefaultZone(), + ) { val cachedData = CachedMealData( mealInfo = mealInfo, - timestamp = LocalDateTime.now(), + timestamp = LocalDateTime.now(clock), date = date ) @@ -90,4 +104,4 @@ object WidgetCacheManager { Timber.d("${restaurant.name}: ${data.date} at ${data.timestamp}") } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/eatssu/android/presentation/widget/util/WidgetDataDisplayManager.kt b/app/src/main/java/com/eatssu/android/presentation/widget/util/WidgetDataDisplayManager.kt index 959f6a37c..48a83e55f 100644 --- a/app/src/main/java/com/eatssu/android/presentation/widget/util/WidgetDataDisplayManager.kt +++ b/app/src/main/java/com/eatssu/android/presentation/widget/util/WidgetDataDisplayManager.kt @@ -7,6 +7,7 @@ import com.eatssu.android.presentation.util.CalendarUtil import com.eatssu.android.presentation.widget.WidgetCacheManager import com.eatssu.common.enums.Restaurant import timber.log.Timber +import java.time.Clock import java.time.LocalTime sealed class MealTime { @@ -37,12 +38,13 @@ object WidgetDataDisplayManager { getMealsUseCase: GetTodayMealUseCase, requestedMealTime: MealTime, restaurant: Restaurant, + clock: Clock = Clock.systemDefaultZone(), ): WidgetMealInfo { Timber.d("Widget - fetchMealInfo") - val targetDate = CalendarUtil.convertMillisToDateString(System.currentTimeMillis()) + val targetDate = CalendarUtil.convertMillisToDateString(clock.millis()) // 캐시에서 데이터 확인 - val cachedMealInfo = WidgetCacheManager.getCachedMealData(restaurant, targetDate) + val cachedMealInfo = WidgetCacheManager.getCachedMealData(restaurant, targetDate, clock) if (cachedMealInfo != null) { return cachedMealInfo } @@ -64,13 +66,13 @@ object WidgetDataDisplayManager { ) // 캐시에 저장 - WidgetCacheManager.cacheMealData(restaurant, mealInfo, targetDate) + WidgetCacheManager.cacheMealData(restaurant, mealInfo, targetDate, clock) return mealInfo } // 다음 날 데이터 확인 - val nextDay = CalendarUtil.getNextDayDate() + val nextDay = CalendarUtil.getNextDayDate(clock) val getNextDayMealResponse = getMealsUseCase(nextDay, restaurant.name) if (getNextDayMealResponse is MealState.Success) { @@ -87,7 +89,7 @@ object WidgetDataDisplayManager { ) // 캐시에 저장 - WidgetCacheManager.cacheMealData(restaurant, mealInfo, targetDate) + WidgetCacheManager.cacheMealData(restaurant, mealInfo, targetDate, clock) return mealInfo } @@ -101,13 +103,15 @@ object WidgetDataDisplayManager { ) // 캐시에 저장 - WidgetCacheManager.cacheMealData(restaurant, emptyMealInfo, targetDate) + WidgetCacheManager.cacheMealData(restaurant, emptyMealInfo, targetDate, clock) return emptyMealInfo } - internal fun getCurrentMealTime(): MealTime { - val currentTime = LocalTime.now() + internal fun getCurrentMealTime( + clock: Clock = Clock.systemDefaultZone(), + ): MealTime { + val currentTime = LocalTime.now(clock) val morningEnd = LocalTime.of(9, 0) val lunchEnd = LocalTime.of(15, 0) @@ -117,4 +121,4 @@ object WidgetDataDisplayManager { else -> MealTime.Dinner } } -} \ No newline at end of file +} diff --git a/app/src/test/java/com/eatssu/android/domain/usecase/alarm/AlarmUseCaseBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/domain/usecase/alarm/AlarmUseCaseBehaviorSpec.kt index b7890edcf..fa1c08d47 100644 --- a/app/src/test/java/com/eatssu/android/domain/usecase/alarm/AlarmUseCaseBehaviorSpec.kt +++ b/app/src/test/java/com/eatssu/android/domain/usecase/alarm/AlarmUseCaseBehaviorSpec.kt @@ -8,7 +8,11 @@ import io.kotest.matchers.shouldBe import io.mockk.every import io.mockk.mockk import io.mockk.mockkStatic +import io.mockk.slot import io.mockk.verify +import java.time.Clock +import java.time.ZoneId +import java.time.ZonedDateTime class AlarmUseCaseBehaviorSpec : AppBehaviorSpec({ @@ -22,24 +26,77 @@ class AlarmUseCaseBehaviorSpec : AppBehaviorSpec({ mockkStatic(PendingIntent::class) every { PendingIntent.getBroadcast(context, 0, any(), flags) } returns pendingIntent - val useCase = AlarmUseCase(context) + `when`("현재 시간이 11시 이전이면") { + val zone = ZoneId.systemDefault() + val now = ZonedDateTime.of(2025, 1, 1, 10, 30, 0, 0, zone) + val clock = Clock.fixed(now.toInstant(), zone) + val useCase = AlarmUseCase(context, clock) + val triggerAtSlot = slot() - `when`("scheduleAlarm을 호출하면") { - then("repeating 알람을 등록한다") { + then("당일 11시로 repeating 알람을 등록한다") { useCase.scheduleAlarm() verify(exactly = 1) { alarmManager.setRepeating( AlarmManager.RTC_WAKEUP, - any(), + capture(triggerAtSlot), AlarmManager.INTERVAL_DAY, pendingIntent, ) } + + val expected = now + .withHour(11) + .withMinute(0) + .withSecond(0) + .withNano(0) + .toInstant() + .toEpochMilli() + + triggerAtSlot.captured shouldBe expected + } + } + + `when`("현재 시간이 11시 이상이면") { + val zone = ZoneId.systemDefault() + val now = ZonedDateTime.of(2025, 1, 1, 11, 0, 0, 0, zone) + val clock = Clock.fixed(now.toInstant(), zone) + val useCase = AlarmUseCase(context, clock) + val triggerAtSlot = slot() + + then("다음날 11시로 repeating 알람을 등록한다") { + useCase.scheduleAlarm() + + verify(exactly = 1) { + alarmManager.setRepeating( + AlarmManager.RTC_WAKEUP, + capture(triggerAtSlot), + AlarmManager.INTERVAL_DAY, + pendingIntent, + ) + } + + val expected = now + .plusDays(1) + .withHour(11) + .withMinute(0) + .withSecond(0) + .withNano(0) + .toInstant() + .toEpochMilli() + + triggerAtSlot.captured shouldBe expected } } `when`("cancelAlarm을 호출하면") { + val zone = ZoneId.systemDefault() + val clock = Clock.fixed( + ZonedDateTime.of(2025, 1, 1, 10, 0, 0, 0, zone).toInstant(), + zone, + ) + val useCase = AlarmUseCase(context, clock) + then("등록했던 pendingIntent로 알람을 취소한다") { useCase.cancelAlarm() verify(exactly = 1) { alarmManager.cancel(pendingIntent) } diff --git a/app/src/test/java/com/eatssu/android/presentation/util/CalendarUtilBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/presentation/util/CalendarUtilBehaviorSpec.kt index 74fbf0ae6..50b9400c9 100644 --- a/app/src/test/java/com/eatssu/android/presentation/util/CalendarUtilBehaviorSpec.kt +++ b/app/src/test/java/com/eatssu/android/presentation/util/CalendarUtilBehaviorSpec.kt @@ -2,9 +2,11 @@ package com.eatssu.android.presentation.util import com.eatssu.android.test.AppBehaviorSpec import io.kotest.matchers.shouldBe +import java.time.Clock +import java.time.Instant import java.time.LocalDate import java.time.ZoneId -import java.time.format.DateTimeFormatter +import java.time.ZoneOffset class CalendarUtilBehaviorSpec : AppBehaviorSpec({ @@ -35,11 +37,9 @@ class CalendarUtilBehaviorSpec : AppBehaviorSpec({ } `when`("getNextDayDate를 호출하면") { - then("내일 날짜의 yyyyMMdd 문자열을 반환한다") { - val expected = LocalDate.now() - .plusDays(1) - .format(DateTimeFormatter.ofPattern("yyyyMMdd")) - CalendarUtil.getNextDayDate() shouldBe expected + then("주입한 clock 기준 다음 날짜의 yyyyMMdd 문자열을 반환한다") { + val clock = Clock.fixed(Instant.parse("2025-12-31T23:59:59Z"), ZoneOffset.UTC) + CalendarUtil.getNextDayDate(clock) shouldBe "20260101" } } } diff --git a/app/src/test/java/com/eatssu/android/presentation/widget/WidgetCacheManagerBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/presentation/widget/WidgetCacheManagerBehaviorSpec.kt index 011982475..ea8ebb0e8 100644 --- a/app/src/test/java/com/eatssu/android/presentation/widget/WidgetCacheManagerBehaviorSpec.kt +++ b/app/src/test/java/com/eatssu/android/presentation/widget/WidgetCacheManagerBehaviorSpec.kt @@ -4,7 +4,9 @@ import com.eatssu.android.domain.model.WidgetMealInfo import com.eatssu.android.test.AppBehaviorSpec import com.eatssu.common.enums.Restaurant import io.kotest.matchers.shouldBe -import java.time.LocalDateTime +import java.time.Clock +import java.time.Instant +import java.time.ZoneOffset class WidgetCacheManagerBehaviorSpec : AppBehaviorSpec({ @@ -19,61 +21,61 @@ class WidgetCacheManagerBehaviorSpec : AppBehaviorSpec({ `when`("같은 날짜로 캐시 조회하면") { then("캐시된 데이터를 반환한다") { + val baseClock = Clock.fixed(Instant.parse("2025-01-01T00:00:00Z"), ZoneOffset.UTC) WidgetCacheManager.clearAllCache() - WidgetCacheManager.cacheMealData(restaurant, mealInfo, "20250101") + WidgetCacheManager.cacheMealData(restaurant, mealInfo, "20250101", baseClock) - WidgetCacheManager.getCachedMealData(restaurant, "20250101") shouldBe mealInfo + WidgetCacheManager.getCachedMealData(restaurant, "20250101", baseClock) shouldBe mealInfo } } `when`("다른 날짜로 조회하면") { then("캐시를 무효화하고 null을 반환한다") { + val baseClock = Clock.fixed(Instant.parse("2025-01-01T00:00:00Z"), ZoneOffset.UTC) WidgetCacheManager.clearAllCache() - WidgetCacheManager.cacheMealData(restaurant, mealInfo, "20250101") + WidgetCacheManager.cacheMealData(restaurant, mealInfo, "20250101", baseClock) - WidgetCacheManager.getCachedMealData(restaurant, "20250102") shouldBe null + WidgetCacheManager.getCachedMealData(restaurant, "20250102", baseClock) shouldBe null } } - `when`("캐시가 30분 초과로 만료되면") { + `when`("캐시가 30분 경계를 넘기면") { then("null을 반환한다") { + val cachedAt = Clock.fixed(Instant.parse("2025-01-01T10:00:00Z"), ZoneOffset.UTC) + val queriedAt = Clock.fixed(Instant.parse("2025-01-01T10:30:00Z"), ZoneOffset.UTC) WidgetCacheManager.clearAllCache() + WidgetCacheManager.cacheMealData(restaurant, mealInfo, "20250101", cachedAt) - @Suppress("UNCHECKED_CAST") - val cacheMap = WidgetCacheManager::class.java - .getDeclaredField("cacheMap") - .apply { isAccessible = true } - .get(WidgetCacheManager) as MutableMap - - cacheMap[restaurant] = WidgetCacheManager.CachedMealData( - mealInfo = mealInfo, - timestamp = LocalDateTime.now().minusMinutes(31), - date = "20250101", - ) - - WidgetCacheManager.getCachedMealData(restaurant, "20250101") shouldBe null + WidgetCacheManager.getCachedMealData(restaurant, "20250101", queriedAt) shouldBe null } } `when`("식당별 캐시를 삭제하면") { then("해당 식당 캐시는 제거된다") { + val baseClock = Clock.fixed(Instant.parse("2025-01-01T00:00:00Z"), ZoneOffset.UTC) WidgetCacheManager.clearAllCache() - WidgetCacheManager.cacheMealData(restaurant, mealInfo, "20250101") + WidgetCacheManager.cacheMealData(restaurant, mealInfo, "20250101", baseClock) WidgetCacheManager.clearCacheForRestaurant(restaurant) - WidgetCacheManager.getCachedMealData(restaurant, "20250101") shouldBe null + WidgetCacheManager.getCachedMealData(restaurant, "20250101", baseClock) shouldBe null } } `when`("전체 캐시를 삭제하면") { then("모든 식당 캐시가 제거된다") { + val baseClock = Clock.fixed(Instant.parse("2025-01-01T00:00:00Z"), ZoneOffset.UTC) WidgetCacheManager.clearAllCache() - WidgetCacheManager.cacheMealData(Restaurant.HAKSIK, mealInfo, "20250101") - WidgetCacheManager.cacheMealData(Restaurant.DODAM, mealInfo.copy(restaurant = Restaurant.DODAM), "20250101") + WidgetCacheManager.cacheMealData(Restaurant.HAKSIK, mealInfo, "20250101", baseClock) + WidgetCacheManager.cacheMealData( + Restaurant.DODAM, + mealInfo.copy(restaurant = Restaurant.DODAM), + "20250101", + baseClock, + ) WidgetCacheManager.clearAllCache() - WidgetCacheManager.getCachedMealData(Restaurant.HAKSIK, "20250101") shouldBe null - WidgetCacheManager.getCachedMealData(Restaurant.DODAM, "20250101") shouldBe null + WidgetCacheManager.getCachedMealData(Restaurant.HAKSIK, "20250101", baseClock) shouldBe null + WidgetCacheManager.getCachedMealData(Restaurant.DODAM, "20250101", baseClock) shouldBe null } } } diff --git a/app/src/test/java/com/eatssu/android/presentation/widget/util/WidgetDataDisplayManagerBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/presentation/widget/util/WidgetDataDisplayManagerBehaviorSpec.kt index 68a59506b..346c175c6 100644 --- a/app/src/test/java/com/eatssu/android/presentation/widget/util/WidgetDataDisplayManagerBehaviorSpec.kt +++ b/app/src/test/java/com/eatssu/android/presentation/widget/util/WidgetDataDisplayManagerBehaviorSpec.kt @@ -3,7 +3,6 @@ package com.eatssu.android.presentation.widget.util import com.eatssu.android.domain.model.WidgetMealInfo import com.eatssu.android.domain.usecase.widget.GetTodayMealUseCase import com.eatssu.android.domain.usecase.widget.MealState -import com.eatssu.android.presentation.util.CalendarUtil import com.eatssu.android.presentation.widget.WidgetCacheManager import com.eatssu.android.presentation.widget.WidgetMealList import com.eatssu.android.test.AppBehaviorSpec @@ -11,11 +10,12 @@ import com.eatssu.common.enums.Restaurant import io.kotest.matchers.shouldBe import io.mockk.coEvery import io.mockk.coVerify -import io.mockk.every import io.mockk.mockk -import io.mockk.mockkObject import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest +import java.time.Clock +import java.time.Instant +import java.time.ZoneOffset @OptIn(ExperimentalCoroutinesApi::class) class WidgetDataDisplayManagerBehaviorSpec : AppBehaviorSpec({ @@ -23,10 +23,7 @@ class WidgetDataDisplayManagerBehaviorSpec : AppBehaviorSpec({ given("위젯 표시 데이터 생성") { val useCase = mockk() val restaurant = Restaurant.HAKSIK - - mockkObject(CalendarUtil) - every { CalendarUtil.convertMillisToDateString(any()) } returns "20250101" - every { CalendarUtil.getNextDayDate() } returns "20250102" + val fixedClock = Clock.fixed(Instant.parse("2025-01-01T00:00:00Z"), ZoneOffset.UTC) val todaySuccess = MealState.Success( WidgetMealList( @@ -54,11 +51,16 @@ class WidgetDataDisplayManagerBehaviorSpec : AppBehaviorSpec({ dinner = listOf(listOf("cached-d")), restaurant = restaurant, ) - WidgetCacheManager.cacheMealData(restaurant, cached, "20250101") + WidgetCacheManager.cacheMealData(restaurant, cached, "20250101", fixedClock) then("useCase 호출 없이 캐시 데이터를 반환한다") { runTest { - WidgetDataDisplayManager.fetchMealInfo(useCase, MealTime.Morning, restaurant) shouldBe cached + WidgetDataDisplayManager.fetchMealInfo( + useCase, + MealTime.Morning, + restaurant, + fixedClock, + ) shouldBe cached coVerify(exactly = 0) { useCase(any(), any()) } } } @@ -70,14 +72,19 @@ class WidgetDataDisplayManagerBehaviorSpec : AppBehaviorSpec({ then("오늘 식단을 반환하고 캐시에 저장한다") { runTest { - val result = WidgetDataDisplayManager.fetchMealInfo(useCase, MealTime.Lunch, restaurant) + val result = WidgetDataDisplayManager.fetchMealInfo( + useCase, + MealTime.Lunch, + restaurant, + fixedClock, + ) result shouldBe WidgetMealInfo.Available( breakfast = listOf(listOf("아침A")), lunch = listOf(listOf("점심A")), dinner = listOf(listOf("저녁A")), restaurant = restaurant, ) - WidgetCacheManager.getCachedMealData(restaurant, "20250101") shouldBe result + WidgetCacheManager.getCachedMealData(restaurant, "20250101", fixedClock) shouldBe result } } } @@ -89,7 +96,12 @@ class WidgetDataDisplayManagerBehaviorSpec : AppBehaviorSpec({ then("내일 식단 기반 결과를 반환한다") { runTest { - val result = WidgetDataDisplayManager.fetchMealInfo(useCase, MealTime.Dinner, restaurant) + val result = WidgetDataDisplayManager.fetchMealInfo( + useCase, + MealTime.Dinner, + restaurant, + fixedClock, + ) result shouldBe WidgetMealInfo.Available( breakfast = listOf(listOf("내일아침")), lunch = listOf(listOf("내일점심")), @@ -109,7 +121,12 @@ class WidgetDataDisplayManagerBehaviorSpec : AppBehaviorSpec({ then("빈 리스트의 Available을 반환한다") { runTest { - WidgetDataDisplayManager.fetchMealInfo(useCase, MealTime.Morning, restaurant) shouldBe + WidgetDataDisplayManager.fetchMealInfo( + useCase, + MealTime.Morning, + restaurant, + fixedClock, + ) shouldBe WidgetMealInfo.Available( breakfast = emptyList(), lunch = emptyList(), @@ -120,4 +137,27 @@ class WidgetDataDisplayManagerBehaviorSpec : AppBehaviorSpec({ } } } + + given("현재 시간 기반 식사 구간 계산") { + `when`("09시 이전이면") { + then("Morning을 반환한다") { + val clock = Clock.fixed(Instant.parse("2025-01-01T08:59:59Z"), ZoneOffset.UTC) + WidgetDataDisplayManager.getCurrentMealTime(clock) shouldBe MealTime.Morning + } + } + + `when`("09시 이상 15시 미만이면") { + then("Lunch를 반환한다") { + val clock = Clock.fixed(Instant.parse("2025-01-01T14:59:59Z"), ZoneOffset.UTC) + WidgetDataDisplayManager.getCurrentMealTime(clock) shouldBe MealTime.Lunch + } + } + + `when`("15시 이상이면") { + then("Dinner를 반환한다") { + val clock = Clock.fixed(Instant.parse("2025-01-01T15:00:00Z"), ZoneOffset.UTC) + WidgetDataDisplayManager.getCurrentMealTime(clock) shouldBe MealTime.Dinner + } + } + } }) From 5302739d78caccf9b79873df8ebde8950591d643 Mon Sep 17 00:00:00 2001 From: PeraSite Date: Sun, 15 Feb 2026 02:11:27 +0900 Subject: [PATCH 14/21] =?UTF-8?q?test:=20=EB=84=A4=ED=8A=B8=EC=9B=8C?= =?UTF-8?q?=ED=81=AC=20=EC=96=B4=EB=8C=91=ED=84=B0=EC=99=80=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=9D=B8=20=EC=9C=A0=ED=8B=B8=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=EB=A5=BC=20=EC=B6=94=EA=B0=80=ED=96=88=EC=96=B4?= =?UTF-8?q?=EC=9A=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ApiResultCallAdapterBehaviorSpec.kt | 45 ++++ ...ApiResultCallAdapterFactoryBehaviorSpec.kt | 100 +++++++++ .../login/UserApiClientBehaviorSpec.kt | 203 ++++++++++++++++++ .../com/eatssu/common/UiTextBehaviorSpec.kt | 59 +++++ 4 files changed, 407 insertions(+) create mode 100644 app/src/test/java/com/eatssu/android/di/network/ApiResultCallAdapterBehaviorSpec.kt create mode 100644 app/src/test/java/com/eatssu/android/di/network/ApiResultCallAdapterFactoryBehaviorSpec.kt create mode 100644 app/src/test/java/com/eatssu/android/presentation/login/UserApiClientBehaviorSpec.kt create mode 100644 core/common/src/test/java/com/eatssu/common/UiTextBehaviorSpec.kt diff --git a/app/src/test/java/com/eatssu/android/di/network/ApiResultCallAdapterBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/di/network/ApiResultCallAdapterBehaviorSpec.kt new file mode 100644 index 000000000..471e7a791 --- /dev/null +++ b/app/src/test/java/com/eatssu/android/di/network/ApiResultCallAdapterBehaviorSpec.kt @@ -0,0 +1,45 @@ +package com.eatssu.android.di.network + +import com.eatssu.android.data.model.ApiResult +import com.eatssu.android.data.remote.dto.response.BaseResponse +import com.eatssu.android.test.AppBehaviorSpec +import io.kotest.matchers.shouldBe +import io.mockk.mockk +import retrofit2.Call +import java.lang.reflect.ParameterizedType +import java.lang.reflect.Type + +class ApiResultCallAdapterBehaviorSpec : AppBehaviorSpec({ + + given("ApiResultCallAdapter") { + fun parameterizedType(rawType: Type, vararg args: Type): ParameterizedType = + object : ParameterizedType { + override fun getRawType(): Type = rawType + override fun getActualTypeArguments(): Array = arrayOf(*args) + override fun getOwnerType(): Type? = null + } + + val baseResponseType = parameterizedType(BaseResponse::class.java, String::class.java) + val adapter = ApiResultCallAdapter( + baseResponseType = baseResponseType, + dataType = String::class.java, + ) + + `when`("responseType을 조회하면") { + then("생성 시 전달된 baseResponseType을 그대로 반환한다") { + adapter.responseType() shouldBe baseResponseType + } + } + + `when`("Call>를 adapt하면") { + val call = mockk>>(relaxed = true) + + then("ApiResultCall로 감싸서 반환한다") { + val adapted = adapter.adapt(call) + + (adapted is ApiResultCall<*>) shouldBe true + (adapted as Call>).request() shouldBe call.request() + } + } + } +}) diff --git a/app/src/test/java/com/eatssu/android/di/network/ApiResultCallAdapterFactoryBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/di/network/ApiResultCallAdapterFactoryBehaviorSpec.kt new file mode 100644 index 000000000..12733644c --- /dev/null +++ b/app/src/test/java/com/eatssu/android/di/network/ApiResultCallAdapterFactoryBehaviorSpec.kt @@ -0,0 +1,100 @@ +package com.eatssu.android.di.network + +import com.eatssu.android.data.model.ApiResult +import com.eatssu.android.data.remote.dto.response.BaseResponse +import com.eatssu.android.test.AppBehaviorSpec +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.matchers.nulls.shouldBeNull +import io.kotest.matchers.shouldBe +import io.mockk.mockk +import retrofit2.Call +import retrofit2.Retrofit +import java.lang.reflect.ParameterizedType +import java.lang.reflect.Type + +class ApiResultCallAdapterFactoryBehaviorSpec : AppBehaviorSpec({ + + given("ApiResultCallAdapterFactory") { + val factory = ApiResultCallAdapterFactory() + val retrofit = mockk() + + fun parameterizedType(rawType: Type, vararg args: Type): ParameterizedType = + object : ParameterizedType { + override fun getRawType(): Type = rawType + override fun getActualTypeArguments(): Array = arrayOf(*args) + override fun getOwnerType(): Type? = null + } + + `when`("return type이 ApiResult 자체면") { + then("suspend 선언 누락 예외를 던진다") { + shouldThrow { + factory.get( + parameterizedType(ApiResult::class.java, String::class.java), + emptyArray(), + retrofit, + ) + } + } + } + + `when`("return type raw type이 Call이 아니면") { + then("adapter를 만들지 않고 null을 반환한다") { + factory.get(String::class.java, emptyArray(), retrofit).shouldBeNull() + } + } + + `when`("return type이 raw Call이면") { + then("ApiResult 형태가 아니라는 예외를 던진다") { + shouldThrow { + factory.get(Call::class.java, emptyArray(), retrofit) + } + } + } + + `when`("Call 내부 타입이 ApiResult가 아니면") { + then("adapter를 만들지 않고 null을 반환한다") { + val returnType = parameterizedType(Call::class.java, String::class.java) + factory.get(returnType, emptyArray(), retrofit).shouldBeNull() + } + } + + `when`("Call처럼 success 타입 파라미터가 빠지면") { + then("ApiResult 형태가 아니라는 예외를 던진다") { + val returnType = parameterizedType( + Call::class.java, + ApiResult::class.java, + ) + + shouldThrow { + factory.get(returnType, emptyArray(), retrofit) + } + } + } + + `when`("Call>이면") { + then("ApiResultCallAdapter를 반환하고 BaseResponse responseType을 구성한다") { + val returnType = parameterizedType( + Call::class.java, + parameterizedType(ApiResult::class.java, String::class.java), + ) + + val adapter = factory.get(returnType, emptyArray(), retrofit) + (adapter is ApiResultCallAdapter<*>) shouldBe true + + val responseType = (adapter as ApiResultCallAdapter).responseType() as ParameterizedType + responseType.rawType shouldBe BaseResponse::class.java + responseType.actualTypeArguments.single() shouldBe String::class.java + } + } + + `when`("createCallAdapter를 직접 호출하면") { + then("지정한 successType으로 responseType을 생성한다") { + val adapter = factory.createCallAdapter(Int::class.java) + val responseType = adapter.responseType() as ParameterizedType + + responseType.rawType shouldBe BaseResponse::class.java + responseType.actualTypeArguments.single() shouldBe Int::class.java + } + } + } +}) diff --git a/app/src/test/java/com/eatssu/android/presentation/login/UserApiClientBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/presentation/login/UserApiClientBehaviorSpec.kt new file mode 100644 index 000000000..4dfdbdf6a --- /dev/null +++ b/app/src/test/java/com/eatssu/android/presentation/login/UserApiClientBehaviorSpec.kt @@ -0,0 +1,203 @@ +package com.eatssu.android.presentation.login + +import android.content.Context +import com.eatssu.android.test.AppBehaviorSpec +import com.kakao.sdk.auth.model.OAuthToken +import com.kakao.sdk.common.model.ClientError +import com.kakao.sdk.common.model.ClientErrorCause +import com.kakao.sdk.user.UserApiClient +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.matchers.shouldBe +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest + +@OptIn(ExperimentalCoroutinesApi::class) +class UserApiClientBehaviorSpec : AppBehaviorSpec({ + + given("UserApiClient login 확장 함수") { + val context = mockk() + val client = mockk() + + mockkObject(UserApiClient.Companion) + every { UserApiClient.instance } returns client + + fun stubTalk(result: Pair) { + every { client.loginWithKakaoTalk(context, any(), any(), any(), any(), any()) } answers { + lastArg<(OAuthToken?, Throwable?) -> Unit>().invoke(result.first, result.second) + } + } + + fun stubAccount(result: Pair) { + every { client.loginWithKakaoAccount(context, any(), any(), any(), any(), any(), any()) } answers { + lastArg<(OAuthToken?, Throwable?) -> Unit>().invoke(result.first, result.second) + } + } + + `when`("카카오톡 로그인이 불가능하면") { + val token = mockk() + every { client.isKakaoTalkLoginAvailable(context) } returns false + stubAccount(token to null) + + then("카카오 계정 로그인 결과를 반환한다") { + runTest { + UserApiClient.loginWithKakao(context) shouldBe token + verify(exactly = 0) { client.loginWithKakaoTalk(context, any(), any(), any(), any(), any()) } + } + } + } + + `when`("카카오톡 로그인 가능 + 카카오톡 로그인이 성공하면") { + val token = mockk() + every { client.isKakaoTalkLoginAvailable(context) } returns true + stubTalk(token to null) + + then("카카오톡 로그인 토큰을 반환한다") { + runTest { + UserApiClient.loginWithKakao(context) shouldBe token + } + } + } + + `when`("카카오톡 로그인에서 취소 오류가 발생하면") { + val cancelled = mockk() + every { cancelled.reason } returns ClientErrorCause.Cancelled + every { client.isKakaoTalkLoginAvailable(context) } returns true + stubTalk(null to cancelled) + + then("카카오 계정 로그인으로 fallback하지 않고 오류를 그대로 던진다") { + runTest { + shouldThrow { + UserApiClient.loginWithKakao(context) + } + + verify(exactly = 0) { + client.loginWithKakaoAccount(context, any(), any(), any(), any(), any(), any()) + } + } + } + } + + `when`("카카오톡 로그인에서 일반 오류가 발생하면") { + val token = mockk() + val error = IllegalStateException("kakao-talk-failed") + every { client.isKakaoTalkLoginAvailable(context) } returns true + stubTalk(null to error) + stubAccount(token to null) + + then("카카오 계정 로그인으로 fallback한다") { + runTest { + UserApiClient.loginWithKakao(context) shouldBe token + verify(exactly = 1) { + client.loginWithKakaoAccount(context, any(), any(), any(), any(), any(), any()) + } + } + } + } + } + + given("loginWithKakaoTalk") { + val context = mockk() + val client = mockk() + + mockkObject(UserApiClient.Companion) + every { UserApiClient.instance } returns client + + `when`("callback이 error를 전달하면") { + val error = IllegalStateException("talk-error") + every { client.loginWithKakaoTalk(context, any(), any(), any(), any(), any()) } answers { + lastArg<(OAuthToken?, Throwable?) -> Unit>().invoke(null, error) + } + + then("해당 예외를 던진다") { + runTest { + shouldThrow { + UserApiClient.loginWithKakaoTalk(context) + } + } + } + } + + `when`("callback이 token을 전달하면") { + val token = mockk() + every { client.loginWithKakaoTalk(context, any(), any(), any(), any(), any()) } answers { + lastArg<(OAuthToken?, Throwable?) -> Unit>().invoke(token, null) + } + + then("token을 반환한다") { + runTest { + UserApiClient.loginWithKakaoTalk(context) shouldBe token + } + } + } + + `when`("callback이 token/error 모두 null을 전달하면") { + every { client.loginWithKakaoTalk(context, any(), any(), any(), any(), any()) } answers { + lastArg<(OAuthToken?, Throwable?) -> Unit>().invoke(null, null) + } + + then("의미 있는 RuntimeException을 던진다") { + runTest { + val error = shouldThrow { + UserApiClient.loginWithKakaoTalk(context) + } + error.message shouldBe "kakao access token을 받아오는데 실패함, 이유는 명확하지 않음." + } + } + } + } + + given("loginWithKakaoAccount") { + val context = mockk() + val client = mockk() + + mockkObject(UserApiClient.Companion) + every { UserApiClient.instance } returns client + + `when`("callback이 error를 전달하면") { + val error = IllegalArgumentException("account-error") + every { client.loginWithKakaoAccount(context, any(), any(), any(), any(), any(), any()) } answers { + lastArg<(OAuthToken?, Throwable?) -> Unit>().invoke(null, error) + } + + then("해당 예외를 던진다") { + runTest { + shouldThrow { + UserApiClient.loginWithKakaoAccount(context) + } + } + } + } + + `when`("callback이 token을 전달하면") { + val token = mockk() + every { client.loginWithKakaoAccount(context, any(), any(), any(), any(), any(), any()) } answers { + lastArg<(OAuthToken?, Throwable?) -> Unit>().invoke(token, null) + } + + then("token을 반환한다") { + runTest { + UserApiClient.loginWithKakaoAccount(context) shouldBe token + } + } + } + + `when`("callback이 token/error 모두 null을 전달하면") { + every { client.loginWithKakaoAccount(context, any(), any(), any(), any(), any(), any()) } answers { + lastArg<(OAuthToken?, Throwable?) -> Unit>().invoke(null, null) + } + + then("의미 있는 RuntimeException을 던진다") { + runTest { + val error = shouldThrow { + UserApiClient.loginWithKakaoAccount(context) + } + error.message shouldBe "kakao access token을 받아오는데 실패함, 이유는 명확하지 않음." + } + } + } + } +}) diff --git a/core/common/src/test/java/com/eatssu/common/UiTextBehaviorSpec.kt b/core/common/src/test/java/com/eatssu/common/UiTextBehaviorSpec.kt new file mode 100644 index 000000000..b799c4b87 --- /dev/null +++ b/core/common/src/test/java/com/eatssu/common/UiTextBehaviorSpec.kt @@ -0,0 +1,59 @@ +package com.eatssu.common + +import android.content.Context +import io.kotest.core.spec.style.BehaviorSpec +import io.kotest.matchers.shouldBe +import io.mockk.every +import io.mockk.mockk + +class UiTextBehaviorSpec : BehaviorSpec({ + + given("UiText.StringResource") { + val context = mockk() + + `when`("포맷 인자가 없으면") { + every { context.getString(1) } returns "기본 문자열" + + then("리소스 문자열을 그대로 반환한다") { + UiText.StringResource(1).asString(context) shouldBe "기본 문자열" + } + } + + `when`("포맷 인자에 UiText가 포함되면") { + every { context.getString(10) } returns "내부 텍스트" + every { context.getString(20, "내부 텍스트", 3) } returns "외부 텍스트" + + then("중첩 UiText를 재귀적으로 해석해 포맷한다") { + val nested = UiText.StringResource(10) + val outer = UiText.StringResource(20, nested, 3) + + outer.asString(context) shouldBe "외부 텍스트" + } + } + + `when`("vararg 생성자를 사용하면") { + then("args 리스트가 생성 순서를 유지한다") { + val text = UiText.StringResource(30, "A", 1) + + text.resId shouldBe 30 + text.args shouldBe listOf("A", 1) + } + } + } + + given("UiText.DynamicString") { + `when`("asString을 호출하면") { + then("원본 문자열을 그대로 반환한다") { + UiText.DynamicString("직접 입력").asString(mockk(relaxed = true)) shouldBe "직접 입력" + } + } + } + + given("UiText.Empty") { + `when`("asString을 호출하면") { + then("빈 문자열을 반환한다") { + UiText.Empty.asString(mockk(relaxed = true)) shouldBe "" + } + } + } +}) From 49dbc74447177aa715f3641d57ff8d5ab016ba2f Mon Sep 17 00:00:00 2001 From: PeraSite Date: Sun, 15 Feb 2026 02:44:00 +0900 Subject: [PATCH 15/21] =?UTF-8?q?test:=20=EB=88=84=EB=9D=BD=20=EB=B6=84?= =?UTF-8?q?=EA=B8=B0=20=ED=85=8C=EC=8A=A4=ED=8A=B8=EC=99=80=20=EA=B3=B5?= =?UTF-8?q?=EC=9A=A9=20DSL=EC=9D=84=20=EB=B3=B4=EA=B0=95=ED=96=88=EC=96=B4?= =?UTF-8?q?=EC=9A=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../PartnershipRepositoryImplBehaviorSpec.kt | 20 ++ .../UserRepositoryImplBehaviorSpec.kt | 84 ++++++ .../widget/GetTodayMealUseCaseBehaviorSpec.kt | 13 + .../presentation/MainViewModelBehaviorSpec.kt | 70 ++++- .../list/ReviewListViewModelBehaviorSpec.kt | 25 +- .../write/WriteReviewViewModelBehaviorSpec.kt | 100 +++++- .../map/MapViewModelBehaviorSpec.kt | 183 +++++++++++ .../userinfo/UserInfoViewModelBehaviorSpec.kt | 284 +++++++++++++++++- .../com/eatssu/android/test/TestHelpers.kt | 19 +- 9 files changed, 772 insertions(+), 26 deletions(-) diff --git a/app/src/test/java/com/eatssu/android/data/remote/repository/PartnershipRepositoryImplBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/data/remote/repository/PartnershipRepositoryImplBehaviorSpec.kt index e5a00eb17..a07fcdda4 100644 --- a/app/src/test/java/com/eatssu/android/data/remote/repository/PartnershipRepositoryImplBehaviorSpec.kt +++ b/app/src/test/java/com/eatssu/android/data/remote/repository/PartnershipRepositoryImplBehaviorSpec.kt @@ -53,6 +53,16 @@ class PartnershipRepositoryImplBehaviorSpec : AppBehaviorSpec({ } } + `when`("전체 제휴 조회 API가 실패하면") { + coEvery { partnershipService.getAllPartnerships() } returns ApiResult.Failure(500, "err") + + then("빈 리스트를 반환한다") { + runTest { + repository.getAllPartnerships() shouldBe emptyList() + } + } + } + `when`("개별 제휴 조회 API가 성공하면") { val response = PartnershipRestaurantResponse( id = 1, @@ -82,6 +92,16 @@ class PartnershipRepositoryImplBehaviorSpec : AppBehaviorSpec({ } } + `when`("개별 제휴 조회 API가 실패하면") { + coEvery { partnershipService.getPartnershipById(1) } returns ApiResult.UnknownError(IllegalStateException("boom")) + + then("null을 반환한다") { + runTest { + repository.getPartnershipById(1) shouldBe null + } + } + } + `when`("유저 학과 제휴 조회가 실패하면") { coEvery { userService.getUserDepartmentPartnerships() } returns ApiResult.Failure(500, "err") diff --git a/app/src/test/java/com/eatssu/android/data/remote/repository/UserRepositoryImplBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/data/remote/repository/UserRepositoryImplBehaviorSpec.kt index 8c9071068..776889db9 100644 --- a/app/src/test/java/com/eatssu/android/data/remote/repository/UserRepositoryImplBehaviorSpec.kt +++ b/app/src/test/java/com/eatssu/android/data/remote/repository/UserRepositoryImplBehaviorSpec.kt @@ -46,6 +46,34 @@ class UserRepositoryImplBehaviorSpec : AppBehaviorSpec({ } } + `when`("닉네임 변경이 Failure지만 메시지가 없으면") { + coEvery { + userService.changeNickname(ChangeNicknameRequest("new")) + } returns ApiResult.Failure(400, null) + + then("기본 실패 메시지를 반환한다") { + runTest { + val result = repository.updateUserName(ChangeNicknameRequest("new")) + result.isFailure shouldBe true + result.exceptionOrNull()?.message shouldBe "닉네임 변경에 실패했어요." + } + } + } + + `when`("닉네임 변경이 UnknownError면") { + coEvery { + userService.changeNickname(ChangeNicknameRequest("new")) + } returns ApiResult.UnknownError(IllegalStateException("boom")) + + then("기본 실패 메시지를 반환한다") { + runTest { + val result = repository.updateUserName(ChangeNicknameRequest("new")) + result.isFailure shouldBe true + result.exceptionOrNull()?.message shouldBe "닉네임 변경에 실패했어요." + } + } + } + `when`("닉네임 중복검사에서 true를 받으면") { coEvery { userService.checkNickname("ok") } returns ApiResult.Success(true) @@ -68,6 +96,30 @@ class UserRepositoryImplBehaviorSpec : AppBehaviorSpec({ } } + `when`("닉네임 중복검사가 Failure이고 메시지가 없으면") { + coEvery { userService.checkNickname("bad") } returns ApiResult.Failure(400, null) + + then("기본 검증 실패 메시지를 반환한다") { + runTest { + val result = repository.checkUserNameValidation("bad") + result.isFailure shouldBe true + result.exceptionOrNull()?.message shouldBe "올바르지 않은 닉네임이에요." + } + } + } + + `when`("닉네임 중복검사가 UnknownError면") { + coEvery { userService.checkNickname("bad") } returns ApiResult.UnknownError(IllegalStateException("boom")) + + then("기본 검증 실패 메시지를 반환한다") { + runTest { + val result = repository.checkUserNameValidation("bad") + result.isFailure shouldBe true + result.exceptionOrNull()?.message shouldBe "올바르지 않은 닉네임이에요." + } + } + } + `when`("내 닉네임 조회가 실패하면") { coEvery { userService.getMyInfo() } returns ApiResult.Failure(500, "err") @@ -154,6 +206,16 @@ class UserRepositoryImplBehaviorSpec : AppBehaviorSpec({ } } + `when`("회원 탈퇴 요청이 성공하면") { + coEvery { userService.signOut() } returns ApiResult.Success(true) + + then("true를 반환한다") { + runTest { + repository.signOut() shouldBe true + } + } + } + `when`("내 닉네임 조회가 성공하지만 nickname이 null이면") { coEvery { userService.getMyInfo() } returns ApiResult.Success( MyNickNameResponse(nickname = null, provider = "KAKAO") @@ -165,5 +227,27 @@ class UserRepositoryImplBehaviorSpec : AppBehaviorSpec({ } } } + + `when`("단과대/학과 목록 조회 API가 실패하면") { + coEvery { userService.getCollegeList() } returns ApiResult.Failure(500, "err") + coEvery { userService.getDepartmentsByCollege(1) } returns ApiResult.UnknownError(IllegalStateException("boom")) + + then("둘 다 빈 리스트를 반환한다") { + runTest { + repository.getTotalColleges() shouldBe emptyList() + repository.getTotalDepartments(1) shouldBe emptyList() + } + } + } + + `when`("유저 단과대/학과 조회 API가 실패하면") { + coEvery { userService.getUserCollegeDepartment() } returns ApiResult.Failure(404, "not found") + + then("null을 반환한다") { + runTest { + repository.getUserCollegeDepartment() shouldBe null + } + } + } } }) diff --git a/app/src/test/java/com/eatssu/android/domain/usecase/widget/GetTodayMealUseCaseBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/domain/usecase/widget/GetTodayMealUseCaseBehaviorSpec.kt index fc5d2a3cb..7223e9093 100644 --- a/app/src/test/java/com/eatssu/android/domain/usecase/widget/GetTodayMealUseCaseBehaviorSpec.kt +++ b/app/src/test/java/com/eatssu/android/domain/usecase/widget/GetTodayMealUseCaseBehaviorSpec.kt @@ -11,6 +11,7 @@ import io.mockk.mockk import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest import java.net.UnknownHostException +import java.nio.channels.UnresolvedAddressException @OptIn(ExperimentalCoroutinesApi::class) class GetTodayMealUseCaseBehaviorSpec : AppBehaviorSpec({ @@ -54,6 +55,18 @@ class GetTodayMealUseCaseBehaviorSpec : AppBehaviorSpec({ } } + `when`("네트워크 주소 미해결 예외가 발생하면") { + coEvery { + mealRepository.getTodayMeal("2025-01-01", "HAKSIK", Time.MORNING.name) + } throws UnresolvedAddressException() + + then("MealState.Failure를 반환한다") { + runTest { + useCase("2025-01-01", "HAKSIK") shouldBe MealState.Failure + } + } + } + `when`("알 수 없는 예외가 발생하면") { coEvery { mealRepository.getTodayMeal("2025-01-01", "HAKSIK", Time.MORNING.name) diff --git a/app/src/test/java/com/eatssu/android/presentation/MainViewModelBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/presentation/MainViewModelBehaviorSpec.kt index 9bdce2bf1..732c1f6e1 100644 --- a/app/src/test/java/com/eatssu/android/presentation/MainViewModelBehaviorSpec.kt +++ b/app/src/test/java/com/eatssu/android/presentation/MainViewModelBehaviorSpec.kt @@ -10,8 +10,7 @@ import com.eatssu.android.domain.usecase.user.GetUserCollegeDepartmentUseCase import com.eatssu.android.domain.usecase.user.GetUserNickNameUseCase import com.eatssu.android.domain.usecase.user.SetUserCollegeDepartmentUseCase import com.eatssu.android.test.AppBehaviorSpec -import com.eatssu.android.test.assertToast -import com.eatssu.android.test.awaitToastEvent +import com.eatssu.android.test.expectToast import com.eatssu.android.test.sampleUserInfo import com.eatssu.common.UiState import com.eatssu.common.enums.ToastType @@ -21,6 +20,8 @@ import io.mockk.coEvery import io.mockk.coVerify import io.mockk.mockk import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import kotlin.time.Duration.Companion.seconds @@ -69,6 +70,69 @@ class MainViewModelBehaviorSpec : AppBehaviorSpec({ } } + `when`("저장된 학과 정보가 없는 유저로 초기화되면") { + coEvery { + getUserCollegeDepartmentUseCase() + } returns sampleUserInfo( + nickname = "eatssu", + college = College(collegeId = -1, collegeName = "단과대"), + department = Department(departmentId = -1, departmentName = "학과"), + ) + coEvery { getUserNickNameUseCase() } coAnswers { + delay(10_000) + "eatssu" + } + coEvery { userRepository.getUserCollegeDepartment() } coAnswers { + delay(1_000) + null + } + + then("not found 토스트를 내보낸다") { + runTest { + val viewModel = MainViewModel( + logoutUseCase = logoutUseCase, + getUserNickNameUseCase = getUserNickNameUseCase, + setUserCollegeDepartmentUseCase = setUserCollegeDepartmentUseCase, + userRepository = userRepository, + getUserCollegeDepartmentUseCase = getUserCollegeDepartmentUseCase, + ) + + viewModel.uiEvent.test { + advanceUntilIdle() + expectToast(R.string.not_found, ToastType.ERROR) + cancelAndIgnoreRemainingEvents() + } + } + } + } + + `when`("닉네임이 비어있는 유저로 초기화되면") { + coEvery { getUserCollegeDepartmentUseCase() } returns userInfo + coEvery { userRepository.getUserCollegeDepartment() } returns (college to department) + coEvery { getUserNickNameUseCase() } coAnswers { + delay(1_000) + " " + } + + then("닉네임 설정 안내 토스트를 내보낸다") { + runTest { + val viewModel = MainViewModel( + logoutUseCase = logoutUseCase, + getUserNickNameUseCase = getUserNickNameUseCase, + setUserCollegeDepartmentUseCase = setUserCollegeDepartmentUseCase, + userRepository = userRepository, + getUserCollegeDepartmentUseCase = getUserCollegeDepartmentUseCase, + ) + + viewModel.uiEvent.test { + advanceUntilIdle() + expectToast(R.string.set_nickname, ToastType.ERROR) + cancelAndIgnoreRemainingEvents() + } + } + } + } + `when`("로그아웃을 수행하면") { val viewModel = MainViewModel( logoutUseCase = logoutUseCase, @@ -83,7 +147,7 @@ class MainViewModelBehaviorSpec : AppBehaviorSpec({ viewModel.uiEvent.test { viewModel.logOut() - awaitToastEvent().assertToast(R.string.toast_logout_success, ToastType.SUCCESS) + expectToast(R.string.toast_logout_success, ToastType.SUCCESS) eventually(2.seconds) { coVerify { logoutUseCase() } viewModel.uiState.value shouldBe UiState.Success(MainState.LoggedOut) diff --git a/app/src/test/java/com/eatssu/android/presentation/cafeteria/review/list/ReviewListViewModelBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/presentation/cafeteria/review/list/ReviewListViewModelBehaviorSpec.kt index 64902abf2..9b97e85f0 100644 --- a/app/src/test/java/com/eatssu/android/presentation/cafeteria/review/list/ReviewListViewModelBehaviorSpec.kt +++ b/app/src/test/java/com/eatssu/android/presentation/cafeteria/review/list/ReviewListViewModelBehaviorSpec.kt @@ -7,8 +7,7 @@ import com.eatssu.android.domain.usecase.review.DeleteReviewUseCase import com.eatssu.android.domain.usecase.review.GetReviewInfoUseCase import com.eatssu.android.domain.usecase.review.GetReviewListPagedUseCase import com.eatssu.android.test.AppBehaviorSpec -import com.eatssu.android.test.assertToast -import com.eatssu.android.test.awaitToastEvent +import com.eatssu.android.test.expectToast import com.eatssu.android.test.sampleReviewInfo import com.eatssu.common.UiState import com.eatssu.common.enums.MenuType @@ -59,7 +58,7 @@ class ReviewListViewModelBehaviorSpec : AppBehaviorSpec({ advanceUntilIdle() viewModel.uiState.value shouldBe UiState.Error - awaitToastEvent().assertToast(R.string.toast_review_load_failed, ToastType.ERROR) + expectToast(R.string.toast_review_load_failed, ToastType.ERROR) cancelAndIgnoreRemainingEvents() } } @@ -76,7 +75,7 @@ class ReviewListViewModelBehaviorSpec : AppBehaviorSpec({ viewModel.deleteReview(55L) advanceUntilIdle() - awaitToastEvent().assertToast(R.string.toast_review_delete_failed, ToastType.ERROR) + expectToast(R.string.toast_review_delete_failed, ToastType.ERROR) cancelAndIgnoreRemainingEvents() } } @@ -104,5 +103,23 @@ class ReviewListViewModelBehaviorSpec : AppBehaviorSpec({ } } } + + `when`("조회 파라미터 없이 리뷰 삭제가 성공하면") { + val viewModel = ReviewListViewModel(getReviewInfoUseCase, getReviewListPagedUseCase, deleteReviewUseCase) + coEvery { deleteReviewUseCase(77L) } returns true + + then("ReviewDeleted 이벤트만 발생하고 정보 재조회는 하지 않는다") { + runTest { + viewModel.uiEvent.test { + viewModel.deleteReview(77L) + advanceUntilIdle() + + awaitItem() shouldBe ReviewListEvent.ReviewDeleted + coVerify(exactly = 0) { getReviewInfoUseCase(any(), any()) } + cancelAndIgnoreRemainingEvents() + } + } + } + } } }) diff --git a/app/src/test/java/com/eatssu/android/presentation/cafeteria/review/write/WriteReviewViewModelBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/presentation/cafeteria/review/write/WriteReviewViewModelBehaviorSpec.kt index cff1f609a..696449546 100644 --- a/app/src/test/java/com/eatssu/android/presentation/cafeteria/review/write/WriteReviewViewModelBehaviorSpec.kt +++ b/app/src/test/java/com/eatssu/android/presentation/cafeteria/review/write/WriteReviewViewModelBehaviorSpec.kt @@ -10,10 +10,10 @@ import com.eatssu.android.domain.usecase.menu.GetValidMenusOfMealUseCase import com.eatssu.android.domain.usecase.review.GetImageUrlUseCase import com.eatssu.android.domain.usecase.review.WriteReviewUseCase import com.eatssu.android.test.AppBehaviorSpec -import com.eatssu.android.test.assertToast -import com.eatssu.android.test.awaitToastEvent +import com.eatssu.android.test.expectNavigateBack +import com.eatssu.android.test.expectToast +import com.eatssu.android.test.successDataAs import com.eatssu.common.EventLogger -import com.eatssu.common.UiEvent import com.eatssu.common.UiState import com.eatssu.common.enums.MenuType import com.eatssu.common.enums.ToastType @@ -101,6 +101,39 @@ class WriteReviewViewModelBehaviorSpec : AppBehaviorSpec({ } } + `when`("Editing 상태가 아닐 때 submit하면") { + val viewModel = WriteReviewViewModel(writeReviewUseCase, getImageUrlUseCase, getValidMenusOfMealUseCase) + + then("아무 동작도 수행하지 않는다") { + runTest { + viewModel.postReview(MenuType.FIXED, 1L, mockk(relaxed = true)) + advanceUntilIdle() + + viewModel.uiState.value shouldBe UiState.Init + coVerify(exactly = 0) { + writeReviewUseCase(any(), any(), any(), any(), any(), any()) + } + } + } + } + + `when`("좋아요 메뉴를 같은 id로 두 번 토글하면") { + val viewModel = WriteReviewViewModel(writeReviewUseCase, getImageUrlUseCase, getValidMenusOfMealUseCase) + + then("likedMenuIds가 추가됐다가 다시 제거된다") { + runTest { + viewModel.loadMenuList(MenuType.FIXED, 1L, "돈가스") + advanceUntilIdle() + + viewModel.toggleLike(101L) + viewModel.uiState.value.successDataAs().likedMenuIds shouldBe setOf(101L) + + viewModel.toggleLike(101L) + viewModel.uiState.value.successDataAs().likedMenuIds shouldBe emptySet() + } + } + } + `when`("이미지 없이 리뷰 작성이 성공하면") { val viewModel = WriteReviewViewModel(writeReviewUseCase, getImageUrlUseCase, getValidMenusOfMealUseCase) coEvery { @@ -127,8 +160,8 @@ class WriteReviewViewModelBehaviorSpec : AppBehaviorSpec({ viewModel.postReview(MenuType.FIXED, 1L, mockk(relaxed = true)) advanceUntilIdle() - awaitToastEvent().assertToast(R.string.toast_review_write_success, ToastType.SUCCESS) - awaitItem() shouldBe UiEvent.NavigateBack + expectToast(R.string.toast_review_write_success, ToastType.SUCCESS) + expectNavigateBack() cancelAndIgnoreRemainingEvents() } } @@ -168,9 +201,50 @@ class WriteReviewViewModelBehaviorSpec : AppBehaviorSpec({ viewModel.postReview(MenuType.FIXED, 1L, context) advanceUntilIdle() - awaitToastEvent().assertToast(R.string.toast_image_upload_success, ToastType.SUCCESS) - awaitToastEvent().assertToast(R.string.toast_review_write_success, ToastType.SUCCESS) - awaitItem() shouldBe UiEvent.NavigateBack + expectToast(R.string.toast_image_upload_success, ToastType.SUCCESS) + expectToast(R.string.toast_review_write_success, ToastType.SUCCESS) + expectNavigateBack() + cancelAndIgnoreRemainingEvents() + } + } + } + } + + `when`("이미지 업로드 URL이 null이어도 리뷰 작성이 성공하면") { + val viewModel = WriteReviewViewModel(writeReviewUseCase, getImageUrlUseCase, getValidMenusOfMealUseCase) + val context = mockk() + val resolver = mockk() + val uri = mockk() + val cacheDir = createTempDir(prefix = "write-review-null-url") + val compressed = File(cacheDir, "compressed.jpg").apply { writeBytes(byteArrayOf(1, 2, 3)) } + + every { context.contentResolver } returns resolver + every { context.cacheDir } returns cacheDir + every { resolver.openInputStream(uri) } returns ByteArrayInputStream(byteArrayOf(1, 2, 3)) + + mockkObject(Compressor) + coEvery { Compressor.compress(context, any()) } returns compressed + coEvery { getImageUrlUseCase(compressed) } returns null + coEvery { + writeReviewUseCase(MenuType.FIXED, 1L, 4, "", null, any()) + } returns true + mockkObject(EventLogger) + every { EventLogger.completeReview(any(), any(), any()) } just Runs + + then("현재 동작대로 이미지 업로드 성공 토스트 후 리뷰 성공 흐름을 유지한다") { + runTest { + viewModel.loadMenuList(MenuType.FIXED, 1L, "돈가스") + advanceUntilIdle() + viewModel.onRatingChanged(4) + viewModel.setSelectedImage(uri) + + viewModel.uiEvent.test { + viewModel.postReview(MenuType.FIXED, 1L, context) + advanceUntilIdle() + + expectToast(R.string.toast_image_upload_success, ToastType.SUCCESS) + expectToast(R.string.toast_review_write_success, ToastType.SUCCESS) + expectNavigateBack() cancelAndIgnoreRemainingEvents() } } @@ -202,8 +276,8 @@ class WriteReviewViewModelBehaviorSpec : AppBehaviorSpec({ viewModel.postReview(MenuType.FIXED, 1L, context) advanceUntilIdle() - awaitToastEvent().assertToast(R.string.toast_image_compress_failed, ToastType.ERROR) - (viewModel.uiState.value as UiState.Success).data::class shouldBe WriteReviewState.Editing::class + expectToast(R.string.toast_image_compress_failed, ToastType.ERROR) + viewModel.uiState.value.successDataAs() coVerify(exactly = 0) { writeReviewUseCase(any(), any(), any(), any(), any(), any()) } cancelAndIgnoreRemainingEvents() } @@ -231,7 +305,7 @@ class WriteReviewViewModelBehaviorSpec : AppBehaviorSpec({ viewModel.postReview(MenuType.FIXED, 1L, context) advanceUntilIdle() - awaitToastEvent().assertToast(R.string.toast_image_upload_failed, ToastType.ERROR) + expectToast(R.string.toast_image_upload_failed, ToastType.ERROR) cancelAndIgnoreRemainingEvents() } } @@ -252,8 +326,8 @@ class WriteReviewViewModelBehaviorSpec : AppBehaviorSpec({ viewModel.postReview(MenuType.FIXED, 1L, mockk(relaxed = true)) advanceUntilIdle() - (viewModel.uiState.value as UiState.Success).data::class shouldBe WriteReviewState.Editing::class - awaitToastEvent().assertToast(R.string.toast_review_write_failed, ToastType.ERROR) + viewModel.uiState.value.successDataAs() + expectToast(R.string.toast_review_write_failed, ToastType.ERROR) cancelAndIgnoreRemainingEvents() } } diff --git a/app/src/test/java/com/eatssu/android/presentation/map/MapViewModelBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/presentation/map/MapViewModelBehaviorSpec.kt index eba7d5d6d..fc22ea223 100644 --- a/app/src/test/java/com/eatssu/android/presentation/map/MapViewModelBehaviorSpec.kt +++ b/app/src/test/java/com/eatssu/android/presentation/map/MapViewModelBehaviorSpec.kt @@ -27,6 +27,7 @@ import io.mockk.mockk import io.mockk.mockkObject import io.mockk.verify import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay import kotlinx.coroutines.test.runTest import kotlin.time.Duration.Companion.seconds @@ -227,5 +228,187 @@ class MapViewModelBehaviorSpec : AppBehaviorSpec({ } } } + + `when`("초기 상태가 Init일 때 Mine 필터를 선택하면") { + coEvery { + getUserCollegeDepartmentUseCase() + } coAnswers { + delay(10_000) + sampleUserInfo( + nickname = "eatssu", + college = College(collegeId = -1, collegeName = "단과대"), + department = Department(departmentId = -1, departmentName = "학과"), + ) + } + + val viewModel = MapViewModel( + partnershipRepository = partnershipRepository, + getPartnershipDetailUseCase = getPartnershipDetailUseCase, + getUserCollegeDepartmentUseCase = getUserCollegeDepartmentUseCase, + ) + + then("상태를 변경하지 않고 반환한다") { + viewModel.setFilter(FilterType.Mine) + viewModel.uiState.value shouldBe UiState.Init + coVerify(exactly = 0) { partnershipRepository.getUserCollegePartnerships() } + } + } + + `when`("제휴 목록에 없는 가게를 선택하면") { + val partnerships = listOf(samplePartnership(storeName = "Cafe A")) + coEvery { + getUserCollegeDepartmentUseCase() + } returns sampleUserInfo( + nickname = "eatssu", + college = College(collegeId = 1, collegeName = "IT"), + department = Department(departmentId = 11, departmentName = "컴퓨터학부"), + ) + coEvery { partnershipRepository.getUserCollegePartnerships() } returns partnerships + coEvery { partnershipRepository.getAllPartnerships() } returns emptyList() + + val viewModel = MapViewModel( + partnershipRepository = partnershipRepository, + getPartnershipDetailUseCase = getPartnershipDetailUseCase, + getUserCollegeDepartmentUseCase = getUserCollegeDepartmentUseCase, + ) + + then("선택 상태를 갱신하지 않는다") { + runTest { + eventually(2.seconds) { + (viewModel.uiState.value as UiState.Success).data.partnerships shouldBe partnerships + } + + viewModel.selectPartnershipByStoreName("Unknown") + eventually(2.seconds) { + val state = viewModel.uiState.value as UiState.Success + state.data.restaurantPartnershipInfo shouldBe null + state.data.restaurantInfoList shouldBe emptyList() + } + } + } + } + + `when`("제휴 상세에서 representative를 찾지 못하면") { + val partnerships = listOf( + samplePartnership( + storeName = "Cafe A", + infos = listOf( + Partnership.PartnershipInfo( + id = 1, + partnershipType = "DISCOUNT", + collegeName = "IT", + departmentName = "CS", + likeCount = 1, + isLiked = true, + description = "할인", + startDate = "2025-01-01", + endDate = "2025-12-31", + ) + ), + ) + ) + coEvery { + getUserCollegeDepartmentUseCase() + } returns sampleUserInfo( + nickname = "eatssu", + college = College(collegeId = 1, collegeName = "IT"), + department = Department(departmentId = 11, departmentName = "컴퓨터학부"), + ) + coEvery { partnershipRepository.getUserCollegePartnerships() } returns partnerships + coEvery { partnershipRepository.getAllPartnerships() } returns emptyList() + every { getPartnershipDetailUseCase(partnerships, "Cafe A", 1) } returns null + + val viewModel = MapViewModel( + partnershipRepository = partnershipRepository, + getPartnershipDetailUseCase = getPartnershipDetailUseCase, + getUserCollegeDepartmentUseCase = getUserCollegeDepartmentUseCase, + ) + + then("선택 상태를 갱신하지 않는다") { + runTest { + eventually(2.seconds) { + (viewModel.uiState.value as UiState.Success).data.partnerships shouldBe partnerships + } + + viewModel.selectPartnershipByStoreName("Cafe A") + eventually(2.seconds) { + val state = viewModel.uiState.value as UiState.Success + state.data.restaurantPartnershipInfo shouldBe null + } + } + } + } + + `when`("대표 제휴 타입이 CAFE면") { + val partnerships = listOf(samplePartnership(storeName = "Cafe C", type = RestaurantType.CAFE)) + val representative = samplePartnershipRestaurant(type = RestaurantType.CAFE) + + coEvery { + getUserCollegeDepartmentUseCase() + } returns sampleUserInfo( + nickname = "eatssu", + college = College(collegeId = 1, collegeName = "IT"), + department = Department(departmentId = 11, departmentName = "컴퓨터학부"), + ) + coEvery { partnershipRepository.getUserCollegePartnerships() } returns partnerships + coEvery { partnershipRepository.getAllPartnerships() } returns emptyList() + every { getPartnershipDetailUseCase(partnerships, "Cafe C", 1) } returns representative + + val viewModel = MapViewModel( + partnershipRepository = partnershipRepository, + getPartnershipDetailUseCase = getPartnershipDetailUseCase, + getUserCollegeDepartmentUseCase = getUserCollegeDepartmentUseCase, + ) + + then("PlaceType.CAFE로 변환한다") { + runTest { + eventually(2.seconds) { + (viewModel.uiState.value as UiState.Success).data.partnerships shouldBe partnerships + } + viewModel.selectPartnershipByStoreName("Cafe C") + + eventually(2.seconds) { + val state = viewModel.uiState.value as UiState.Success + state.data.placeType shouldBe PlaceType.CAFE + } + } + } + } + + `when`("대표 제휴 타입이 RESTAURANT면") { + val partnerships = listOf(samplePartnership(storeName = "Restaurant A", type = RestaurantType.RESTAURANT)) + val representative = samplePartnershipRestaurant(type = RestaurantType.RESTAURANT) + + coEvery { + getUserCollegeDepartmentUseCase() + } returns sampleUserInfo( + nickname = "eatssu", + college = College(collegeId = 1, collegeName = "IT"), + department = Department(departmentId = 11, departmentName = "컴퓨터학부"), + ) + coEvery { partnershipRepository.getUserCollegePartnerships() } returns partnerships + coEvery { partnershipRepository.getAllPartnerships() } returns emptyList() + every { getPartnershipDetailUseCase(partnerships, "Restaurant A", 1) } returns representative + + val viewModel = MapViewModel( + partnershipRepository = partnershipRepository, + getPartnershipDetailUseCase = getPartnershipDetailUseCase, + getUserCollegeDepartmentUseCase = getUserCollegeDepartmentUseCase, + ) + + then("PlaceType.RESTAURANT로 변환한다") { + runTest { + eventually(2.seconds) { + (viewModel.uiState.value as UiState.Success).data.partnerships shouldBe partnerships + } + viewModel.selectPartnershipByStoreName("Restaurant A") + + eventually(2.seconds) { + val state = viewModel.uiState.value as UiState.Success + state.data.placeType shouldBe PlaceType.RESTAURANT + } + } + } + } } }) diff --git a/app/src/test/java/com/eatssu/android/presentation/mypage/userinfo/UserInfoViewModelBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/presentation/mypage/userinfo/UserInfoViewModelBehaviorSpec.kt index e3d8eb991..7ff063d54 100644 --- a/app/src/test/java/com/eatssu/android/presentation/mypage/userinfo/UserInfoViewModelBehaviorSpec.kt +++ b/app/src/test/java/com/eatssu/android/presentation/mypage/userinfo/UserInfoViewModelBehaviorSpec.kt @@ -12,9 +12,8 @@ import com.eatssu.android.domain.usecase.user.SetUserNicknameUseCase import com.eatssu.android.domain.usecase.user.ValidateNicknameLocalUseCase import com.eatssu.android.domain.usecase.user.ValidateNicknameServerUseCase import com.eatssu.android.test.AppBehaviorSpec -import com.eatssu.android.test.assertToast import com.eatssu.android.test.asStringResIdOrNull -import com.eatssu.android.test.awaitToastEvent +import com.eatssu.android.test.expectToast import com.eatssu.android.test.sampleUserInfo import com.eatssu.common.UiState import com.eatssu.common.UiText @@ -210,7 +209,7 @@ class UserInfoViewModelBehaviorSpec : AppBehaviorSpec({ viewModel.uiEvent.test { viewModel.saveUserInfo() - awaitToastEvent().assertToast(R.string.toast_no_changes, ToastType.INFO) + expectToast(R.string.toast_no_changes, ToastType.INFO) eventually(2.seconds) { val state = viewModel.uiState.value as UiState.Success @@ -260,7 +259,7 @@ class UserInfoViewModelBehaviorSpec : AppBehaviorSpec({ viewModel.onNicknameChanged("newNick") viewModel.uiEvent.test { viewModel.saveUserInfo() - awaitToastEvent().assertToast(R.string.toast_nickname_change_failed, ToastType.ERROR) + expectToast(R.string.toast_nickname_change_failed, ToastType.ERROR) eventually(2.seconds) { viewModel.uiState.value shouldBe UiState.Error } @@ -325,7 +324,7 @@ class UserInfoViewModelBehaviorSpec : AppBehaviorSpec({ viewModel.uiEvent.test { viewModel.saveUserInfo() - awaitToastEvent().assertToast(R.string.toast_info_updated, ToastType.INFO) + expectToast(R.string.toast_info_updated, ToastType.INFO) eventually(2.seconds) { val state = viewModel.uiState.value as UiState.Success @@ -337,5 +336,280 @@ class UserInfoViewModelBehaviorSpec : AppBehaviorSpec({ } } } + + `when`("닉네임 중복확인이 성공하면") { + val setUserNicknameUseCase = mockk() + val getUserCollegeDepartmentUseCase = mockk() + val setUserCollegeDepartmentUseCase = mockk() + val validateNicknameServerUseCase = mockk() + val validateNicknameLocalUseCase = mockk() + val userRepository = mockk() + + coEvery { + getUserCollegeDepartmentUseCase() + } returns sampleUserInfo( + nickname = "oldNick", + college = baseCollege, + department = baseDepartment, + ) + coEvery { userRepository.getTotalColleges() } returns listOf(baseCollege) + coEvery { userRepository.getTotalDepartments(baseCollege.collegeId) } returns listOf(baseDepartment) + every { validateNicknameLocalUseCase(any(), any(), any()) } returns NicknameValidationResult.Valid + coEvery { validateNicknameServerUseCase("newNick") } returns Result.success(Unit) + + val viewModel = UserInfoViewModel( + setUserNicknameUseCase = setUserNicknameUseCase, + getUserCollegeDepartmentUseCase = getUserCollegeDepartmentUseCase, + setUserCollegeDepartmentUseCase = setUserCollegeDepartmentUseCase, + validateNicknameServerUseCase = validateNicknameServerUseCase, + validateNicknameLocalUseCase = validateNicknameLocalUseCase, + userRepository = userRepository, + ) + + then("중복확인 완료 상태가 true가 된다") { + runTest { + eventually(2.seconds) { + (viewModel.uiState.value is UiState.Success) shouldBe true + } + + viewModel.onNicknameChanged("newNick") + viewModel.checkNicknameDuplication() + + eventually(2.seconds) { + val state = viewModel.uiState.value as UiState.Success + state.data.isDuplicationChecked shouldBe true + state.data.nicknameValidationError shouldBe null + } + } + } + } + + `when`("닉네임 중복확인 실패 메시지가 null이면") { + val setUserNicknameUseCase = mockk() + val getUserCollegeDepartmentUseCase = mockk() + val setUserCollegeDepartmentUseCase = mockk() + val validateNicknameServerUseCase = mockk() + val validateNicknameLocalUseCase = mockk() + val userRepository = mockk() + + coEvery { + getUserCollegeDepartmentUseCase() + } returns sampleUserInfo( + nickname = "oldNick", + college = baseCollege, + department = baseDepartment, + ) + coEvery { userRepository.getTotalColleges() } returns listOf(baseCollege) + coEvery { userRepository.getTotalDepartments(baseCollege.collegeId) } returns listOf(baseDepartment) + every { validateNicknameLocalUseCase(any(), any(), any()) } returns NicknameValidationResult.Valid + coEvery { validateNicknameServerUseCase("newNick") } returns Result.failure(IllegalStateException()) + + val viewModel = UserInfoViewModel( + setUserNicknameUseCase = setUserNicknameUseCase, + getUserCollegeDepartmentUseCase = getUserCollegeDepartmentUseCase, + setUserCollegeDepartmentUseCase = setUserCollegeDepartmentUseCase, + validateNicknameServerUseCase = validateNicknameServerUseCase, + validateNicknameLocalUseCase = validateNicknameLocalUseCase, + userRepository = userRepository, + ) + + then("기본 닉네임 오류 리소스를 사용한다") { + runTest { + eventually(2.seconds) { + (viewModel.uiState.value is UiState.Success) shouldBe true + } + + viewModel.onNicknameChanged("newNick") + viewModel.checkNicknameDuplication() + + eventually(2.seconds) { + val state = viewModel.uiState.value as UiState.Success + state.data.nicknameValidationError.asStringResIdOrNull() shouldBe R.string.nickname_error_invalid + } + } + } + } + + `when`("닉네임만 바꿔 저장하면") { + val setUserNicknameUseCase = mockk() + val getUserCollegeDepartmentUseCase = mockk() + val setUserCollegeDepartmentUseCase = mockk() + val validateNicknameServerUseCase = mockk() + val validateNicknameLocalUseCase = mockk() + val userRepository = mockk() + + coEvery { + getUserCollegeDepartmentUseCase() + } returns sampleUserInfo( + nickname = "oldNick", + college = baseCollege, + department = baseDepartment, + ) + coEvery { userRepository.getTotalColleges() } returns listOf(baseCollege) + coEvery { userRepository.getTotalDepartments(baseCollege.collegeId) } returns listOf(baseDepartment) + every { validateNicknameLocalUseCase(any(), any(), any()) } returns NicknameValidationResult.Valid + coEvery { validateNicknameServerUseCase("newNick") } returns Result.success(Unit) + coEvery { setUserNicknameUseCase("newNick") } returns Result.success(Unit) + + val viewModel = UserInfoViewModel( + setUserNicknameUseCase = setUserNicknameUseCase, + getUserCollegeDepartmentUseCase = getUserCollegeDepartmentUseCase, + setUserCollegeDepartmentUseCase = setUserCollegeDepartmentUseCase, + validateNicknameServerUseCase = validateNicknameServerUseCase, + validateNicknameLocalUseCase = validateNicknameLocalUseCase, + userRepository = userRepository, + ) + + then("닉네임 수정 토스트를 보낸다") { + runTest { + eventually(2.seconds) { + (viewModel.uiState.value is UiState.Success) shouldBe true + } + + viewModel.onNicknameChanged("newNick") + viewModel.checkNicknameDuplication() + + viewModel.uiEvent.test { + viewModel.saveUserInfo() + expectToast(R.string.toast_nickname_changed, ToastType.INFO) + cancelAndIgnoreRemainingEvents() + } + } + } + } + + `when`("학과만 바꿔 저장하면") { + val setUserNicknameUseCase = mockk() + val getUserCollegeDepartmentUseCase = mockk() + val setUserCollegeDepartmentUseCase = mockk() + val validateNicknameServerUseCase = mockk() + val validateNicknameLocalUseCase = mockk() + val userRepository = mockk() + + coEvery { + getUserCollegeDepartmentUseCase() + } returns sampleUserInfo( + nickname = "oldNick", + college = baseCollege, + department = baseDepartment, + ) + coEvery { userRepository.getTotalColleges() } returns listOf(baseCollege, otherCollege) + coEvery { userRepository.getTotalDepartments(otherCollege.collegeId) } returns listOf(otherDepartment) + coEvery { userRepository.getTotalDepartments(baseCollege.collegeId) } returns listOf(baseDepartment) + every { validateNicknameLocalUseCase(any(), any(), any()) } returns NicknameValidationResult.Valid + coEvery { userRepository.setUserDepartment(otherDepartment.departmentId) } returns true + coEvery { setUserCollegeDepartmentUseCase(otherCollege, otherDepartment) } returns Unit + + val viewModel = UserInfoViewModel( + setUserNicknameUseCase = setUserNicknameUseCase, + getUserCollegeDepartmentUseCase = getUserCollegeDepartmentUseCase, + setUserCollegeDepartmentUseCase = setUserCollegeDepartmentUseCase, + validateNicknameServerUseCase = validateNicknameServerUseCase, + validateNicknameLocalUseCase = validateNicknameLocalUseCase, + userRepository = userRepository, + ) + + then("학과 수정 토스트를 보낸다") { + runTest { + eventually(2.seconds) { + (viewModel.uiState.value is UiState.Success) shouldBe true + } + + viewModel.selectCollege(otherCollege) + eventually(2.seconds) { + val state = viewModel.uiState.value as UiState.Success + state.data.departmentList shouldBe listOf(otherDepartment) + } + viewModel.selectDepartment(otherDepartment) + + viewModel.uiEvent.test { + viewModel.saveUserInfo() + expectToast(R.string.toast_department_updated, ToastType.INFO) + cancelAndIgnoreRemainingEvents() + } + } + } + } + } + + given("UserInfoData 버튼 활성화 규칙") { + val baseCollege = College(collegeId = 1, collegeName = "IT") + val baseDepartment = Department(departmentId = 11, departmentName = "컴퓨터학부") + + `when`("닉네임이 변경됐고 로컬 검증 오류가 없으며 중복확인을 안 했으면") { + then("중복확인 버튼이 활성화된다") { + UserInfoData( + nickname = "new", + originalNickname = "old", + isNicknameChanged = true, + nicknameValidationError = null, + isDuplicationChecked = false, + ).canCheckDuplication shouldBe true + } + } + + `when`("닉네임 변경 + 중복확인 완료 + 검증 오류 없음이면") { + then("저장 버튼이 활성화된다") { + UserInfoData( + nickname = "new", + originalNickname = "old", + isNicknameChanged = true, + nicknameValidationError = null, + isDuplicationChecked = true, + ).canSave shouldBe true + } + } + + `when`("닉네임 변경이 있지만 중복확인을 하지 않았으면") { + then("저장 버튼이 비활성화된다") { + UserInfoData( + nickname = "new", + originalNickname = "old", + isNicknameChanged = true, + nicknameValidationError = null, + isDuplicationChecked = false, + ).canSave shouldBe false + } + } + + `when`("학과/단과대만 변경됐고 학과가 선택되어 있으면") { + then("저장 버튼이 활성화된다") { + UserInfoData( + selectedCollege = baseCollege, + originalCollege = baseCollege, + isCollegeChanged = true, + selectedDepartment = baseDepartment, + originalDepartment = baseDepartment, + isDepartmentChanged = false, + ).canSave shouldBe true + } + } + + `when`("학과/단과대만 변경됐지만 학과가 비어있으면") { + then("저장 버튼이 비활성화된다") { + UserInfoData( + selectedCollege = baseCollege, + originalCollege = baseCollege, + isCollegeChanged = true, + selectedDepartment = null, + originalDepartment = baseDepartment, + ).canSave shouldBe false + } + } + + `when`("변경사항이 전혀 없으면") { + then("중복확인/저장 버튼 모두 비활성화된다") { + val data = UserInfoData( + nickname = "same", + originalNickname = "same", + isNicknameChanged = false, + nicknameValidationError = null, + isDuplicationChecked = false, + ) + + data.canCheckDuplication shouldBe false + data.canSave shouldBe false + } + } } }) diff --git a/app/src/test/java/com/eatssu/android/test/TestHelpers.kt b/app/src/test/java/com/eatssu/android/test/TestHelpers.kt index 920775fa9..54a337138 100644 --- a/app/src/test/java/com/eatssu/android/test/TestHelpers.kt +++ b/app/src/test/java/com/eatssu/android/test/TestHelpers.kt @@ -2,7 +2,9 @@ package com.eatssu.android.test import app.cash.turbine.ReceiveTurbine import com.eatssu.common.UiEvent +import com.eatssu.common.UiState import com.eatssu.common.UiText +import com.eatssu.common.enums.ToastType import io.kotest.matchers.shouldBe fun UiText?.asStringResIdOrNull(): Int? = (this as? UiText.StringResource)?.resId @@ -11,7 +13,22 @@ suspend fun ReceiveTurbine.awaitToastEvent(): UiEvent.ShowToast { return awaitItem() as UiEvent.ShowToast } -fun UiEvent.ShowToast.assertToast(resId: Int, type: com.eatssu.common.enums.ToastType) { +fun UiEvent.ShowToast.assertToast(resId: Int, type: ToastType) { message.asStringResIdOrNull() shouldBe resId this.type shouldBe type } + +suspend fun ReceiveTurbine.expectToast(resId: Int, type: ToastType): UiEvent.ShowToast { + return awaitToastEvent().also { it.assertToast(resId, type) } +} + +suspend fun ReceiveTurbine.expectNavigateBack() { + awaitItem() shouldBe UiEvent.NavigateBack +} + +inline fun UiState<*>.successDataAs(): T { + val success = this as? UiState.Success<*> + ?: error("Expected UiState.Success but was $this") + return success.data as? T + ?: error("Expected success data type ${T::class}, but was ${success.data?.let { it::class }}") +} From 9058c93644de1e8e0c4dd0d14c433c5de36822e3 Mon Sep 17 00:00:00 2001 From: PeraSite Date: Sun, 15 Feb 2026 02:46:13 +0900 Subject: [PATCH 16/21] =?UTF-8?q?test:=20=EC=9C=A0=EC=A0=80=20=EC=A0=95?= =?UTF-8?q?=EB=B3=B4=20=EC=A0=80=EC=9E=A5=20=EB=B6=84=EA=B8=B0=20=EC=A1=B0?= =?UTF-8?q?=EA=B1=B4=20=ED=85=8C=EC=8A=A4=ED=8A=B8=EB=A5=BC=20=EB=B3=B4?= =?UTF-8?q?=EA=B0=95=ED=96=88=EC=96=B4=EC=9A=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../userinfo/UserInfoViewModelBehaviorSpec.kt | 104 ++++++++++++++++++ 1 file changed, 104 insertions(+) diff --git a/app/src/test/java/com/eatssu/android/presentation/mypage/userinfo/UserInfoViewModelBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/presentation/mypage/userinfo/UserInfoViewModelBehaviorSpec.kt index 7ff063d54..3903541f3 100644 --- a/app/src/test/java/com/eatssu/android/presentation/mypage/userinfo/UserInfoViewModelBehaviorSpec.kt +++ b/app/src/test/java/com/eatssu/android/presentation/mypage/userinfo/UserInfoViewModelBehaviorSpec.kt @@ -25,6 +25,7 @@ import io.mockk.coVerify import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import kotlin.time.Duration.Companion.seconds @@ -530,6 +531,109 @@ class UserInfoViewModelBehaviorSpec : AppBehaviorSpec({ } } } + + `when`("단과대만 바꾼 상태로 저장하면") { + val setUserNicknameUseCase = mockk() + val getUserCollegeDepartmentUseCase = mockk() + val setUserCollegeDepartmentUseCase = mockk() + val validateNicknameServerUseCase = mockk() + val validateNicknameLocalUseCase = mockk() + val userRepository = mockk() + + coEvery { + getUserCollegeDepartmentUseCase() + } returns sampleUserInfo( + nickname = "oldNick", + college = baseCollege, + department = baseDepartment, + ) + coEvery { userRepository.getTotalColleges() } returns listOf(baseCollege, otherCollege) + coEvery { userRepository.getTotalDepartments(otherCollege.collegeId) } returns emptyList() + coEvery { userRepository.getTotalDepartments(baseCollege.collegeId) } returns listOf(baseDepartment) + every { validateNicknameLocalUseCase(any(), any(), any()) } returns NicknameValidationResult.Valid + + val viewModel = UserInfoViewModel( + setUserNicknameUseCase = setUserNicknameUseCase, + getUserCollegeDepartmentUseCase = getUserCollegeDepartmentUseCase, + setUserCollegeDepartmentUseCase = setUserCollegeDepartmentUseCase, + validateNicknameServerUseCase = validateNicknameServerUseCase, + validateNicknameLocalUseCase = validateNicknameLocalUseCase, + userRepository = userRepository, + ) + + then("현재 동작대로 Loading 상태에서 조기 종료된다") { + runTest { + eventually(2.seconds) { + (viewModel.uiState.value is UiState.Success) shouldBe true + } + + viewModel.selectCollege(otherCollege) + advanceUntilIdle() + + viewModel.uiEvent.test { + viewModel.saveUserInfo() + advanceUntilIdle() + + expectNoEvents() + cancelAndIgnoreRemainingEvents() + } + + viewModel.uiState.value shouldBe UiState.Loading + coVerify(exactly = 0) { userRepository.setUserDepartment(any()) } + } + } + } + + `when`("학과 저장 API가 실패하면") { + val setUserNicknameUseCase = mockk() + val getUserCollegeDepartmentUseCase = mockk() + val setUserCollegeDepartmentUseCase = mockk() + val validateNicknameServerUseCase = mockk() + val validateNicknameLocalUseCase = mockk() + val userRepository = mockk() + + coEvery { + getUserCollegeDepartmentUseCase() + } returns sampleUserInfo( + nickname = "oldNick", + college = baseCollege, + department = baseDepartment, + ) + coEvery { userRepository.getTotalColleges() } returns listOf(baseCollege, otherCollege) + coEvery { userRepository.getTotalDepartments(otherCollege.collegeId) } returns listOf(otherDepartment) + coEvery { userRepository.getTotalDepartments(baseCollege.collegeId) } returns listOf(baseDepartment) + every { validateNicknameLocalUseCase(any(), any(), any()) } returns NicknameValidationResult.Valid + coEvery { userRepository.setUserDepartment(otherDepartment.departmentId) } returns false + + val viewModel = UserInfoViewModel( + setUserNicknameUseCase = setUserNicknameUseCase, + getUserCollegeDepartmentUseCase = getUserCollegeDepartmentUseCase, + setUserCollegeDepartmentUseCase = setUserCollegeDepartmentUseCase, + validateNicknameServerUseCase = validateNicknameServerUseCase, + validateNicknameLocalUseCase = validateNicknameLocalUseCase, + userRepository = userRepository, + ) + + then("Error 상태로 전이되고 로컬 학과 갱신은 수행하지 않는다") { + runTest { + eventually(2.seconds) { + (viewModel.uiState.value is UiState.Success) shouldBe true + } + + viewModel.selectCollege(otherCollege) + eventually(2.seconds) { + val state = viewModel.uiState.value as UiState.Success + state.data.departmentList shouldBe listOf(otherDepartment) + } + viewModel.selectDepartment(otherDepartment) + viewModel.saveUserInfo() + advanceUntilIdle() + + viewModel.uiState.value shouldBe UiState.Error + coVerify(exactly = 0) { setUserCollegeDepartmentUseCase(any(), any()) } + } + } + } } given("UserInfoData 버튼 활성화 규칙") { From d82c9ecca2b769e196da20d472b5ef896fe425e5 Mon Sep 17 00:00:00 2001 From: PeraSite Date: Sun, 15 Feb 2026 03:21:24 +0900 Subject: [PATCH 17/21] fix: reflect gemini review feedback in review and user info flows --- .../review/modify/ModifyViewModel.kt | 1 + .../review/write/WriteReviewViewModel.kt | 6 +++ .../mypage/userinfo/UserInfoViewModel.kt | 48 +++++++++++++++++-- app/src/main/res/values/strings.xml | 1 + .../modify/ModifyViewModelBehaviorSpec.kt | 14 ++++-- .../write/WriteReviewViewModelBehaviorSpec.kt | 15 ++---- .../userinfo/UserInfoViewModelBehaviorSpec.kt | 8 ++-- 7 files changed, 73 insertions(+), 20 deletions(-) diff --git a/app/src/main/java/com/eatssu/android/presentation/cafeteria/review/modify/ModifyViewModel.kt b/app/src/main/java/com/eatssu/android/presentation/cafeteria/review/modify/ModifyViewModel.kt index 626a8c35b..cd095488c 100644 --- a/app/src/main/java/com/eatssu/android/presentation/cafeteria/review/modify/ModifyViewModel.kt +++ b/app/src/main/java/com/eatssu/android/presentation/cafeteria/review/modify/ModifyViewModel.kt @@ -74,6 +74,7 @@ class ModifyViewModel @Inject constructor( ToastType.ERROR ) ) + return@launch } _uiEvent.emit(UiEvent.NavigateBack) diff --git a/app/src/main/java/com/eatssu/android/presentation/cafeteria/review/write/WriteReviewViewModel.kt b/app/src/main/java/com/eatssu/android/presentation/cafeteria/review/write/WriteReviewViewModel.kt index a8ec9af6b..72faafcce 100644 --- a/app/src/main/java/com/eatssu/android/presentation/cafeteria/review/write/WriteReviewViewModel.kt +++ b/app/src/main/java/com/eatssu/android/presentation/cafeteria/review/write/WriteReviewViewModel.kt @@ -115,6 +115,12 @@ class WriteReviewViewModel @Inject constructor( val compressedFile = compressImage(context, originalFile) if (compressedFile != null && compressedFile.exists()) { imageUrl = getImageUrlUseCase(compressedFile) + if (imageUrl == null) { + _uiState.value = UiState.Success(editing) // 되돌림 + _uiEvent.emit(UiEvent.ShowToast(UiText.StringResource(R.string.toast_image_upload_failed), ToastType.ERROR)) + originalFile.delete() + return@launch + } _uiEvent.emit(UiEvent.ShowToast(UiText.StringResource(R.string.toast_image_upload_success), ToastType.SUCCESS)) // 원본 파일 삭제 (압축된 파일만 유지) diff --git a/app/src/main/java/com/eatssu/android/presentation/mypage/userinfo/UserInfoViewModel.kt b/app/src/main/java/com/eatssu/android/presentation/mypage/userinfo/UserInfoViewModel.kt index 2d6b7c57d..db7ef426d 100644 --- a/app/src/main/java/com/eatssu/android/presentation/mypage/userinfo/UserInfoViewModel.kt +++ b/app/src/main/java/com/eatssu/android/presentation/mypage/userinfo/UserInfoViewModel.kt @@ -198,10 +198,30 @@ class UserInfoViewModel @Inject constructor( fun saveUserInfo() { viewModelScope.launch { val currentState = _uiState.value as? UiState.Success ?: return@launch + val data = currentState.data + + if ((data.isCollegeChanged || data.isDepartmentChanged) && data.selectedDepartment == null) { + _uiEvent.emit( + UiEvent.ShowToast( + UiText.StringResource(R.string.toast_department_required), + ToastType.ERROR + ) + ) + return@launch + } + + if ((data.isCollegeChanged || data.isDepartmentChanged) && data.selectedCollege == null) { + _uiEvent.emit( + UiEvent.ShowToast( + UiText.StringResource(R.string.toast_college_required), + ToastType.ERROR + ) + ) + return@launch + } _uiState.update { UiState.Loading } - val data = currentState.data var nicknameUpdated = false var departmentUpdated = false @@ -223,8 +243,29 @@ class UserInfoViewModel @Inject constructor( // 학과/단과대 변경이 있는 경우 if (data.isCollegeChanged || data.isDepartmentChanged) { - val department = data.selectedDepartment ?: return@launch - val college = data.selectedCollege ?: return@launch + val department = data.selectedDepartment + if (department == null) { + _uiState.value = UiState.Success(data) + _uiEvent.emit( + UiEvent.ShowToast( + UiText.StringResource(R.string.toast_department_required), + ToastType.ERROR + ) + ) + return@launch + } + + val college = data.selectedCollege + if (college == null) { + _uiState.value = UiState.Success(data) + _uiEvent.emit( + UiEvent.ShowToast( + UiText.StringResource(R.string.toast_college_required), + ToastType.ERROR + ) + ) + return@launch + } val success = userRepository.setUserDepartment(department.departmentId) if (!success) { @@ -305,4 +346,3 @@ data class UserInfoData( } } } - diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 49293065c..bb7b22e5d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -95,6 +95,7 @@ 닉네임 변경에 실패했어요. 단과대를 먼저 선택해주세요. + 학과를 선택해주세요. 정보가 업데이트되었습니다. 닉네임이 변경되었습니다. 학과 정보가 업데이트되었습니다. diff --git a/app/src/test/java/com/eatssu/android/presentation/cafeteria/review/modify/ModifyViewModelBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/presentation/cafeteria/review/modify/ModifyViewModelBehaviorSpec.kt index 40ad9980a..46211eefe 100644 --- a/app/src/test/java/com/eatssu/android/presentation/cafeteria/review/modify/ModifyViewModelBehaviorSpec.kt +++ b/app/src/test/java/com/eatssu/android/presentation/cafeteria/review/modify/ModifyViewModelBehaviorSpec.kt @@ -95,17 +95,25 @@ class ModifyViewModelBehaviorSpec : AppBehaviorSpec({ viewModel.onContentChanged("new") coEvery { useCase2(11L, 4, "new", any()) } returns false - then("현재 동작(characterization): 실패 토스트 후에도 뒤로가기+성공 토스트를 보낸다") { + then("실패 토스트만 보내고 Editing 상태로 되돌린다") { runTest { viewModel.uiEvent.test { viewModel.submit(11L) advanceUntilIdle() awaitToastEvent().assertToast(R.string.toast_review_modify_failed, ToastType.ERROR) - awaitItem() shouldBe UiEvent.NavigateBack - awaitToastEvent().assertToast(R.string.toast_review_modify_success, ToastType.SUCCESS) + expectNoEvents() cancelAndIgnoreRemainingEvents() } + + viewModel.uiState.value shouldBe UiState.Success( + ModifyState.Editing( + rating = 4, + content = "new", + menuLikeInfos = likes, + baseline = ModifyState.Baseline(4, "old", likes), + ) + ) } } } diff --git a/app/src/test/java/com/eatssu/android/presentation/cafeteria/review/write/WriteReviewViewModelBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/presentation/cafeteria/review/write/WriteReviewViewModelBehaviorSpec.kt index 696449546..89cb1e052 100644 --- a/app/src/test/java/com/eatssu/android/presentation/cafeteria/review/write/WriteReviewViewModelBehaviorSpec.kt +++ b/app/src/test/java/com/eatssu/android/presentation/cafeteria/review/write/WriteReviewViewModelBehaviorSpec.kt @@ -210,7 +210,7 @@ class WriteReviewViewModelBehaviorSpec : AppBehaviorSpec({ } } - `when`("이미지 업로드 URL이 null이어도 리뷰 작성이 성공하면") { + `when`("이미지 업로드 URL이 null이면") { val viewModel = WriteReviewViewModel(writeReviewUseCase, getImageUrlUseCase, getValidMenusOfMealUseCase) val context = mockk() val resolver = mockk() @@ -225,13 +225,8 @@ class WriteReviewViewModelBehaviorSpec : AppBehaviorSpec({ mockkObject(Compressor) coEvery { Compressor.compress(context, any()) } returns compressed coEvery { getImageUrlUseCase(compressed) } returns null - coEvery { - writeReviewUseCase(MenuType.FIXED, 1L, 4, "", null, any()) - } returns true - mockkObject(EventLogger) - every { EventLogger.completeReview(any(), any(), any()) } just Runs - then("현재 동작대로 이미지 업로드 성공 토스트 후 리뷰 성공 흐름을 유지한다") { + then("업로드 실패 토스트를 보내고 Editing으로 롤백한다") { runTest { viewModel.loadMenuList(MenuType.FIXED, 1L, "돈가스") advanceUntilIdle() @@ -242,9 +237,9 @@ class WriteReviewViewModelBehaviorSpec : AppBehaviorSpec({ viewModel.postReview(MenuType.FIXED, 1L, context) advanceUntilIdle() - expectToast(R.string.toast_image_upload_success, ToastType.SUCCESS) - expectToast(R.string.toast_review_write_success, ToastType.SUCCESS) - expectNavigateBack() + expectToast(R.string.toast_image_upload_failed, ToastType.ERROR) + viewModel.uiState.value.successDataAs() + coVerify(exactly = 0) { writeReviewUseCase(any(), any(), any(), any(), any(), any()) } cancelAndIgnoreRemainingEvents() } } diff --git a/app/src/test/java/com/eatssu/android/presentation/mypage/userinfo/UserInfoViewModelBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/presentation/mypage/userinfo/UserInfoViewModelBehaviorSpec.kt index 3903541f3..e8a9ca08a 100644 --- a/app/src/test/java/com/eatssu/android/presentation/mypage/userinfo/UserInfoViewModelBehaviorSpec.kt +++ b/app/src/test/java/com/eatssu/android/presentation/mypage/userinfo/UserInfoViewModelBehaviorSpec.kt @@ -561,7 +561,7 @@ class UserInfoViewModelBehaviorSpec : AppBehaviorSpec({ userRepository = userRepository, ) - then("현재 동작대로 Loading 상태에서 조기 종료된다") { + then("학과 선택 안내 토스트를 보내고 Success 상태를 유지한다") { runTest { eventually(2.seconds) { (viewModel.uiState.value is UiState.Success) shouldBe true @@ -574,11 +574,13 @@ class UserInfoViewModelBehaviorSpec : AppBehaviorSpec({ viewModel.saveUserInfo() advanceUntilIdle() - expectNoEvents() + expectToast(R.string.toast_department_required, ToastType.ERROR) cancelAndIgnoreRemainingEvents() } - viewModel.uiState.value shouldBe UiState.Loading + val state = viewModel.uiState.value as UiState.Success + state.data.selectedCollege shouldBe otherCollege + state.data.selectedDepartment shouldBe null coVerify(exactly = 0) { userRepository.setUserDepartment(any()) } } } From 3ad63146253a2920a5ebec8abe402d2bf457ed8b Mon Sep 17 00:00:00 2001 From: PeraSite Date: Sun, 15 Feb 2026 03:35:19 +0900 Subject: [PATCH 18/21] =?UTF-8?q?fix:=20=EA=B3=A0=EC=A0=95=20=EB=A9=94?= =?UTF-8?q?=EB=89=B4=20=EB=A6=AC=EB=B7=B0=EC=97=90=EC=84=9C=20=EB=B9=88=20?= =?UTF-8?q?likeMenuIdList=EB=A5=BC=20null=EB=A1=9C=20=EC=B2=98=EB=A6=AC?= =?UTF-8?q?=ED=96=88=EC=96=B4=EC=9A=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ReviewRepositoryImplBehaviorSpec.kt | 22 ++++++++++--------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/app/src/test/java/com/eatssu/android/data/remote/repository/ReviewRepositoryImplBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/data/remote/repository/ReviewRepositoryImplBehaviorSpec.kt index 791f6605d..14f503c2b 100644 --- a/app/src/test/java/com/eatssu/android/data/remote/repository/ReviewRepositoryImplBehaviorSpec.kt +++ b/app/src/test/java/com/eatssu/android/data/remote/repository/ReviewRepositoryImplBehaviorSpec.kt @@ -13,7 +13,6 @@ import com.eatssu.android.data.remote.dto.response.MyReviewListResponse import com.eatssu.android.data.remote.service.ReviewService import com.eatssu.android.domain.model.Review import com.eatssu.android.test.AppBehaviorSpec -import io.kotest.assertions.throwables.shouldThrow import io.kotest.matchers.collections.shouldHaveSize import io.kotest.matchers.shouldBe import io.mockk.coEvery @@ -129,16 +128,19 @@ class ReviewRepositoryImplBehaviorSpec : AppBehaviorSpec({ } `when`("writeMenuReview에서 likeMenuIdList가 빈 리스트면") { - then("현재 구현 그대로 NoSuchElementException이 발생한다") { + val requestSlot = slot() + coEvery { service.writeMenuReview(capture(requestSlot)) } returns ApiResult.Success(Unit) + + then("menuLike=null로 전달하고 정상 처리한다") { runTest { - shouldThrow { - repository.writeMenuReview( - rating = 1, - content = "x", - imageUrls = emptyList(), - likeMenuIdList = emptyList(), - ) - } + repository.writeMenuReview( + rating = 1, + content = "x", + imageUrls = emptyList(), + likeMenuIdList = emptyList(), + ) shouldBe true + + requestSlot.captured.menuLike shouldBe null } } } From 2486cb6a7fc4d0d6f23228a6c86f169473b2c690 Mon Sep 17 00:00:00 2001 From: PeraSite Date: Sun, 15 Feb 2026 03:35:22 +0900 Subject: [PATCH 19/21] =?UTF-8?q?fix:=20=EA=B3=A0=EC=A0=95=20=EB=A9=94?= =?UTF-8?q?=EB=89=B4=20=EB=A6=AC=EB=B7=B0=EC=97=90=EC=84=9C=20=EB=B9=88=20?= =?UTF-8?q?likeMenuIdList=EB=A5=BC=20null=EB=A1=9C=20=EC=B2=98=EB=A6=AC?= =?UTF-8?q?=ED=96=88=EC=96=B4=EC=9A=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../android/data/remote/repository/ReviewRepositoryImpl.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/eatssu/android/data/remote/repository/ReviewRepositoryImpl.kt b/app/src/main/java/com/eatssu/android/data/remote/repository/ReviewRepositoryImpl.kt index 4d309d006..441285276 100644 --- a/app/src/main/java/com/eatssu/android/data/remote/repository/ReviewRepositoryImpl.kt +++ b/app/src/main/java/com/eatssu/android/data/remote/repository/ReviewRepositoryImpl.kt @@ -61,9 +61,9 @@ class ReviewRepositoryImpl @Inject constructor(private val reviewService: Review rating = rating, content = content, imageUrls = imageUrls, - menuLike = likeMenuIdList?.let { + menuLike = likeMenuIdList?.firstOrNull()?.let { WriteMenuReviewRequest.MenuLike( - menuId = it.first(), + menuId = it, isLike = true, ) } From cf6ac98dbc36c4b8b265ffcbec9d0b84e7574011 Mon Sep 17 00:00:00 2001 From: PeraSite Date: Sun, 15 Feb 2026 03:35:25 +0900 Subject: [PATCH 20/21] =?UTF-8?q?fix:=20=EB=8B=89=EB=84=A4=EC=9E=84?= =?UTF-8?q?=EC=9D=80=20=EC=9B=90=EA=B2=A9=20=EB=B3=80=EA=B2=BD=20=EC=84=B1?= =?UTF-8?q?=EA=B3=B5=20=ED=9B=84=EC=97=90=EB=A7=8C=20=EB=A1=9C=EC=BB=AC=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=ED=95=98=EB=8F=84=EB=A1=9D=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=ED=96=88=EC=96=B4=EC=9A=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/usecase/user/SetUserNicknameUseCase.kt | 11 +++++++---- .../user/UserDelegatingUseCasesBehaviorSpec.kt | 8 ++++---- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/com/eatssu/android/domain/usecase/user/SetUserNicknameUseCase.kt b/app/src/main/java/com/eatssu/android/domain/usecase/user/SetUserNicknameUseCase.kt index 06c1fb916..9a7b6810f 100644 --- a/app/src/main/java/com/eatssu/android/domain/usecase/user/SetUserNicknameUseCase.kt +++ b/app/src/main/java/com/eatssu/android/domain/usecase/user/SetUserNicknameUseCase.kt @@ -22,8 +22,11 @@ class SetUserNicknameUseCase @Inject constructor( private val accountDataStore: AccountDataStore ) { suspend operator fun invoke(nickname: String): Result { - // 로컬 저장 - accountDataStore.setName(nickname) - return userRepository.updateUserName(ChangeNicknameRequest(nickname)) + val result = userRepository.updateUserName(ChangeNicknameRequest(nickname)) + if (result.isSuccess) { + // 서버 닉네임 변경이 성공한 경우에만 로컬 닉네임 변경 + accountDataStore.setName(nickname) + } + return result } -} \ No newline at end of file +} diff --git a/app/src/test/java/com/eatssu/android/domain/usecase/user/UserDelegatingUseCasesBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/domain/usecase/user/UserDelegatingUseCasesBehaviorSpec.kt index 7c90851d6..f3a4fe2de 100644 --- a/app/src/test/java/com/eatssu/android/domain/usecase/user/UserDelegatingUseCasesBehaviorSpec.kt +++ b/app/src/test/java/com/eatssu/android/domain/usecase/user/UserDelegatingUseCasesBehaviorSpec.kt @@ -137,13 +137,13 @@ class UserDelegatingUseCasesBehaviorSpec : AppBehaviorSpec({ `when`("원격 닉네임 변경이 성공하면") { coEvery { userRepository.updateUserName(request) } returns Result.success(Unit) - then("성공 결과를 반환하고 로컬 닉네임을 먼저 저장한다") { + then("성공 결과를 반환하고 원격 성공 후 로컬 닉네임을 저장한다") { runTest { val result = useCase(nickname) result.isSuccess shouldBe true coVerifyOrder { - accountDataStore.setName(nickname) userRepository.updateUserName(request) + accountDataStore.setName(nickname) } } } @@ -152,12 +152,12 @@ class UserDelegatingUseCasesBehaviorSpec : AppBehaviorSpec({ `when`("원격 닉네임 변경이 실패하면") { coEvery { userRepository.updateUserName(request) } returns Result.failure(IllegalStateException("fail")) - then("실패 결과를 반환해도 로컬 닉네임 저장은 수행한다") { + then("실패 결과를 반환하고 로컬 닉네임은 변경하지 않는다") { runTest { val result = useCase(nickname) result.isFailure shouldBe true - coVerify(exactly = 1) { accountDataStore.setName(nickname) } coVerify(exactly = 1) { userRepository.updateUserName(request) } + coVerify(exactly = 0) { accountDataStore.setName(any()) } } } } From 2ccd91486b4a6c2dd7b7bc9480bb1e456fd055b0 Mon Sep 17 00:00:00 2001 From: PeraSite Date: Sun, 15 Feb 2026 03:39:14 +0900 Subject: [PATCH 21/21] =?UTF-8?q?ci:=20debug=20workflow=EC=97=90=EC=84=9C?= =?UTF-8?q?=20unit=20test=20=ED=86=B5=EA=B3=BC=EB=A5=BC=20=ED=95=84?= =?UTF-8?q?=EC=88=98=ED=99=94=ED=96=88=EC=96=B4=EC=9A=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/debug.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/debug.yml b/.github/workflows/debug.yml index 0ea4a6924..428880663 100644 --- a/.github/workflows/debug.yml +++ b/.github/workflows/debug.yml @@ -65,6 +65,9 @@ jobs: - name: Grant execute permission for gradlew run: chmod +x gradlew + - name: Run unit tests (required) + run: ./gradlew :app:testDebugUnitTest :core:common:testDebugUnitTest + # - name: Assemble Debug APK # if: > # github.event_name == 'pull_request' &&