diff --git a/.github/workflows/ci_build.yml b/.github/workflows/ci_build.yml index 80e6fff..5e64cc8 100644 --- a/.github/workflows/ci_build.yml +++ b/.github/workflows/ci_build.yml @@ -42,6 +42,19 @@ jobs: echo "naver.client.id=$NAVER_CLIENT_ID" >> local.properties echo "naver.client.secret=$NAVER_CLIENT_SECRET" >> local.properties + # Unit Test 실행 + - name: Run Unit Tests + run: ./gradlew testDebugUnitTest + + # 테스트 결과 저장 + - name: Upload Test Results + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-results + path: app/build/reports/tests/testDebugUnitTest + retention-days: 7 + - name: Build Debug APK run: ./gradlew assembleDebug # 디버그 APK 빌드 명령어 실행 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b8a0abc..3112258 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -134,11 +134,20 @@ dependencies { implementation(libs.lottie.compose) // preferences datastore implementation(libs.datastore.preferences) + // coroutines + implementation(libs.coroutines.core) + implementation(libs.coroutines.android) + + // Unit Test + testImplementation(libs.kotest.runner) + testImplementation(libs.kotest.assertion) + testImplementation(libs.mockk) + testImplementation(libs.kotlinx.coroutines.test) - testImplementation(libs.junit) - androidTestImplementation(libs.androidx.junit) - androidTestImplementation(libs.androidx.espresso.core) - androidTestImplementation(libs.androidx.ui.test.junit4) debugImplementation(libs.androidx.ui.tooling) - debugImplementation(libs.androidx.ui.test.manifest) +} + +// Kotest 사용을 위한 JUnit5 설정 +tasks.withType { + useJUnitPlatform() } diff --git a/app/src/main/java/com/min/dnapp/data/repository/LocalSearchRepositoryImpl.kt b/app/src/main/java/com/min/dnapp/data/repository/LocalSearchRepositoryImpl.kt index 6dc6d19..021f891 100644 --- a/app/src/main/java/com/min/dnapp/data/repository/LocalSearchRepositoryImpl.kt +++ b/app/src/main/java/com/min/dnapp/data/repository/LocalSearchRepositoryImpl.kt @@ -1,7 +1,5 @@ package com.min.dnapp.data.repository -import android.net.http.HttpEngine -import android.util.Log import com.min.dnapp.data.remote.LocalSearchResponse import com.min.dnapp.data.remote.LocalSearchService import com.min.dnapp.domain.model.LocalPlace @@ -21,7 +19,7 @@ class LocalSearchRepositoryImpl @Inject constructor( ) : LocalSearchRepository { override fun searchPlaces(query: String): Flow>> = flow { // 로딩 상태 방출 - emit(Resource.Loading()) + emit(Resource.Loading) try { val response: LocalSearchResponse = api.search( diff --git a/app/src/main/java/com/min/dnapp/presentation/find/FindViewModel.kt b/app/src/main/java/com/min/dnapp/presentation/find/FindViewModel.kt index 1ff7b39..8cb3481 100644 --- a/app/src/main/java/com/min/dnapp/presentation/find/FindViewModel.kt +++ b/app/src/main/java/com/min/dnapp/presentation/find/FindViewModel.kt @@ -31,7 +31,7 @@ class FindViewModel @Inject constructor( // 공유된 기록 목록 가져오기 try { val sharedRecords = getSharedRecordUseCase() - Log.d("record", "loaFindData - sharedRecords: $sharedRecords") +// Log.d("record", "loaFindData - sharedRecords: $sharedRecords") val successState = FindUiState.Success( records = sharedRecords ) @@ -40,7 +40,7 @@ class FindViewModel @Inject constructor( _uiState.value = FindUiState.Success( records = emptyList() ) - Log.e("record", "기록 목록 조회 실패", e) +// Log.e("record", "기록 목록 조회 실패", e) } } } diff --git a/app/src/main/java/com/min/dnapp/presentation/home/HomeViewModel.kt b/app/src/main/java/com/min/dnapp/presentation/home/HomeViewModel.kt index 20c6597..d151876 100644 --- a/app/src/main/java/com/min/dnapp/presentation/home/HomeViewModel.kt +++ b/app/src/main/java/com/min/dnapp/presentation/home/HomeViewModel.kt @@ -45,7 +45,7 @@ class HomeViewModel @Inject constructor( val successState: HomeUiState.Success try { val user = getUserDataUseCase(uid) - Log.d("home", "loadHomeData - user: $user") +// Log.d("home", "loadHomeData - user: $user") successState = mapUserToHomeUiState(user) @@ -58,7 +58,7 @@ class HomeViewModel @Inject constructor( // 여행기록 정보 로드 및 상태 업데이트 try { val userRecords = getUserRecordUseCase() - Log.d("home", "loadHomeData - userRecords: $userRecords") +// Log.d("home", "loadHomeData - userRecords: $userRecords") val finalSuccessState = successState.copy( records = userRecords ) @@ -67,7 +67,7 @@ class HomeViewModel @Inject constructor( _uiState.value = successState.copy( records = emptyList() ) - Log.e("home", "기록 정보 로드 실패", e) +// Log.e("home", "기록 정보 로드 실패", e) } } } diff --git a/app/src/main/java/com/min/dnapp/presentation/write/RecordWriteViewModel.kt b/app/src/main/java/com/min/dnapp/presentation/write/RecordWriteViewModel.kt index d625acb..76f61be 100644 --- a/app/src/main/java/com/min/dnapp/presentation/write/RecordWriteViewModel.kt +++ b/app/src/main/java/com/min/dnapp/presentation/write/RecordWriteViewModel.kt @@ -42,7 +42,8 @@ class RecordWriteViewModel @Inject constructor( private val _completeSaveRecord = Channel() val completeSaveRecordFlow = _completeSaveRecord.receiveAsFlow() - private val _snackbarMessage = MutableSharedFlow() + // 최근에 발행된 메시지 1개 저장 + private val _snackbarMessage = MutableSharedFlow(replay = 1) val snackbarMessage = _snackbarMessage.asSharedFlow() // 이전 검색 작업을 취소하기 위한 Job @@ -57,7 +58,7 @@ class RecordWriteViewModel @Inject constructor( */ fun updateTitle(newText: String) { _uiState.value = _uiState.value.copy(recordTitle = newText) - Log.d("write", "updateTitle - newText : $newText") +// Log.d("write", "updateTitle - newText : $newText") } /** @@ -65,7 +66,7 @@ class RecordWriteViewModel @Inject constructor( */ fun updateContent(newText: String) { _uiState.value = _uiState.value.copy(recordContent = newText) - Log.d("write", "updateContent - newText : $newText") +// Log.d("write", "updateContent - newText : $newText") } /** @@ -83,7 +84,7 @@ class RecordWriteViewModel @Inject constructor( */ fun updateEmotion(emotionType: EmotionType) { _uiState.value = _uiState.value.copy(selectedEmotion = emotionType) - Log.d("write", "selectEmotion - emotionType : $emotionType") +// Log.d("write", "selectEmotion - emotionType : $emotionType") } /** @@ -91,7 +92,7 @@ class RecordWriteViewModel @Inject constructor( */ fun updateWeather(weatherType: WeatherType) { _uiState.value = _uiState.value.copy(selectedWeather = weatherType) - Log.d("write", "selectWeather - weatherType : $weatherType") +// Log.d("write", "selectWeather - weatherType : $weatherType") } /** @@ -104,7 +105,7 @@ class RecordWriteViewModel @Inject constructor( if (newQuery.isBlank()) { // 진행중인 검색 작업 취소 searchJob?.cancel() - Log.d("naver", "updateQuery - newQuery blank") +// Log.d("naver", "updateQuery - newQuery blank") } } @@ -116,7 +117,7 @@ class RecordWriteViewModel @Inject constructor( // 검색어가 빈 경우 if (currentQuery.isBlank()) { - Log.d("naver", "searchPlace - newQuery blank") +// Log.d("naver", "searchPlace - newQuery blank") return } @@ -131,28 +132,28 @@ class RecordWriteViewModel @Inject constructor( // 로딩 시작 _uiState.value = _uiState.value.copy(searchState = _uiState.value.searchState.copy( isLoading = true, - places = result.data ?: emptyList(), + places = emptyList(), error = null )) - Log.d("naver", "search for $currentQuery : Loading...") +// Log.d("naver", "search for $currentQuery : Loading...") } is Resource.Success -> { // 성공 _uiState.value = _uiState.value.copy(searchState = _uiState.value.searchState.copy( isLoading = false, - places = result.data ?: emptyList(), + places = result.data, error = null )) - Log.d("naver", "search success : ${result.data?.size} 개") +// Log.d("naver", "search success : ${result.data?.size} 개") } is Resource.Error -> { // 에러 _uiState.value = _uiState.value.copy(searchState = _uiState.value.searchState.copy( isLoading = false, - places = result.data ?: emptyList(), - error = result.message ?: "알 수 없는 에러 발생" + places = emptyList(), + error = result.message )) - Log.e("naver", "search error : ${result.message}") +// Log.e("naver", "search error : ${result.message}") } } } @@ -173,7 +174,7 @@ class RecordWriteViewModel @Inject constructor( places = emptyList(), error = null )) - Log.d("naver", "result cleared") +// Log.d("naver", "result cleared") } /** @@ -188,7 +189,7 @@ class RecordWriteViewModel @Inject constructor( */ fun updateOverseas(newText: String) { _uiState.value = _uiState.value.copy(overseasPlace = newText) - Log.d("write", "updateOverseas - newText : $newText") +// Log.d("write", "updateOverseas - newText : $newText") } /** @@ -196,7 +197,7 @@ class RecordWriteViewModel @Inject constructor( */ fun updateShare(newChecked: Boolean) { _uiState.value = _uiState.value.copy(isShareChecked = newChecked) - Log.d("write", "updateShare - newChecked : $newChecked") +// Log.d("write", "updateShare - newChecked : $newChecked") } /** @@ -218,7 +219,7 @@ class RecordWriteViewModel @Inject constructor( */ fun onPhotoSelected(uri: Uri?) { _uiState.value = _uiState.value.copy(selectedImageUri = uri) - Log.d("write", "onPhotoSelected - uri : $uri") +// Log.d("write", "onPhotoSelected - uri : $uri") } /** @@ -249,7 +250,7 @@ class RecordWriteViewModel @Inject constructor( _completeSaveRecord.send(Unit) }.onFailure { exception -> // 저장 실패 - Log.e("write", "saveRecord - exception : $exception") +// Log.e("write", "saveRecord - exception : $exception") } _uiState.update { it.copy(isSaving = false) } diff --git a/app/src/main/java/com/min/dnapp/util/Resource.kt b/app/src/main/java/com/min/dnapp/util/Resource.kt index 6404418..280490b 100644 --- a/app/src/main/java/com/min/dnapp/util/Resource.kt +++ b/app/src/main/java/com/min/dnapp/util/Resource.kt @@ -1,7 +1,7 @@ package com.min.dnapp.util -sealed class Resource(val data: T? = null, val message: String? = null) { - class Success(data: T) : Resource(data) - class Error(message: String, data: T? = null) : Resource(data, message) - class Loading(data: T? = null) : Resource(data) -} \ No newline at end of file +sealed class Resource { + data class Success(val data: T) : Resource() + data class Error(val message: String) : Resource() + data object Loading : Resource() +} diff --git a/app/src/test/java/com/min/dnapp/ExampleUnitTest.kt b/app/src/test/java/com/min/dnapp/ExampleUnitTest.kt deleted file mode 100644 index 047d7f7..0000000 --- a/app/src/test/java/com/min/dnapp/ExampleUnitTest.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.min.dnapp - -import org.junit.Test - -import org.junit.Assert.* - -/** - * Example local unit test, which will execute on the development machine (host). - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -class ExampleUnitTest { - @Test - fun addition_isCorrect() { - assertEquals(4, 2 + 2) - } -} \ No newline at end of file diff --git a/app/src/test/java/com/min/dnapp/presentation/find/FindViewModelTest.kt b/app/src/test/java/com/min/dnapp/presentation/find/FindViewModelTest.kt new file mode 100644 index 0000000..54b77b4 --- /dev/null +++ b/app/src/test/java/com/min/dnapp/presentation/find/FindViewModelTest.kt @@ -0,0 +1,126 @@ +package com.min.dnapp.presentation.find + +import com.min.dnapp.domain.model.LocalPlace +import com.min.dnapp.domain.model.TripRecord +import com.min.dnapp.domain.model.UserData +import com.min.dnapp.domain.usecase.GetSharedRecordUseCase +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import io.kotest.matchers.types.shouldBeInstanceOf +import io.mockk.coEvery +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain + +@OptIn(ExperimentalCoroutinesApi::class) +class FindViewModelTest : FunSpec({ + + lateinit var testDispatcher: TestDispatcher + lateinit var getSharedUserCase: GetSharedRecordUseCase + lateinit var viewModel: FindViewModel + + beforeEach { + testDispatcher = StandardTestDispatcher() + Dispatchers.setMain(testDispatcher) + + getSharedUserCase = mockk() + } + + afterEach { + Dispatchers.resetMain() + } + + test("공유된 기록 데이터 로드 성공 시 UiState가 Success 상태가 된다") { + val fakeSharedRecords = listOf( + TripRecord( + userId = "user_1", + userData = UserData( + badgeLv = 1, + nickname = "유저_1", + profileImageName = "profile_1", + ), + title = "제주도 여행", + content = "제주도에서의 멋진 하루", + startDateMillis = 1700000000000L, + endDateMillis = 1700086400000L, + emotionKey = "happy", + weatherKey = "cloud", + selectedPlace = LocalPlace( + title = "한라산", + category = "산", + roadAddress = "제주 서귀포시 토평동 산15-1" + ), + overseasPlace = "", + isShareChecked = true, + imageUrl = "", + createdAt = System.currentTimeMillis() + ), + TripRecord( + userId = "user_2", + userData = UserData( + badgeLv = 2, + nickname = "유저_2", + profileImageName = "profile_2", + ), + title = "부산 여행", + content = "부산에서의 멋진 하루", + startDateMillis = 1700000000000L, + endDateMillis = 1700086400000L, + emotionKey = "happy", + weatherKey = "cloud", + selectedPlace = LocalPlace( + title = "광안리해수욕장", + category = "해수욕장,해변", + roadAddress = "부산광역시 수영구 광안해변로 219" + ), + overseasPlace = "", + isShareChecked = true, + imageUrl = "https://firebasestorage.googleapis.com/v0/b/dngo-2a086.firebasestorage.app/o/images%2FGvwFgO0zx4VkIeCBBaPw0WmGCm62%2F1762159948481_1000000740?alt=media&token=3c510698-7c22-4dbc-a400-f7504c52d888", + createdAt = System.currentTimeMillis() + ) + ) + + coEvery { getSharedUserCase() } returns fakeSharedRecords + + viewModel = FindViewModel(getSharedUserCase) + testDispatcher.scheduler.advanceUntilIdle() + + val uiState = viewModel.uiState.value + + // Success 상태, 2개의 기록 있는지 확인 + uiState.shouldBeInstanceOf() + uiState.records.size shouldBe 2 + + uiState.records[0].title shouldBe "제주도 여행" + uiState.records[0].isShareChecked shouldBe true + uiState.records[0].userData?.nickname shouldBe "유저_1" + uiState.records[1].title shouldBe "부산 여행" + uiState.records[1].selectedPlace?.title shouldBe "광안리해수욕장" + } + + test("데이터 로드 실패 시 빈 리스트와 함께 Success 상태가 된다") { + coEvery { getSharedUserCase() } throws Exception("네트워크 연결 오류") + + viewModel = FindViewModel(getSharedUserCase) + testDispatcher.scheduler.advanceUntilIdle() + + val uiState = viewModel.uiState.value + + uiState.shouldBeInstanceOf() + uiState.records shouldBe emptyList() + } + + test("viewModel 초기화 시 Loading 상태로 시작한다") { + coEvery { getSharedUserCase() } returns emptyList() + + viewModel = FindViewModel(getSharedUserCase) + + val uiState = viewModel.uiState.value + + uiState.shouldBeInstanceOf() + } +}) diff --git a/app/src/test/java/com/min/dnapp/presentation/home/HomeViewModelTest.kt b/app/src/test/java/com/min/dnapp/presentation/home/HomeViewModelTest.kt new file mode 100644 index 0000000..289baed --- /dev/null +++ b/app/src/test/java/com/min/dnapp/presentation/home/HomeViewModelTest.kt @@ -0,0 +1,116 @@ +package com.min.dnapp.presentation.home + +import com.min.dnapp.domain.model.TripRecord +import com.min.dnapp.domain.model.User +import com.min.dnapp.domain.usecase.GetCurrentUserIdUseCase +import com.min.dnapp.domain.usecase.GetUserDataUseCase +import com.min.dnapp.domain.usecase.GetUserRecordUseCase +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldContain +import io.kotest.matchers.types.shouldBeInstanceOf +import io.mockk.coEvery +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain + +@OptIn(ExperimentalCoroutinesApi::class) +class HomeViewModelTest : FunSpec({ + + lateinit var testDispatcher: TestDispatcher + lateinit var getCurrentUserIdUseCase: GetCurrentUserIdUseCase + lateinit var getUserDataUseCase: GetUserDataUseCase + lateinit var getUserRecordUseCase: GetUserRecordUseCase + lateinit var viewModel: HomeViewModel + + beforeEach { + // 각 테스트마다 새로운 인스턴스 생성 + testDispatcher = StandardTestDispatcher() + Dispatchers.setMain(testDispatcher) + + getCurrentUserIdUseCase = mockk() + getUserDataUseCase = mockk() + getUserRecordUseCase = mockk() + } + + afterEach { + Dispatchers.resetMain() + } + + test("홈 데이터 로드 성공 시 UiState가 Success 상태가 된다") { + val fakeUid = "uid_123" + val fakeUser = User( + userId = fakeUid, + nickname = "user_123", + profileImageName = "profile_123", + badgeLv = 1, + badgeName = "새내기", + recordCnt = 3, + stampCnt = 5, + createdAt = System.currentTimeMillis() + ) + val fakeRecords = emptyList() + + coEvery { getCurrentUserIdUseCase() } returns fakeUid + coEvery { getUserDataUseCase(fakeUid) } returns fakeUser + coEvery { getUserRecordUseCase() } returns fakeRecords + + viewModel = HomeViewModel( + getUserDataUseCase, + getCurrentUserIdUseCase, + getUserRecordUseCase + ) + + testDispatcher.scheduler.advanceUntilIdle() + + val uiState = viewModel.uiState.value + + uiState.shouldBeInstanceOf() + + uiState.nickname shouldBe "user_123" + uiState.recordCnt shouldBe 3 + uiState.records shouldBe fakeRecords + } + + test("유저 ID가 없을 경우 UiState가 Error 상태가 된다") { + coEvery { getCurrentUserIdUseCase() } returns null + + viewModel = HomeViewModel( + getUserDataUseCase, + getCurrentUserIdUseCase, + getUserRecordUseCase + ) + + testDispatcher.scheduler.advanceUntilIdle() + + val uiState = viewModel.uiState.value + + uiState.shouldBeInstanceOf() + + uiState.message shouldBe "인증 정보 로드 실패: 사용자 인증 정보 없음" + } + + test("유저 데이터 로드 실패 시 UiState가 Error 상태가 된다") { + val fakeUid = "uid_123" + coEvery { getCurrentUserIdUseCase() } returns fakeUid + coEvery { getUserDataUseCase(fakeUid) } throws Exception("DB 연결 오류") + + viewModel = HomeViewModel( + getUserDataUseCase, + getCurrentUserIdUseCase, + getUserRecordUseCase + ) + + testDispatcher.scheduler.advanceUntilIdle() + + val uiState = viewModel.uiState.value + + uiState.shouldBeInstanceOf() + + uiState.message shouldContain "DB 연결 오류" + } +}) diff --git a/app/src/test/java/com/min/dnapp/presentation/write/RecordWriteViewModelTest.kt b/app/src/test/java/com/min/dnapp/presentation/write/RecordWriteViewModelTest.kt new file mode 100644 index 0000000..0c2a28e --- /dev/null +++ b/app/src/test/java/com/min/dnapp/presentation/write/RecordWriteViewModelTest.kt @@ -0,0 +1,339 @@ +package com.min.dnapp.presentation.write + +import android.net.Uri +import com.min.dnapp.domain.model.EmotionType +import com.min.dnapp.domain.model.LocalPlace +import com.min.dnapp.domain.model.WeatherType +import com.min.dnapp.domain.usecase.LocalSearchUseCase +import com.min.dnapp.domain.usecase.SaveRecordUseCase +import com.min.dnapp.util.Resource +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain + +@OptIn(ExperimentalCoroutinesApi::class) +class RecordWriteViewModelTest : FunSpec({ + + lateinit var testDispatcher: TestDispatcher + lateinit var localSearchUseCase: LocalSearchUseCase + lateinit var saveRecordUseCase: SaveRecordUseCase + lateinit var viewModel: RecordWriteViewModel + + beforeEach { + testDispatcher = StandardTestDispatcher() + Dispatchers.setMain(testDispatcher) + + localSearchUseCase = mockk() + saveRecordUseCase = mockk() + } + + afterEach { + Dispatchers.resetMain() + } + + /** + * 테스트 1: 제목 입력 테스트 + * 시나리오: + * - 유저가 제목 입력 + * - UiState의 recordTitle이 업데이트되는지 확인 + */ + test("제목 입력 시 UiState의 recordTitle이 업데이트된다") { + viewModel = RecordWriteViewModel(localSearchUseCase, saveRecordUseCase) + + val testTitle = "제주도 여행" + viewModel.updateTitle(testTitle) + + viewModel.uiState.value.recordTitle shouldBe testTitle + } + + /** + * 테스트 2: 날짜 범위 선택 테스트 + * 시나리오: + * - 유저가 DatePicker에서 시작일과 종료일 선택 + * - 날짜가 UiState에 올바르게 저장되는지 확인 + */ + test("날짜 범위 선택 시 날짜가 UiState에 저장된다") { + viewModel = RecordWriteViewModel(localSearchUseCase, saveRecordUseCase) + + val startDateMillis = 1700000000000L + val endDateMillis = 1700086400000L + viewModel.updateDateRange(startDateMillis, endDateMillis) + + viewModel.uiState.value.selectedStartDateMillis shouldBe startDateMillis + viewModel.uiState.value.selectedEndDateMillis shouldBe endDateMillis + } + + /** + * 테스트 3: 감정 선택 테스트 + * 시나리오: + * - 유저가 감정 바텀시트에서 감정 선택 + * - 선택된 감정이 UiState에 저장되는지 확인 + */ + test("감정 선택 시 감정이 UiState에 저장된다") { + viewModel = RecordWriteViewModel(localSearchUseCase, saveRecordUseCase) + + val selectedEmotion = EmotionType.HAPPY + viewModel.updateEmotion(selectedEmotion) + + viewModel.uiState.value.selectedEmotion shouldBe EmotionType.HAPPY + } + + /** + * 테스트 4: 날씨 선택 테스트 + * 시나리오: + * - 유저가 날씨 바텀시트에서 날씨 선택 + * - 선택된 날씨가 UiState에 저장되는지 확인 + */ + test("날씨 선택 시 날씨가 UiState에 저장된다") { + viewModel = RecordWriteViewModel(localSearchUseCase, saveRecordUseCase) + + val selectedWeather = WeatherType.SUN + viewModel.updateWeather(selectedWeather) + + viewModel.uiState.value.selectedWeather shouldBe WeatherType.SUN + } + + /** + * 테스트 5: 장소 검색 성공 테스트 + * 시나리오: + * - 유저가 검색어 입력 후 검색 버튼 클릭 + * - LocalSearchUseCase가 검색 결과 반환 + * - UiState의 searchState에 결과가 저장되는지 확인 + */ + test("장소 검색 성공 시 검색 결과가 UiState에 저장된다") { + val searchQuery = "광안리" + val fakePlaces = listOf( + LocalPlace( + title = "광안리해수욕장", + category = "해수욕장,해변", + roadAddress = "부산광역시 수영구 광안해변로 219" + ), + LocalPlace( + title = "광안리카페거리", + category = "거리,골목", + roadAddress = "부산 수영구 민락동 178-21" + ) + ) + + // Flow로 Resource를 순차적으로 발행 (Loading -> Success) + val searchFlow = flowOf( + Resource.Loading, + Resource.Success(fakePlaces) + ) + + every { localSearchUseCase(searchQuery) } returns searchFlow + + viewModel = RecordWriteViewModel(localSearchUseCase, saveRecordUseCase) + + // 검색어 입력 후 검색 실행 + viewModel.updateQuery(searchQuery) + viewModel.searchPlace() + + // Flow의 모든 값이 수집될때까지 대기 + testDispatcher.scheduler.advanceUntilIdle() + + val searchState = viewModel.uiState.value.searchState + + searchState.isLoading shouldBe false + searchState.places.size shouldBe 2 + searchState.places[0].title shouldBe "광안리해수욕장" + searchState.error shouldBe null + } + + /** + * 테스트 6: 장소 검색 실패 테스트 + * 시나리오: + * - 유저가 검색 실행 + * - 검색 실패 + * - 에러 메시지가 UiState에 저장되는지 확인 + */ + test("장소 검색 실패 시 에러 메시지가 저장된다") { + val searchQuery = "없는장소" + val errorMessage = "검색 결과가 없습니다" + + val searchFlow = flowOf( + Resource.Loading, + Resource.Error(errorMessage) + ) + + every { localSearchUseCase(searchQuery) } returns searchFlow + + viewModel = RecordWriteViewModel(localSearchUseCase, saveRecordUseCase) + + viewModel.updateQuery(searchQuery) + viewModel.searchPlace() + testDispatcher.scheduler.advanceUntilIdle() + + val searchState = viewModel.uiState.value.searchState + + searchState.isLoading shouldBe false + searchState.error shouldBe errorMessage + searchState.places shouldBe emptyList() + } + + /** + * 테스트 7: 검색 결과에서 장소 선택 테스트 + * 시나리오: + * - 검색 결과가 표시됨 + * - 유저가 특정 장소를 선택 + * - 선택된 장소가 UiState에 저장되는지 확인 + */ + test("검색 결과에서 장소 선택 시 UiState에 저장된다") { + viewModel = RecordWriteViewModel(localSearchUseCase, saveRecordUseCase) + + val seletedPlace = LocalPlace( + title = "광안리해수욕장", + category = "해수욕장,해변", + roadAddress = "부산광역시 수영구 광안해변로 219" + ) + viewModel.updatePlace(seletedPlace) + + viewModel.uiState.value.selectedPlace shouldBe seletedPlace + viewModel.uiState.value.selectedPlace?.title shouldBe "광안리해수욕장" + } + + /** + * 테스트 8: 해외 여행지 입력 테스트 + * 시나리오: + * - 유저가 해외 여행지를 직접 입력 + * - overseasPlace가 UiState에 저장되는지 확인 + */ + test("해외 여행지 입력 시 UiState에 저장된다") { + viewModel = RecordWriteViewModel(localSearchUseCase, saveRecordUseCase) + + val overseasPlace = "오사카" + viewModel.updateOverseas(overseasPlace) + + viewModel.uiState.value.overseasPlace shouldBe "오사카" + } + + /** + * 테스트 9: 공유 설정 토글 테스트 + * 시나리오: + * - 유저가 공유 스위치를 ON/OFF + * - isShareChecked가 UiState에 반영되는지 확인 + * - 초기값은 true (공유 ON) + */ + test("공유 설정 변경 시 UiState가 업데이트된다") { + viewModel = RecordWriteViewModel(localSearchUseCase, saveRecordUseCase) + viewModel.uiState.value.isShareChecked shouldBe true + + // 공유 OFF로 변경 + viewModel.updateShare(false) + viewModel.uiState.value.isShareChecked shouldBe false + + // 다시 ON으로 변경 + viewModel.updateShare(true) + viewModel.uiState.value.isShareChecked shouldBe true + } + + /** + * 테스트 10: 이미지 선택 테스트 + * 시나리오: + * - 유저가 갤러리에서 이미지 선택 + * - 선택된 이미지의 URI가 UiState에 저장되는지 확인 + */ + test("이미지 선택 시 URI가 UiState에 저장된다") { + viewModel = RecordWriteViewModel(localSearchUseCase, saveRecordUseCase) + val mockUri: Uri = mockk(relaxed = true) + + viewModel.onPhotoSelected(mockUri) + + viewModel.uiState.value.selectedImageUri shouldBe mockUri + viewModel.uiState.value.selectedImageUri shouldNotBe null + } + + /** + * 테스트 11: 기록 저장 성공 테스트 + * 시나리오: + * - 모든 필수 항목이 입력된 상태 + * - 완료 버튼 클릭해야 기록 저장 + * - saveRecordUseCase가 호출되는지 확인 + * - completeSaveRecordFlow에서 이벤트가 발행되는지 확인 + */ + test("모든 필수 항목이 입력된 경우 기록 저장이 성공한다") { + // saveRecordUseCase가 성공 반환하도록 설정 + coEvery { + saveRecordUseCase(any(), any()) + } returns Result.success(Unit) + + viewModel = RecordWriteViewModel(localSearchUseCase, saveRecordUseCase) + + // 필수 항목 입력 + viewModel.updateTitle("부산 여행") + viewModel.updateContent("부산 여행 최고") + viewModel.updateDateRange(1700000000000L, 1700086400000L) + viewModel.updateEmotion(EmotionType.HAPPY) + viewModel.updateWeather(WeatherType.SUN) + viewModel.updatePlace( + LocalPlace( + title = "광안리해수욕장", + category = "해수욕장,해변", + roadAddress = "부산광역시 수영구 광안해변로 219" + ) + ) + + // 기록 저장 + viewModel.saveRecord() + testDispatcher.scheduler.advanceUntilIdle() + + // saveRecordUseCase가 1번 호출되었는지 확인 + coVerify(exactly = 1) { + saveRecordUseCase(any(), any()) + } + + // 이벤트가 발행되었는지 확인 + val completeEvent = viewModel.completeSaveRecordFlow.first() + completeEvent shouldBe Unit + } + + /** + * 테스트 12: 필수 항목 누락 시 저장 실패 테스트 + * 시나리오: + * - 필수 항목(제목) 미입력 상태 + * - 완료 버튼 클릭 + * - snackbarMessage 발행되는지 확인 + * - saveRecordUseCase가 호출되지 않는지 확인 + */ + test("제목이 빈 경우 저장 실패 하고 메시지가 발행된다") { + viewModel = RecordWriteViewModel(localSearchUseCase, saveRecordUseCase) + + // 제목 비워둔 상태 + viewModel.updateContent("부산 여행 최고") + viewModel.updateDateRange(1700000000000L, 1700086400000L) + viewModel.updateEmotion(EmotionType.HAPPY) + viewModel.updateWeather(WeatherType.SUN) + viewModel.updatePlace( + LocalPlace( + title = "광안리해수욕장", + category = "해수욕장,해변", + roadAddress = "부산광역시 수영구 광안해변로 219" + ) + ) + + // 기록 저장 + viewModel.saveRecord() + testDispatcher.scheduler.advanceUntilIdle() + + // saveRecordUseCase가 호출되지 않았는지 확인 + coVerify(exactly = 0) { + saveRecordUseCase(any(), any()) + } + + // 스낵바메시지 확인 + val snackbarMessage = viewModel.snackbarMessage.first() + snackbarMessage.message shouldBe WriteMessage.TITLE_EMPTY + } +}) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c44dc68..19ee81c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -2,9 +2,6 @@ agp = "8.11.1" kotlin = "2.2.0" coreKtx = "1.17.0" -junit = "4.13.2" -junitVersion = "1.3.0" -espressoCore = "3.7.0" lifecycleRuntimeKtx = "2.9.2" activityCompose = "1.10.1" #composeBom = "2024.09.00" @@ -24,21 +21,19 @@ okhttp = "5.0.0" coil = "3.1.0" lottie = "6.6.9" datastore = "1.1.7" +coroutines = "1.10.2" +mockk = "1.14.0" +kotest = "5.8.1" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } -junit = { group = "junit", name = "junit", version.ref = "junit" } -androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } -androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" } androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } androidx-ui = { group = "androidx.compose.ui", name = "ui" } -androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } -androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } -androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } +androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } androidx-material3 = { group = "androidx.compose.material3", name = "material3" } firebase-bom = { module = "com.google.firebase:firebase-bom", version.ref = "firebaseBom" } @@ -61,6 +56,14 @@ coil = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" } coil-okhttp = { module = "io.coil-kt.coil3:coil-network-okhttp", version.ref = "coil" } lottie-compose = { module = "com.airbnb.android:lottie-compose", version.ref = "lottie" } datastore-preferences = { module = "androidx.datastore:datastore-preferences" , version.ref = "datastore" } +coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } +coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" } + +# Unit Test +kotest-runner = { module = "io.kotest:kotest-runner-junit5-jvm", version.ref = "kotest" } +kotest-assertion = { module = "io.kotest:kotest-assertions-core-jvm", version.ref = "kotest" } +mockk = { module = "io.mockk:mockk", version.ref = "mockk" } +kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" }