From 79a3e37c3d47b33ba28e9f84c64a423bdd29916b Mon Sep 17 00:00:00 2001 From: unam98 Date: Wed, 24 Jun 2026 23:34:17 +0900 Subject: [PATCH] =?UTF-8?q?test:=20ViewModel=20unit=20test=20=EC=9D=B8?= =?UTF-8?q?=ED=94=84=EB=9D=BC=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EC=B2=AB?= =?UTF-8?q?=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MockK/Turbine/coroutines-test 의존성을 추가하고 MyPageViewModel, VisitorModeManager에 대한 unit test를 작성해 AI 기반 개발 시 회귀를 자동으로 잡아낼 수 있는 안전망을 구축한다. --- app/build.gradle | 3 + .../event/VisitorModeManagerTest.kt | 58 ++++++++ .../mypage/MyPageViewModelTest.kt | 124 ++++++++++++++++++ gradle/libs.versions.toml | 5 + 4 files changed, 190 insertions(+) create mode 100644 app/src/test/java/com/runnect/runnect/presentation/event/VisitorModeManagerTest.kt create mode 100644 app/src/test/java/com/runnect/runnect/presentation/mypage/MyPageViewModelTest.kt diff --git a/app/build.gradle b/app/build.gradle index 24a2f9d68..42145f303 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -188,6 +188,9 @@ dependencies { // Test testImplementation libs.junit + testImplementation libs.mockk + testImplementation libs.turbine + testImplementation libs.kotlinx.coroutines.test androidTestImplementation libs.androidx.junit androidTestImplementation libs.androidx.espresso.core } diff --git a/app/src/test/java/com/runnect/runnect/presentation/event/VisitorModeManagerTest.kt b/app/src/test/java/com/runnect/runnect/presentation/event/VisitorModeManagerTest.kt new file mode 100644 index 000000000..b311ff775 --- /dev/null +++ b/app/src/test/java/com/runnect/runnect/presentation/event/VisitorModeManagerTest.kt @@ -0,0 +1,58 @@ +package com.runnect.runnect.presentation.event + +import android.content.Context +import com.runnect.runnect.application.PreferenceManager +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.unmockkObject +import org.junit.After +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +class VisitorModeManagerTest { + + private val context: Context = mockk() + private lateinit var visitorModeManager: VisitorModeManager + + @Before + fun setUp() { + mockkObject(PreferenceManager) + visitorModeManager = VisitorModeManager(context) + } + + @After + fun tearDown() { + unmockkObject(PreferenceManager) + } + + @Test + fun `access token이 visitor면 방문자 모드다`() { + every { PreferenceManager.getString(any(), any()) } returns "visitor" + + assertTrue(visitorModeManager.isVisitorMode) + } + + @Test + fun `access token이 실제 로그인 토큰이면 방문자 모드가 아니다`() { + every { PreferenceManager.getString(any(), any()) } returns "eyJhbGciOiJIUzI1NiJ9.sometoken" + + assertFalse(visitorModeManager.isVisitorMode) + } + + @Test + fun `토큰이 저장된 적 없는 기본값(none)이면 방문자 모드가 아니다`() { + every { PreferenceManager.getString(any(), any()) } returns "none" + + assertFalse(visitorModeManager.isVisitorMode) + } + + @Test + fun `access token이 빈 문자열이면 EXPIRED로 처리되어 방문자 모드가 아니다`() { + every { PreferenceManager.getString(any(), any()) } returns "" + + assertFalse(visitorModeManager.isVisitorMode) + } +} diff --git a/app/src/test/java/com/runnect/runnect/presentation/mypage/MyPageViewModelTest.kt b/app/src/test/java/com/runnect/runnect/presentation/mypage/MyPageViewModelTest.kt new file mode 100644 index 000000000..0f33a22ce --- /dev/null +++ b/app/src/test/java/com/runnect/runnect/presentation/mypage/MyPageViewModelTest.kt @@ -0,0 +1,124 @@ +package com.runnect.runnect.presentation.mypage + +import app.cash.turbine.test +import app.cash.turbine.turbineScope +import com.runnect.runnect.domain.entity.User +import com.runnect.runnect.domain.repository.UserRepository +import io.mockk.coEvery +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class MyPageViewModelTest { + + private val testDispatcher = StandardTestDispatcher() + + private lateinit var userRepository: UserRepository + private lateinit var viewModel: MyPageViewModel + + @Before + fun setUp() { + Dispatchers.setMain(testDispatcher) + userRepository = mockk() + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `LoadUserInfo 성공 시 유저 정보로 상태가 갱신된다`() = runTest(testDispatcher) { + val user = User( + email = "runner@runnect.com", + latestStamp = "CSPR0", + level = 3, + levelPercent = 42, + nickname = "러너" + ) + coEvery { userRepository.getUserInfo() } returns flow { + delay(1) // onLoading 상태를 별도 프레임으로 관찰하기 위한 실제 suspension 지점 + emit(Result.success(user)) + } + viewModel = MyPageViewModel(userRepository) + + viewModel.state.test { + val initial = awaitItem() + assertEquals(MyPageUiState(), initial) + assertTrue("초기 상태는 isLoading=true가 기본값", initial.isLoading) + + viewModel.intent(MyPageIntent.LoadUserInfo) + + // MyPageUiState의 isLoading 기본값이 true라서, onLoading reduce는 + // 초기 상태와 동일해 StateFlow가 별도로 emit하지 않고 곧바로 결과 상태로 넘어간다. + val success = awaitItem() + assertFalse(success.isLoading) + assertEquals("러너", success.nickname) + assertEquals("CSPR0", success.stampId) + assertEquals("3", success.level) + assertEquals(42, success.levelPercent) + assertEquals("runner@runnect.com", success.email) + assertNull(success.error) + + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `LoadUserInfo 실패 시 에러 상태와 ShowError 이펙트가 발생한다`() = runTest(testDispatcher) { + coEvery { userRepository.getUserInfo() } returns flow { + delay(1) + throw RuntimeException("네트워크 오류") + } + viewModel = MyPageViewModel(userRepository) + + turbineScope { + val stateTurbine = viewModel.state.testIn(backgroundScope) + val effectTurbine = viewModel.effect.testIn(backgroundScope) + + assertEquals(MyPageUiState(), stateTurbine.awaitItem()) + + viewModel.intent(MyPageIntent.LoadUserInfo) + + // 초기 상태가 이미 isLoading=true라 onLoading reduce는 별도로 emit되지 않는다. + val failure = stateTurbine.awaitItem() + assertFalse(failure.isLoading) + assertEquals("네트워크 오류 (unknown)", failure.error) + + val effect = effectTurbine.awaitItem() + assertTrue(effect is MyPageEffect.ShowError) + + stateTurbine.cancelAndIgnoreRemainingEvents() + effectTurbine.cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `UpdateNickname 인텐트는 닉네임 상태를 갱신한다`() = runTest(testDispatcher) { + viewModel = MyPageViewModel(userRepository) + + viewModel.state.test { + assertEquals(MyPageUiState(), awaitItem()) + + viewModel.intent(MyPageIntent.UpdateNickname("새닉네임")) + + assertEquals("새닉네임", awaitItem().nickname) + + cancelAndIgnoreRemainingEvents() + } + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 56b559afc..dc1de1294 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -86,6 +86,8 @@ foojay = "0.10.0" junit = "4.13.2" androidx-junit = "1.3.0" espresso = "3.7.0" +mockk = "1.13.13" +turbine = "1.2.0" [libraries] # AndroidX @@ -180,6 +182,9 @@ tedpermission-normal = { group = "io.github.ParkSangGwon", name = "tedpermission junit = { group = "junit", name = "junit", version.ref = "junit" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidx-junit" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espresso" } +mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" } +turbine = { group = "app.cash.turbine", name = "turbine", version.ref = "turbine" } +kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutines" } [bundles] firebase = [