From f6aab33e6296cadb26c9eff0af99652ac932ccf6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Mon, 24 Aug 2026 19:43:32 +0900 Subject: [PATCH 1/2] =?UTF-8?q?test(mindrecord):=20DailyQuestionRepository?= =?UTF-8?q?=20fake=206=EB=B2=8C=EC=9D=84=20=EA=B3=84=EC=95=BD=20=EC=98=86?= =?UTF-8?q?=20testFixtures=201=EB=B2=8C=EB=A1=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 계약을 하나 바꾸면 fake 를 여러 곳 고쳐야 하는데, 그 여러 곳이 서로 다른 컴파일 단위에 흩어져 있었다. 작성자는 자기 모듈이 초록인 걸 보고 다 고쳤다고 믿지만 app/src/androidTest 쪽 복사본은 신호 없이 남는다 — #911 이 create/update 를 Result 으로 바꿨을 때가 그대로였고, 그 잔재가 #1022 의 6건이 됐다. fake 를 도메인 모듈의 src/testFixtures 로 옮긴다. 이제 계약에 메서드를 추가하면 :feature:mindrecord:domain:compileDebugTestFixturesKotlin 이 그 자리에서 깨진다 (실측 확인). 유닛 테스트와 androidTest 는 testFixtures(...) 로 같은 정본을 쓴다. 픽스처 설계: - 기본 동작은 메모리 저장소. 호출은 전부 기록해 테스트가 사후에 단언한다. - onGetList/onGetToday/onCreate/onUpdate/onDelete 람다로 갈아끼운다 — 경합용 게이트, 스크립트된 실패처럼 저장소 동작으로 표현 못 하는 시나리오용. - strict() 는 모든 호출을 unexpectedCall 로 터뜨리고 쓰는 것만 열게 한다. 기본 동작이 관대해지면 시나리오별 fake 가 error("호출되면 안 됨") 으로 지키던 경계가 사라지므로, 그 경계를 옮길 자리를 남긴다. 기록 접근자 이름이 바뀐 단언 4종(createCallCount→createdPayloads.size, deleteCalls→deletedIds, listCalls→listQueries.size, todayCalls→getTodayCalls). 의미는 그대로다. 검증: :koverXmlReportCi :konsist:test :build-logic:test :app:compileDebugAndroidTestKotlin 전 모듈 ktlintCheck 통과. --- app/build.gradle.kts | 1 + .../afternote_fe/MindRecordFlowAndroidTest.kt | 13 +- .../MindRecordLifecycleAndroidTest.kt | 54 ++----- ...meLetterMindRecordCompletionAndroidTest.kt | 115 ++++----------- .../afternote/afternote_fe/test/TestFakes.kt | 36 ----- .../afternote_fe/test/TestHiltModules.kt | 1 + .../kotlin/AndroidDomainConventionPlugin.kt | 13 +- feature/home/presentation/build.gradle.kts | 1 + .../home/presentation/HomeTabViewModelTest.kt | 46 ++---- .../testing/FakeDailyQuestionRepository.kt | 136 ++++++++++++++++++ .../domain/testing/UnexpectedCall.kt | 10 ++ .../mindrecord/presentation/build.gradle.kts | 1 + .../DailyQuestionWriteViewModelTest.kt | 70 ++------- .../viewmodel/DraftListDeleteTest.kt | 60 ++------ 14 files changed, 250 insertions(+), 307 deletions(-) create mode 100644 feature/mindrecord/domain/src/testFixtures/kotlin/com/afternote/feature/mindrecord/domain/testing/FakeDailyQuestionRepository.kt create mode 100644 feature/mindrecord/domain/src/testFixtures/kotlin/com/afternote/feature/mindrecord/domain/testing/UnexpectedCall.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index aa19a5f5ab..e59a83e18d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -238,6 +238,7 @@ dependencies { androidTestImplementation(projects.core.data) androidTestImplementation(projects.feature.afternote.domain) androidTestImplementation(projects.feature.mindrecord.domain) + androidTestImplementation(testFixtures(projects.feature.mindrecord.domain)) androidTestImplementation(projects.feature.receiver.domain) androidTestImplementation(projects.feature.timeletter.domain) kspAndroidTest(libs.hilt.compiler) diff --git a/app/src/androidTest/java/com/afternote/afternote_fe/MindRecordFlowAndroidTest.kt b/app/src/androidTest/java/com/afternote/afternote_fe/MindRecordFlowAndroidTest.kt index 97d075dba4..dfb16939f2 100644 --- a/app/src/androidTest/java/com/afternote/afternote_fe/MindRecordFlowAndroidTest.kt +++ b/app/src/androidTest/java/com/afternote/afternote_fe/MindRecordFlowAndroidTest.kt @@ -14,12 +14,12 @@ import androidx.compose.ui.test.waitUntilAtLeastOneExists import androidx.lifecycle.SavedStateHandle import androidx.test.ext.junit.runners.AndroidJUnit4 import com.afternote.afternote_fe.test.FailureArtifactRule -import com.afternote.afternote_fe.test.FakeDailyQuestionRepository import com.afternote.afternote_fe.test.FakeDiaryRepository import com.afternote.afternote_fe.test.FakeUserRepository import com.afternote.core.domain.repository.PhotoUploadRepository import com.afternote.core.ui.theme.AfternoteTheme import com.afternote.feature.mindrecord.domain.model.TodayMood +import com.afternote.feature.mindrecord.domain.testing.FakeDailyQuestionRepository import com.afternote.feature.mindrecord.presentation.screen.sender.DiaryWriteScreen import com.afternote.feature.mindrecord.presentation.viewmodel.DailyQuestionWriteViewModel import com.afternote.feature.mindrecord.presentation.viewmodel.DiaryWriteViewModel @@ -105,8 +105,15 @@ class MindRecordFlowAndroidTest { @Test fun dailyQuestion_failureThenRetry_keepsAnswerAndAvoidsDuplicateSuccess() { val repository = FakeDailyQuestionRepository() - repository.createResults.addLast(Result.failure(IllegalStateException("offline"))) - repository.createResults.addLast(Result.success(1L)) + // 첫 저장은 실패, 재시도는 성공 — 실패 후 답변이 남는지와 중복 성공이 없는지를 본다. + val createResults = + ArrayDeque( + listOf( + Result.failure(IllegalStateException("offline")), + Result.success(FakeDailyQuestionRepository.FIRST_CREATED_ID), + ), + ) + repository.onCreate = { createResults.removeFirst() } val viewModel = DailyQuestionWriteViewModel( repository = repository, diff --git a/app/src/androidTest/java/com/afternote/afternote_fe/MindRecordLifecycleAndroidTest.kt b/app/src/androidTest/java/com/afternote/afternote_fe/MindRecordLifecycleAndroidTest.kt index 777f6c7457..4c0b28c3f7 100644 --- a/app/src/androidTest/java/com/afternote/afternote_fe/MindRecordLifecycleAndroidTest.kt +++ b/app/src/androidTest/java/com/afternote/afternote_fe/MindRecordLifecycleAndroidTest.kt @@ -25,15 +25,15 @@ import com.afternote.feature.mindrecord.domain.model.DiaryUpdatePayload import com.afternote.feature.mindrecord.domain.model.MindRecordSummary import com.afternote.feature.mindrecord.domain.model.MindRecordType import com.afternote.feature.mindrecord.domain.model.ReceiverMindRecords -import com.afternote.feature.mindrecord.domain.model.TodayDailyQuestion import com.afternote.feature.mindrecord.domain.model.TodayMood import com.afternote.feature.mindrecord.domain.model.WeeklyReport import com.afternote.feature.mindrecord.domain.model.WeeklyReportDailyQuestion import com.afternote.feature.mindrecord.domain.model.WeeklyReportDay -import com.afternote.feature.mindrecord.domain.repository.DailyQuestionRepository import com.afternote.feature.mindrecord.domain.repository.DiaryRepository import com.afternote.feature.mindrecord.domain.repository.MindRecordReceiverRepository import com.afternote.feature.mindrecord.domain.repository.WeeklyReportRepository +import com.afternote.feature.mindrecord.domain.testing.FakeDailyQuestionRepository +import com.afternote.feature.mindrecord.domain.testing.unexpectedCall import com.afternote.feature.mindrecord.presentation.model.DayBackground import com.afternote.feature.mindrecord.presentation.model.DayContent import com.afternote.feature.mindrecord.presentation.screen.receiver.ReceiverMindRecordScreen @@ -138,8 +138,12 @@ class MindRecordLifecycleAndroidTest { ) } val dailyQuestionRepository = - PrivateDailyQuestionRepository( - questions = + FakeDailyQuestionRepository( + // 이 시나리오는 임시저장 목록의 조회·삭제만 태운다. + onGetToday = { unexpectedCall("DailyQuestionRepository.getToday") }, + onCreate = { unexpectedCall("DailyQuestionRepository.create") }, + onUpdate = { _, _ -> unexpectedCall("DailyQuestionRepository.update") }, + initialAnswers = listOf( DailyQuestion( dailyQuestionId = 202L, @@ -176,7 +180,7 @@ class MindRecordLifecycleAndroidTest { .assertIsDisplayed() composeRule.onNodeWithText("질문 임시저장").assertDoesNotExist() composeRule.onNodeWithText("일기 임시저장").assertIsDisplayed() - assertEquals(listOf(202L), dailyQuestionRepository.deleteCalls) + assertEquals(listOf(202L), dailyQuestionRepository.deletedIds) assertTrue(diaryRepository.deleteCalls.isEmpty()) composeRule.waitUntil(timeoutMillis = TIMEOUT) { @@ -186,9 +190,9 @@ class MindRecordLifecycleAndroidTest { composeRule.onNodeWithText("전체 삭제").performClick() composeRule.onNodeWithText("임시 저장된 항목이 없습니다.").assertIsDisplayed() assertEquals(listOf(201L), diaryRepository.deleteCalls) - assertEquals(listOf(202L), dailyQuestionRepository.deleteCalls) + assertEquals(listOf(202L), dailyQuestionRepository.deletedIds) assertEquals(3, diaryRepository.listCalls.size) - assertEquals(3, dailyQuestionRepository.listCalls.size) + assertEquals(3, dailyQuestionRepository.listQueries.size) assertTrue((viewModel.uiState.value as DraftListUiState.Success).items.isEmpty()) } @@ -365,42 +369,6 @@ private class PrivateDiaryRepository : DiaryRepository { } } -private data class PrivateDailyQuestionQuery( - val date: String?, - val draftOnly: Boolean?, -) - -private class PrivateDailyQuestionRepository( - questions: List, -) : DailyQuestionRepository { - private var questions = questions - val listCalls = mutableListOf() - val deleteCalls = mutableListOf() - - override suspend fun getList( - date: String?, - draftOnly: Boolean?, - ): Result> { - listCalls += PrivateDailyQuestionQuery(date, draftOnly) - return Result.success(questions) - } - - override suspend fun getToday(): Result = error("Unexpected today question load") - - override suspend fun create(payload: DailyQuestionCreatePayload): Result = error("Unexpected question create") - - override suspend fun update( - id: Long, - payload: DailyQuestionUpdatePayload, - ): Result = error("Unexpected question update") - - override suspend fun delete(id: Long): Result { - deleteCalls += id - questions = questions.filterNot { it.dailyQuestionId == id } - return Result.success(Unit) - } -} - private class PrivateWeeklyReportRepository : WeeklyReportRepository { val results = ArrayDeque>() val requestedDates = mutableListOf() diff --git a/app/src/androidTest/java/com/afternote/afternote_fe/TimeLetterMindRecordCompletionAndroidTest.kt b/app/src/androidTest/java/com/afternote/afternote_fe/TimeLetterMindRecordCompletionAndroidTest.kt index 3fde0c2765..190b155582 100644 --- a/app/src/androidTest/java/com/afternote/afternote_fe/TimeLetterMindRecordCompletionAndroidTest.kt +++ b/app/src/androidTest/java/com/afternote/afternote_fe/TimeLetterMindRecordCompletionAndroidTest.kt @@ -47,9 +47,9 @@ import com.afternote.feature.mindrecord.domain.model.WeeklyReport import com.afternote.feature.mindrecord.domain.model.WeeklyReportDailyQuestion import com.afternote.feature.mindrecord.domain.model.WeeklyReportDay import com.afternote.feature.mindrecord.domain.model.WeeklyReportEmotion -import com.afternote.feature.mindrecord.domain.repository.DailyQuestionRepository import com.afternote.feature.mindrecord.domain.repository.DiaryRepository import com.afternote.feature.mindrecord.domain.repository.WeeklyReportRepository +import com.afternote.feature.mindrecord.domain.testing.FakeDailyQuestionRepository import com.afternote.feature.mindrecord.presentation.screen.memoryspace.MemorySpaceScreen import com.afternote.feature.mindrecord.presentation.screen.sender.DailyQuestionAnswerListScreen import com.afternote.feature.mindrecord.presentation.screen.sender.DailyQuestionWriteScreen @@ -331,7 +331,16 @@ class TimeLetterMindRecordCompletionAndroidTest { @Test fun mindRecordHome_dailyQuestionLoadingEmptyAndErrorRetrySuccess_areRendered() { val emptyGate = CompletableDeferred>>() - val emptyRepository = CompletionDailyQuestionRepository(nextListGate = emptyGate) + val emptyRepository = + FakeDailyQuestionRepository(today = completionToday()).apply { + // 첫 조회를 붙잡아 로딩 상태를 만든다. 그 뒤 조회는 저장소 기본 동작(0건). + var gate: CompletableDeferred>>? = emptyGate + onGetList = { _, _ -> + val pending = gate + gate = null + pending?.await() ?: Result.success(emptyList()) + } + } var activeViewModel by mutableStateOf(DailyQuestionListViewModel(emptyRepository)) composeRule.setContent { @@ -340,7 +349,7 @@ class TimeLetterMindRecordCompletionAndroidTest { } } - composeRule.waitUntil(timeoutMillis = TIMEOUT) { emptyRepository.listCalls == 1 } + composeRule.waitUntil(timeoutMillis = TIMEOUT) { emptyRepository.listQueries.size == 1 } assertEquals(DailyQuestionListUiState.Loading, activeViewModel.uiState.value) composeRule .onNode(hasProgressBarRangeInfo(ProgressBarRangeInfo.Indeterminate)) @@ -352,9 +361,13 @@ class TimeLetterMindRecordCompletionAndroidTest { } composeRule.onNodeWithText("아직 등록된 답변이 없어요.").assertIsDisplayed() + val listResults = + ArrayDeque>>().apply { + addLast(Result.failure(IllegalStateException("home offline"))) + } val retryRepository = - CompletionDailyQuestionRepository().apply { - listResults.addLast(Result.failure(IllegalStateException("home offline"))) + FakeDailyQuestionRepository(today = completionToday()).apply { + onGetList = { _, _ -> listResults.removeFirst() } } val retryViewModel = DailyQuestionListViewModel(retryRepository) composeRule.runOnIdle { activeViewModel = retryViewModel } @@ -363,7 +376,7 @@ class TimeLetterMindRecordCompletionAndroidTest { } composeRule.onNodeWithText("home offline").assertIsDisplayed() - retryRepository.listResults.addLast( + listResults.addLast( Result.success( listOf( dailyQuestion(id = 81L, title = "재시도로 돌아온 답변"), @@ -377,13 +390,13 @@ class TimeLetterMindRecordCompletionAndroidTest { } composeRule.onNodeWithText("재시도로 돌아온 답변").assertIsDisplayed() composeRule.onNodeWithText("노출되면 안 되는 임시답변").assertDoesNotExist() - assertEquals(2, retryRepository.listCalls) - assertEquals(2, retryRepository.todayCalls) + assertEquals(2, retryRepository.listQueries.size) + assertEquals(2, retryRepository.getTodayCalls) } @Test fun dailyQuestionWrite_successRefreshesExistingListWithCreatedAnswer() { - val repository = CompletionDailyQuestionRepository() + val repository = FakeDailyQuestionRepository(today = completionToday()) val listViewModel = DailyQuestionListViewModel(repository) var writeViewModel by mutableStateOf(null) var submitSuccessCalls = 0 @@ -434,11 +447,11 @@ class TimeLetterMindRecordCompletionAndroidTest { } composeRule.onNodeWithText("오늘의 테스트 질문").assertIsDisplayed() composeRule.onNodeWithText("작성 후 목록에 반영될 답변").assertIsDisplayed() - assertEquals(1, repository.createCalls.size) - assertEquals(71L, repository.createCalls.single().questionId) - assertEquals(false, repository.createCalls.single().isDraft) - assertEquals(2, repository.listCalls) - assertEquals(3, repository.todayCalls) + assertEquals(1, repository.createdPayloads.size) + assertEquals(71L, repository.createdPayloads.single().questionId) + assertEquals(false, repository.createdPayloads.single().isDraft) + assertEquals(2, repository.listQueries.size) + assertEquals(3, repository.getTodayCalls) } @Test @@ -461,7 +474,7 @@ class TimeLetterMindRecordCompletionAndroidTest { ), ), ) - val draftDailyQuestionRepository = CompletionDailyQuestionRepository() + val draftDailyQuestionRepository = FakeDailyQuestionRepository(today = completionToday()) val draftListViewModel = DraftListViewModel( loader = MindRecordDraftLoader(repository, draftDailyQuestionRepository), @@ -558,7 +571,7 @@ class TimeLetterMindRecordCompletionAndroidTest { ), ), ) - val viewModel = MemorySpaceViewModel(diaryRepository, CompletionDailyQuestionRepository()) + val viewModel = MemorySpaceViewModel(diaryRepository, FakeDailyQuestionRepository()) var backCalls = 0 composeRule.setContent { @@ -763,74 +776,8 @@ private object CompletionFileMetadataRepository : FileMetadataRepository { override suspend fun getMimeType(uriString: String): String? = error("Unexpected MIME lookup: $uriString") } -private class CompletionDailyQuestionRepository( - var nextListGate: CompletableDeferred>>? = null, -) : DailyQuestionRepository { - val listResults = ArrayDeque>>() - val storedAnswers = mutableListOf() - val createCalls = mutableListOf() - val updateCalls = mutableListOf>() - var today = TodayDailyQuestion(71L, 71, "오늘의 테스트 질문", false) - var listCalls = 0 - private set - var todayCalls = 0 - private set - - override suspend fun getList( - date: String?, - draftOnly: Boolean?, - ): Result> { - listCalls += 1 - val gate = nextListGate - nextListGate = null - return gate?.await() ?: listResults.removeFirstOrNull() ?: Result.success(storedAnswers.toList()) - } - - override suspend fun getToday(): Result { - todayCalls += 1 - return Result.success(today) - } - - override suspend fun create(payload: DailyQuestionCreatePayload): Result { - createCalls += payload - val createdId = 800L + createCalls.size - storedAnswers += - DailyQuestion( - dailyQuestionId = createdId, - title = today.content, - content = payload.content, - createdAt = "2026-08-22", - imageUrl = payload.imageUrl, - isDraft = payload.isDraft, - ) - today = today.copy(isAnswered = !payload.isDraft, isDraft = payload.isDraft) - return Result.success(createdId) - } - - override suspend fun update( - id: Long, - payload: DailyQuestionUpdatePayload, - ): Result { - updateCalls += id to payload - storedAnswers.replaceAll { answer -> - if (answer.dailyQuestionId == id) { - answer.copy( - content = payload.content ?: answer.content, - imageUrl = payload.imageUrl ?: answer.imageUrl, - isDraft = payload.isDraft ?: answer.isDraft, - ) - } else { - answer - } - } - return Result.success(id) - } - - override suspend fun delete(id: Long): Result { - storedAnswers.removeAll { it.dailyQuestionId == id } - return Result.success(Unit) - } -} +/** 이 파일의 데일리질문 시나리오가 공유하는 "오늘의 질문". `questionId` 를 단언하는 곳이 있다. */ +private fun completionToday() = TodayDailyQuestion(71L, 71, "오늘의 테스트 질문", false) private class CompletionDiaryRepository( val drafts: MutableList, diff --git a/app/src/androidTest/java/com/afternote/afternote_fe/test/TestFakes.kt b/app/src/androidTest/java/com/afternote/afternote_fe/test/TestFakes.kt index ba27408d8d..f4d2c47381 100644 --- a/app/src/androidTest/java/com/afternote/afternote_fe/test/TestFakes.kt +++ b/app/src/androidTest/java/com/afternote/afternote_fe/test/TestFakes.kt @@ -15,13 +15,9 @@ import com.afternote.core.model.user.User import com.afternote.core.model.user.UserConnectedAccount import com.afternote.core.model.user.UserPushSetting import com.afternote.feature.mindrecord.domain.model.DailyQuestion -import com.afternote.feature.mindrecord.domain.model.DailyQuestionCreatePayload -import com.afternote.feature.mindrecord.domain.model.DailyQuestionUpdatePayload import com.afternote.feature.mindrecord.domain.model.DiaryCreatePayload import com.afternote.feature.mindrecord.domain.model.DiaryList import com.afternote.feature.mindrecord.domain.model.DiaryUpdatePayload -import com.afternote.feature.mindrecord.domain.model.TodayDailyQuestion -import com.afternote.feature.mindrecord.domain.repository.DailyQuestionRepository import com.afternote.feature.mindrecord.domain.repository.DiaryRepository import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow @@ -257,38 +253,6 @@ class FakeDiaryRepository( override suspend fun delete(id: Long): Result = Result.success(Unit) } -class FakeDailyQuestionRepository( - var todayResult: Result = - Result.success(TodayDailyQuestion(1L, 1, "오늘의 질문", false)), -) : DailyQuestionRepository { - val createdPayloads = mutableListOf() - val createResults = ArrayDeque>() - - override suspend fun getList( - date: String?, - draftOnly: Boolean?, - ): Result> = Result.success(emptyList()) - - override suspend fun getToday(): Result = todayResult - - override suspend fun create(payload: DailyQuestionCreatePayload): Result { - createdPayloads += payload - return createResults.removeFirstOrNull() ?: Result.success(CREATED_DAILY_QUESTION_ID) - } - - override suspend fun update( - id: Long, - payload: DailyQuestionUpdatePayload, - ): Result = Result.success(id) - - override suspend fun delete(id: Long): Result = Result.success(Unit) - - private companion object { - /** 서버가 돌려주는 `userDailyQuestionId` 자리 (#573). 값 자체에 의미는 없다. */ - const val CREATED_DAILY_QUESTION_ID = 1L - } -} - class FakeErrorReporter : ErrorReporter { val failures = mutableListOf>>() diff --git a/app/src/androidTest/java/com/afternote/afternote_fe/test/TestHiltModules.kt b/app/src/androidTest/java/com/afternote/afternote_fe/test/TestHiltModules.kt index f4ac30b43b..e481d24f91 100644 --- a/app/src/androidTest/java/com/afternote/afternote_fe/test/TestHiltModules.kt +++ b/app/src/androidTest/java/com/afternote/afternote_fe/test/TestHiltModules.kt @@ -13,6 +13,7 @@ import com.afternote.feature.mindrecord.domain.repository.DailyQuestionRepositor import com.afternote.feature.mindrecord.domain.repository.DiaryRepository import com.afternote.feature.mindrecord.domain.repository.MindRecordReceiverRepository import com.afternote.feature.mindrecord.domain.repository.WeeklyReportRepository +import com.afternote.feature.mindrecord.domain.testing.FakeDailyQuestionRepository import dagger.Module import dagger.Provides import dagger.hilt.components.SingletonComponent diff --git a/build-logic/src/main/kotlin/AndroidDomainConventionPlugin.kt b/build-logic/src/main/kotlin/AndroidDomainConventionPlugin.kt index b25ffed830..a4cbc1680f 100644 --- a/build-logic/src/main/kotlin/AndroidDomainConventionPlugin.kt +++ b/build-logic/src/main/kotlin/AndroidDomainConventionPlugin.kt @@ -1,5 +1,7 @@ +import com.android.build.api.dsl.LibraryExtension import org.gradle.api.Plugin import org.gradle.api.Project +import org.gradle.kotlin.dsl.configure class AndroidDomainConventionPlugin : Plugin { override fun apply(target: Project) { @@ -7,9 +9,18 @@ class AndroidDomainConventionPlugin : Plugin { pluginManager.apply("afternote.android.library") pluginManager.apply("afternote.android.hilt") + // 도메인 계약의 fake 정본은 계약 옆(src/testFixtures)에 둔다 — 유닛 테스트와 + // androidTest 가 각자 복사본을 들고 있으면 계약이 바뀔 때 고칠 곳이 갈라지고, + // 그중 일부만 고친 채로 초록을 보게 된다 (#1030). + extensions.configure { + testFixtures { + enable = true + } + } + afterNoteDependencies { project(":core:model") } } } -} \ No newline at end of file +} diff --git a/feature/home/presentation/build.gradle.kts b/feature/home/presentation/build.gradle.kts index a2d9dd51fa..4b9089f29e 100644 --- a/feature/home/presentation/build.gradle.kts +++ b/feature/home/presentation/build.gradle.kts @@ -21,6 +21,7 @@ dependencies { // HomeTabViewModel 경합 테스트 — 가상 시간으로 viewModelScope 요청 순서를 제어한다. testImplementation(libs.coroutines.test) + testImplementation(testFixtures(projects.feature.mindrecord.domain)) screenshotTestImplementation(libs.screenshot.validation.api) screenshotTestImplementation(libs.androidx.compose.ui.tooling) diff --git a/feature/home/presentation/src/test/kotlin/com/afternote/feature/home/presentation/HomeTabViewModelTest.kt b/feature/home/presentation/src/test/kotlin/com/afternote/feature/home/presentation/HomeTabViewModelTest.kt index bc1e219bb8..0e19cdb7da 100644 --- a/feature/home/presentation/src/test/kotlin/com/afternote/feature/home/presentation/HomeTabViewModelTest.kt +++ b/feature/home/presentation/src/test/kotlin/com/afternote/feature/home/presentation/HomeTabViewModelTest.kt @@ -13,15 +13,12 @@ import com.afternote.core.model.user.User import com.afternote.core.model.user.UserConnectedAccount import com.afternote.core.model.user.UserPushSetting import com.afternote.feature.home.presentation.usecase.GetHomeSummaryUseCase -import com.afternote.feature.mindrecord.domain.model.DailyQuestion -import com.afternote.feature.mindrecord.domain.model.DailyQuestionCreatePayload -import com.afternote.feature.mindrecord.domain.model.DailyQuestionUpdatePayload import com.afternote.feature.mindrecord.domain.model.DiaryCreatePayload import com.afternote.feature.mindrecord.domain.model.DiaryList import com.afternote.feature.mindrecord.domain.model.DiaryUpdatePayload import com.afternote.feature.mindrecord.domain.model.TodayDailyQuestion -import com.afternote.feature.mindrecord.domain.repository.DailyQuestionRepository import com.afternote.feature.mindrecord.domain.repository.DiaryRepository +import com.afternote.feature.mindrecord.domain.testing.FakeDailyQuestionRepository import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -271,13 +268,20 @@ private class Fixture { private class FakeHomeRepositories { val userRepository = FakeUserRepository() val diaryRepository = FakeDiaryRepository() - val dailyQuestionRepository = FakeDailyQuestionRepository() + + /** 완료 시점을 테스트가 쥐고 있어야 병렬 조회의 경합 순서를 만들 수 있다. */ + private val questionResults = ArrayDeque>>() + + val dailyQuestionRepository = + FakeDailyQuestionRepository.strict().apply { + onGetToday = { questionResults.takeNext("DailyQuestionRepository.getToday").await() } + } fun enqueueRequest(): PendingHomeRequest = PendingHomeRequest().also { request -> userRepository.enqueue(request.profile, request.receivers) diaryRepository.enqueue(request.diary) - dailyQuestionRepository.enqueue(request.question) + questionResults.addLast(request.question) } } @@ -441,36 +445,6 @@ private class FakeDiaryRepository : DiaryRepository { override suspend fun delete(id: Long): Result = unexpected("DiaryRepository.delete") } -private class FakeDailyQuestionRepository : DailyQuestionRepository { - private val results = ArrayDeque>>() - - var getTodayCalls: Int = 0 - private set - - fun enqueue(result: CompletableDeferred>) { - results.addLast(result) - } - - override suspend fun getToday(): Result { - getTodayCalls++ - return results.takeNext("DailyQuestionRepository.getToday").await() - } - - override suspend fun getList( - date: String?, - draftOnly: Boolean?, - ): Result> = unexpected("DailyQuestionRepository.getList") - - override suspend fun create(payload: DailyQuestionCreatePayload): Result = unexpected("DailyQuestionRepository.create") - - override suspend fun update( - id: Long, - payload: DailyQuestionUpdatePayload, - ): Result = unexpected("DailyQuestionRepository.update") - - override suspend fun delete(id: Long): Result = unexpected("DailyQuestionRepository.delete") -} - private class FakeUserProfileRepository : UserProfileRepository { var cachedUserName: String? = null val savedUserNames = mutableListOf() diff --git a/feature/mindrecord/domain/src/testFixtures/kotlin/com/afternote/feature/mindrecord/domain/testing/FakeDailyQuestionRepository.kt b/feature/mindrecord/domain/src/testFixtures/kotlin/com/afternote/feature/mindrecord/domain/testing/FakeDailyQuestionRepository.kt new file mode 100644 index 0000000000..ab2b3243ac --- /dev/null +++ b/feature/mindrecord/domain/src/testFixtures/kotlin/com/afternote/feature/mindrecord/domain/testing/FakeDailyQuestionRepository.kt @@ -0,0 +1,136 @@ +package com.afternote.feature.mindrecord.domain.testing + +import com.afternote.feature.mindrecord.domain.model.DailyQuestion +import com.afternote.feature.mindrecord.domain.model.DailyQuestionCreatePayload +import com.afternote.feature.mindrecord.domain.model.DailyQuestionUpdatePayload +import com.afternote.feature.mindrecord.domain.model.TodayDailyQuestion +import com.afternote.feature.mindrecord.domain.repository.DailyQuestionRepository + +/** + * [DailyQuestionRepository] fake 정본. + * + * 기본 동작은 **메모리 저장소**다 — 생성하면 [answers] 에 쌓이고, 수정·삭제가 그 목록에 + * 반영되며, 조회는 그 상태를 돌려준다. 호출은 전부 기록되므로 테스트는 사후에 단언한다. + * + * 기본 동작으로 안 되는 시나리오(경합을 만들려고 조회를 붙잡아 두기, 특정 호출을 실패로 + * 밀어 넣기, "이 시나리오에서 호출되면 안 됨" 을 그 자리에서 터뜨리기)는 `onX` 람다로 + * 갈아끼운다. 람다가 `null` 이면 기본 동작이다. + * + * 시나리오마다 클래스를 새로 만들지 않는다 — 계약이 바뀔 때 고칠 곳이 갈라지는 게 + * androidTest 컴파일 파손의 원인이었다 (#936, #1022, #1030). + */ +class FakeDailyQuestionRepository( + initialAnswers: List = emptyList(), + var today: TodayDailyQuestion = DEFAULT_TODAY, + var onGetList: (suspend (String?, Boolean?) -> Result>)? = null, + var onGetToday: (suspend () -> Result)? = null, + var onCreate: (suspend (DailyQuestionCreatePayload) -> Result)? = null, + var onUpdate: (suspend (Long, DailyQuestionUpdatePayload) -> Result)? = null, + var onDelete: (suspend (Long) -> Result)? = null, +) : DailyQuestionRepository { + /** 메모리 저장소. 테스트가 직접 들여다보거나 조작해도 된다. */ + val answers: MutableList = initialAnswers.toMutableList() + + val listQueries = mutableListOf() + val createdPayloads = mutableListOf() + val updatedPayloads = mutableListOf>() + val deletedIds = mutableListOf() + + var getTodayCalls: Int = 0 + private set + + /** 다음에 붙일 식별자. 생성 fake 가 돌려주는 `userDailyQuestionId` 자리다 (#573). */ + var nextCreatedId: Long = FIRST_CREATED_ID + + data class ListQuery( + val date: String?, + val draftOnly: Boolean?, + ) + + override suspend fun getList( + date: String?, + draftOnly: Boolean?, + ): Result> { + listQueries += ListQuery(date, draftOnly) + onGetList?.let { return it(date, draftOnly) } + // `draftOnly` 를 생략하면 서버는 제출 완료만 내려준다 — 그 계약을 그대로 흉내 낸다. + val matching = + when (draftOnly) { + null -> answers.filterNot { it.isDraft } + else -> answers.filter { it.isDraft == draftOnly } + } + return Result.success(matching) + } + + override suspend fun getToday(): Result { + getTodayCalls += 1 + onGetToday?.let { return it() } + return Result.success(today) + } + + override suspend fun create(payload: DailyQuestionCreatePayload): Result { + createdPayloads += payload + onCreate?.let { return it(payload) } + val id = nextCreatedId + nextCreatedId += 1 + answers += + DailyQuestion( + dailyQuestionId = id, + title = today.content, + content = payload.content, + createdAt = DEFAULT_CREATED_AT, + imageUrl = payload.imageUrl, + isDraft = payload.isDraft, + ) + today = today.copy(isAnswered = !payload.isDraft, isDraft = payload.isDraft) + return Result.success(id) + } + + override suspend fun update( + id: Long, + payload: DailyQuestionUpdatePayload, + ): Result { + updatedPayloads += id to payload + onUpdate?.let { return it(id, payload) } + answers.replaceAll { answer -> + if (answer.dailyQuestionId == id) { + answer.copy( + content = payload.content ?: answer.content, + imageUrl = payload.imageUrl ?: answer.imageUrl, + isDraft = payload.isDraft ?: answer.isDraft, + ) + } else { + answer + } + } + return Result.success(id) + } + + override suspend fun delete(id: Long): Result { + deletedIds += id + onDelete?.let { return it(id) } + answers.removeAll { it.dailyQuestionId == id } + return Result.success(Unit) + } + + companion object { + const val FIRST_CREATED_ID = 1L + private const val DEFAULT_CREATED_AT = "2026-08-22" + private val DEFAULT_TODAY = TodayDailyQuestion(1L, 1, "오늘의 질문", false) + + /** + * 모든 호출을 그 자리에서 실패시키는 fake. 시나리오가 실제로 쓰는 것만 `onX` 로 연다. + * + * 메모리 저장소 기본 동작이 "안 불릴 줄 알았던 호출" 을 조용히 받아 주면, 종전 + * 시나리오별 fake 가 `error("호출되면 안 됨")` 으로 지키던 경계가 사라진다. + */ + fun strict(): FakeDailyQuestionRepository = + FakeDailyQuestionRepository( + onGetList = { _, _ -> unexpectedCall("DailyQuestionRepository.getList") }, + onGetToday = { unexpectedCall("DailyQuestionRepository.getToday") }, + onCreate = { unexpectedCall("DailyQuestionRepository.create") }, + onUpdate = { _, _ -> unexpectedCall("DailyQuestionRepository.update") }, + onDelete = { unexpectedCall("DailyQuestionRepository.delete") }, + ) + } +} diff --git a/feature/mindrecord/domain/src/testFixtures/kotlin/com/afternote/feature/mindrecord/domain/testing/UnexpectedCall.kt b/feature/mindrecord/domain/src/testFixtures/kotlin/com/afternote/feature/mindrecord/domain/testing/UnexpectedCall.kt new file mode 100644 index 0000000000..553b0753fa --- /dev/null +++ b/feature/mindrecord/domain/src/testFixtures/kotlin/com/afternote/feature/mindrecord/domain/testing/UnexpectedCall.kt @@ -0,0 +1,10 @@ +package com.afternote.feature.mindrecord.domain.testing + +/** + * "이 시나리오에서 호출되면 안 됨" 을 그 자리에서 터뜨린다. + * + * fake 를 하나로 접으면 기본 동작이 관대해져, 안 불릴 줄 알았던 호출이 조용히 통과할 수 + * 있다. 시나리오별 fake 가 `error(...)` 로 지키던 경계를 `onX = { unexpectedCall(...) }` + * 로 그대로 옮긴다. + */ +fun unexpectedCall(method: String): Nothing = error("$method 는 이 시나리오에서 호출되면 안 됨") diff --git a/feature/mindrecord/presentation/build.gradle.kts b/feature/mindrecord/presentation/build.gradle.kts index 9d593b9c83..4e96346bb0 100644 --- a/feature/mindrecord/presentation/build.gradle.kts +++ b/feature/mindrecord/presentation/build.gradle.kts @@ -27,6 +27,7 @@ dependencies { } testImplementation(libs.coroutines.test) + testImplementation(testFixtures(projects.feature.mindrecord.domain)) // 컴파일된 리소스로 문구를 검증한다 — aapt2 의 앞뒤 공백 제거는 소스 XML 만 봐서는 잡히지 않는다 (#732). // 열린 주차 메뉴의 스크롤·최하단 선택도 JVM 에서 그대로 재현한다 (#729). diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionWriteViewModelTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionWriteViewModelTest.kt index c77902d7ad..1bb2c948f6 100644 --- a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionWriteViewModelTest.kt +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionWriteViewModelTest.kt @@ -2,14 +2,12 @@ package com.afternote.feature.mindrecord.presentation.viewmodel import com.afternote.core.domain.repository.PhotoUploadRepository import com.afternote.feature.mindrecord.domain.model.DailyQuestion -import com.afternote.feature.mindrecord.domain.model.DailyQuestionCreatePayload -import com.afternote.feature.mindrecord.domain.model.DailyQuestionUpdatePayload import com.afternote.feature.mindrecord.domain.model.DiaryCreatePayload import com.afternote.feature.mindrecord.domain.model.DiaryList import com.afternote.feature.mindrecord.domain.model.DiaryUpdatePayload import com.afternote.feature.mindrecord.domain.model.TodayDailyQuestion -import com.afternote.feature.mindrecord.domain.repository.DailyQuestionRepository import com.afternote.feature.mindrecord.domain.repository.DiaryRepository +import com.afternote.feature.mindrecord.domain.testing.FakeDailyQuestionRepository import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.runBlocking @@ -55,7 +53,7 @@ class DailyQuestionWriteViewModelTest { Result.success(todayQuestion(isDraft = true)) } }, - onGetList = { Result.success(listOf(draft(content = "서버에 남아 있던 옛 임시저장본"))) }, + onGetList = { _, _ -> Result.success(listOf(draft(content = "서버에 남아 있던 옛 임시저장본"))) }, ) val viewModel = DailyQuestionWriteViewModel(repository, NoopPhotoUploadRepository, noopDraftLoader()) @@ -73,7 +71,7 @@ class DailyQuestionWriteViewModelTest { val repository = FakeDailyQuestionRepository( onGetToday = { Result.success(todayQuestion(isDraft = true)) }, - onGetList = { Result.success(listOf(draft(content = "이어쓸 본문"))) }, + onGetList = { _, _ -> Result.success(listOf(draft(content = "이어쓸 본문"))) }, ) val viewModel = DailyQuestionWriteViewModel(repository, NoopPhotoUploadRepository, noopDraftLoader()) @@ -95,7 +93,7 @@ class DailyQuestionWriteViewModelTest { Result.success(todayQuestion(isDraft = true)) } }, - onGetList = { + onGetList = { _, _ -> Result.success(listOf(draft(content = "옛 본문", imageUrl = "https://cdn/old.jpg"))) }, ) @@ -118,7 +116,7 @@ class DailyQuestionWriteViewModelTest { val repository = FakeDailyQuestionRepository( onGetToday = { Result.success(todayQuestion(isDraft = true)) }, - onGetList = { + onGetList = { _, _ -> Result.success(listOf(draft(content = "이어쓸 본문", imageUrl = "https://cdn/old.jpg"))) }, ) @@ -133,7 +131,7 @@ class DailyQuestionWriteViewModelTest { val repository = FakeDailyQuestionRepository( onGetToday = { Result.failure(IllegalStateException("네트워크 실패")) }, - onGetList = { Result.success(emptyList()) }, + onGetList = { _, _ -> Result.success(emptyList()) }, ) val viewModel = DailyQuestionWriteViewModel(repository, NoopPhotoUploadRepository, noopDraftLoader()) @@ -141,7 +139,7 @@ class DailyQuestionWriteViewModelTest { viewModel.submit() assertTrue(viewModel.uiState.value.submitState is SubmitState.Failed) - assertEquals(0, repository.createCallCount) + assertEquals(0, repository.createdPayloads.size) } private fun todayQuestion(isDraft: Boolean) = @@ -166,35 +164,6 @@ class DailyQuestionWriteViewModelTest { ) } -/** 미지정 경로 호출은 error 로 드러낸다 (core:data 의 Fake 들과 같은 규칙). */ -private class FakeDailyQuestionRepository( - private val onGetToday: () -> Result, - private val onGetList: () -> Result>, -) : DailyQuestionRepository { - var createCallCount = 0 - private set - - override suspend fun getList( - date: String?, - draftOnly: Boolean?, - ): Result> = onGetList() - - override suspend fun getToday(): Result = onGetToday() - - override suspend fun create(payload: DailyQuestionCreatePayload): Result { - createCallCount += 1 - // 서버가 돌려주는 "내 답변" 식별자 (#573). - return Result.success(CREATED_ANSWER_ID) - } - - override suspend fun update( - id: Long, - payload: DailyQuestionUpdatePayload, - ): Result = error("update 는 이 시나리오에서 호출되면 안 됨") - - override suspend fun delete(id: Long): Result = error("delete 는 이 시나리오에서 호출되면 안 됨") -} - private object NoopPhotoUploadRepository : PhotoUploadRepository { override suspend fun upload( uriString: String, @@ -202,13 +171,14 @@ private object NoopPhotoUploadRepository : PhotoUploadRepository { ): Result = error("upload 는 이 시나리오에서 호출되면 안 됨") } -private const val CREATED_ANSWER_ID = 19L - /** 툴바 카운트는 이 테스트의 관심사가 아니다 — 0건으로 고정한다 (#769). */ private fun noopDraftLoader() = MindRecordDraftLoader( diaryRepository = EmptyDraftDiaryRepository, - dailyQuestionRepository = EmptyDraftDailyQuestionRepository, + dailyQuestionRepository = + FakeDailyQuestionRepository.strict().apply { + onGetList = { _, _ -> Result.success(emptyList()) } + }, ) private object EmptyDraftDiaryRepository : DiaryRepository { @@ -226,21 +196,3 @@ private object EmptyDraftDiaryRepository : DiaryRepository { override suspend fun delete(id: Long): Result = error("호출되면 안 됨") } - -private object EmptyDraftDailyQuestionRepository : DailyQuestionRepository { - override suspend fun getList( - date: String?, - draftOnly: Boolean?, - ): Result> = Result.success(emptyList()) - - override suspend fun getToday(): Result = error("호출되면 안 됨") - - override suspend fun create(payload: DailyQuestionCreatePayload): Result = error("호출되면 안 됨") - - override suspend fun update( - id: Long, - payload: DailyQuestionUpdatePayload, - ): Result = error("호출되면 안 됨") - - override suspend fun delete(id: Long): Result = error("호출되면 안 됨") -} diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DraftListDeleteTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DraftListDeleteTest.kt index 7f66c2bb0e..b3f851f12d 100644 --- a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DraftListDeleteTest.kt +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DraftListDeleteTest.kt @@ -1,8 +1,6 @@ package com.afternote.feature.mindrecord.presentation.viewmodel import com.afternote.feature.mindrecord.domain.model.DailyQuestion -import com.afternote.feature.mindrecord.domain.model.DailyQuestionCreatePayload -import com.afternote.feature.mindrecord.domain.model.DailyQuestionUpdatePayload import com.afternote.feature.mindrecord.domain.model.Diary import com.afternote.feature.mindrecord.domain.model.DiaryCreatePayload import com.afternote.feature.mindrecord.domain.model.DiaryList @@ -10,6 +8,7 @@ import com.afternote.feature.mindrecord.domain.model.DiaryUpdatePayload import com.afternote.feature.mindrecord.domain.model.TodayDailyQuestion import com.afternote.feature.mindrecord.domain.repository.DailyQuestionRepository import com.afternote.feature.mindrecord.domain.repository.DiaryRepository +import com.afternote.feature.mindrecord.domain.testing.FakeDailyQuestionRepository import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.UnconfinedTestDispatcher @@ -43,11 +42,10 @@ class DraftListDeleteTest { fun `삭제에 실패하고 항목이 남아 있으면 완료가 아니라 실패로 알린다`() { val stubborn = dailyQuestion(id = 1L) val repository = - FakeDailyQuestionDraftRepository( - drafts = listOf(stubborn), - // 서버가 거절해 항목이 그대로 남는 상황. - deleteResult = Result.failure(IllegalStateException("서버 거절")), - removesOnDelete = false, + FakeDailyQuestionRepository( + initialAnswers = listOf(stubborn), + // 서버가 거절해 항목이 그대로 남는 상황 — 저장소에서 지우지 않는다. + onDelete = { Result.failure(IllegalStateException("서버 거절")) }, ) val viewModel = viewModel(repository) val target = (viewModel.uiState.value as DraftListUiState.Success).items @@ -64,12 +62,14 @@ class DraftListDeleteTest { @Test fun `삭제가 실패했어도 항목이 사라졌으면 완료로 본다`() { val repository = - FakeDailyQuestionDraftRepository( - drafts = listOf(dailyQuestion(id = 1L)), - // 이미 없는 항목을 지우려다 404 — 사용자가 원한 결과는 이뤄졌다. - deleteResult = Result.failure(IllegalStateException("404")), - removesOnDelete = true, - ) + FakeDailyQuestionRepository(initialAnswers = listOf(dailyQuestion(id = 1L))) + .apply { + // 이미 없는 항목을 지우려다 404 — 실패로 답하지만 항목은 사라진다. + onDelete = { id -> + answers.removeAll { it.dailyQuestionId == id } + Result.failure(IllegalStateException("404")) + } + } val viewModel = viewModel(repository) val target = (viewModel.uiState.value as DraftListUiState.Success).items @@ -83,11 +83,8 @@ class DraftListDeleteTest { @Test fun `모두 성공하면 완료로 알린다`() { val repository = - FakeDailyQuestionDraftRepository( - drafts = listOf(dailyQuestion(id = 1L)), - deleteResult = Result.success(Unit), - removesOnDelete = true, - ) + // 성공 삭제는 픽스처 기본 동작(저장소에서 제거 + success)이 그대로 맞다. + FakeDailyQuestionRepository(initialAnswers = listOf(dailyQuestion(id = 1L))) val viewModel = viewModel(repository) val target = (viewModel.uiState.value as DraftListUiState.Success).items @@ -122,33 +119,6 @@ class DraftListDeleteTest { ) } -private class FakeDailyQuestionDraftRepository( - drafts: List, - private val deleteResult: Result, - private val removesOnDelete: Boolean, -) : DailyQuestionRepository { - private val remaining = drafts.toMutableList() - - override suspend fun getList( - date: String?, - draftOnly: Boolean?, - ): Result> = Result.success(remaining.toList()) - - override suspend fun getToday(): Result = error("호출되면 안 됨") - - override suspend fun create(payload: DailyQuestionCreatePayload): Result = error("호출되면 안 됨") - - override suspend fun update( - id: Long, - payload: DailyQuestionUpdatePayload, - ): Result = error("호출되면 안 됨") - - override suspend fun delete(id: Long): Result { - if (removesOnDelete) remaining.removeAll { it.dailyQuestionId == id } - return deleteResult - } -} - private object EmptyDiaryDraftRepository : DiaryRepository { override suspend fun getList( yearMonth: String, From 1d71d573bdea6a4a5d820c7d3caaea2e365b1234 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Mon, 24 Aug 2026 19:50:25 +0900 Subject: [PATCH 2/2] =?UTF-8?q?test(mindrecord):=20Diary=C2=B7MindRecordRe?= =?UTF-8?q?ceiver=C2=B7WeeklyReport=20fake=2010=EB=B2=8C=EC=9D=84=20testFi?= =?UTF-8?q?xtures=203=EB=B2=8C=EB=A1=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DailyQuestionRepository 에 이어 mindrecord 도메인의 남은 계약 3종을 같은 규약으로 접는다 — 기본은 메모리 저장소, 표현 못 하는 시나리오만 onX 람다, 호출되면 안 되는 경계는 strict() + unexpectedCall. - DiaryRepository 5벌 → 1벌. 달마다 다른 목록을 돌려주던 PrivateDiaryRepository 는 픽스처 기본 저장소가 달을 가르지 않으므로 onGetList 로 직접 답한다(scriptedDiaryRepository). - MindRecordReceiverRepository 3벌 → 1벌. 홈 경합 시나리오의 CompletableDeferred 대기열은 테스트가 들고 onGetAll 로 물린다. - WeeklyReportRepository 2벌 → 1벌. 두 사본이 사실상 동일했다. 기록 접근자 이름이 바뀐 단언: deleteCalls→deletedIds, listCalls→listQueries, getListCalls→listQueries.size, PrivateDiaryQuery→FakeDiaryRepository.ListQuery. 검증: :koverXmlReportCi :konsist:test :build-logic:test :app:compileDebugAndroidTestKotlin 전 모듈 ktlintCheck 통과. --- .../AppAndReceiverCompletionAndroidTest.kt | 26 +-- .../afternote_fe/MindRecordFlowAndroidTest.kt | 13 +- .../MindRecordLifecycleAndroidTest.kt | 161 +++++++----------- ...meLetterMindRecordCompletionAndroidTest.kt | 95 ++--------- .../afternote/afternote_fe/test/TestFakes.kt | 32 ---- .../afternote_fe/test/TestHiltModules.kt | 1 + .../home/presentation/HomeTabViewModelTest.kt | 41 +---- .../domain/testing/FakeDiaryRepository.kt | 97 +++++++++++ .../FakeMindRecordReceiverRepository.kt | 28 +++ .../testing/FakeWeeklyReportRepository.kt | 21 +++ .../DailyQuestionWriteViewModelTest.kt | 27 +-- .../viewmodel/DraftListDeleteTest.kt | 30 ++-- .../receiver/presentation/build.gradle.kts | 1 + .../home/ReceiverHomeViewModelTest.kt | 8 +- 14 files changed, 273 insertions(+), 308 deletions(-) create mode 100644 feature/mindrecord/domain/src/testFixtures/kotlin/com/afternote/feature/mindrecord/domain/testing/FakeDiaryRepository.kt create mode 100644 feature/mindrecord/domain/src/testFixtures/kotlin/com/afternote/feature/mindrecord/domain/testing/FakeMindRecordReceiverRepository.kt create mode 100644 feature/mindrecord/domain/src/testFixtures/kotlin/com/afternote/feature/mindrecord/domain/testing/FakeWeeklyReportRepository.kt diff --git a/app/src/androidTest/java/com/afternote/afternote_fe/AppAndReceiverCompletionAndroidTest.kt b/app/src/androidTest/java/com/afternote/afternote_fe/AppAndReceiverCompletionAndroidTest.kt index 3435509b55..c89769079b 100644 --- a/app/src/androidTest/java/com/afternote/afternote_fe/AppAndReceiverCompletionAndroidTest.kt +++ b/app/src/androidTest/java/com/afternote/afternote_fe/AppAndReceiverCompletionAndroidTest.kt @@ -48,7 +48,7 @@ import com.afternote.feature.afternote.domain.AfternoteType import com.afternote.feature.afternote.domain.model.LeaveMessageBlock import com.afternote.feature.home.presentation.HomeTabActions import com.afternote.feature.mindrecord.domain.model.ReceiverMindRecords -import com.afternote.feature.mindrecord.domain.repository.MindRecordReceiverRepository +import com.afternote.feature.mindrecord.domain.testing.FakeMindRecordReceiverRepository import com.afternote.feature.receiver.domain.error.ReceiverEmailAuthException import com.afternote.feature.receiver.domain.model.AfterNoteListItem import com.afternote.feature.receiver.domain.model.AfterNotesListResult @@ -234,7 +234,10 @@ class ReceiverRuntimeCompletionAndroidTest { @Test fun receiverHome_allFailureThenRetryPartialSuccess_keepsAvailableSectionsAndReportsBothStages() { val repository = CompletionReceiverRepository() - val mindRecordRepository = CompletionMindRecordReceiverRepository() + // 완료 시점을 테스트가 쥐어야 세 조회의 경합 순서를 만들 수 있다. + val mindRecordHomeResults = ArrayDeque>>() + val mindRecordRepository = + FakeMindRecordReceiverRepository(onGetAll = { mindRecordHomeResults.removeFirst().await() }) val timeLetterRepository = CompletionReceiverTimeLetterRepository() fun homeCallCounts(): List = @@ -245,8 +248,8 @@ class ReceiverRuntimeCompletionAndroidTest { repository.senderMessageCalls, ) - val allFailureAttempt = enqueueHomeAttempt(repository, mindRecordRepository, timeLetterRepository) - val partialAttempt = enqueueHomeAttempt(repository, mindRecordRepository, timeLetterRepository) + val allFailureAttempt = enqueueHomeAttempt(repository, mindRecordHomeResults, timeLetterRepository) + val partialAttempt = enqueueHomeAttempt(repository, mindRecordHomeResults, timeLetterRepository) val reporter = FakeErrorReporter() val viewModel = ReceiverHomeViewModel( @@ -744,7 +747,7 @@ private data class PendingHomeAttempt( /** 홈 한 번의 로드가 물리는 세 리포지토리 대기열에 결과 게이트를 한 벌씩 건다. */ private fun enqueueHomeAttempt( receiverRepository: CompletionReceiverRepository, - mindRecordRepository: CompletionMindRecordReceiverRepository, + mindRecordHomeResults: ArrayDeque>>, timeLetterRepository: CompletionReceiverTimeLetterRepository, ): PendingHomeAttempt { val attempt = @@ -755,7 +758,7 @@ private fun enqueueHomeAttempt( senderMessage = CompletableDeferred(), ) receiverRepository.afterNoteHomeResults.addLast(attempt.afterNotes) - mindRecordRepository.homeResults.addLast(attempt.mindRecords) + mindRecordHomeResults.addLast(attempt.mindRecords) timeLetterRepository.homeResults.addLast(attempt.timeLetters) receiverRepository.senderMessageHomeResults.addLast(attempt.senderMessage) return attempt @@ -807,17 +810,6 @@ private class CompletionReceiverRepository : ReceiverRepository { } } -private class CompletionMindRecordReceiverRepository : MindRecordReceiverRepository { - val homeResults = ArrayDeque>>() - var getAllCalls = 0 - private set - - override suspend fun getAll(): Result { - getAllCalls += 1 - return homeResults.removeFirst().await() - } -} - private class CompletionReceiverTimeLetterRepository : ReceiverTimeLetterRepository { val homeResults = ArrayDeque>>() var listCalls = 0 diff --git a/app/src/androidTest/java/com/afternote/afternote_fe/MindRecordFlowAndroidTest.kt b/app/src/androidTest/java/com/afternote/afternote_fe/MindRecordFlowAndroidTest.kt index dfb16939f2..f3ca3d4045 100644 --- a/app/src/androidTest/java/com/afternote/afternote_fe/MindRecordFlowAndroidTest.kt +++ b/app/src/androidTest/java/com/afternote/afternote_fe/MindRecordFlowAndroidTest.kt @@ -14,12 +14,12 @@ import androidx.compose.ui.test.waitUntilAtLeastOneExists import androidx.lifecycle.SavedStateHandle import androidx.test.ext.junit.runners.AndroidJUnit4 import com.afternote.afternote_fe.test.FailureArtifactRule -import com.afternote.afternote_fe.test.FakeDiaryRepository import com.afternote.afternote_fe.test.FakeUserRepository import com.afternote.core.domain.repository.PhotoUploadRepository import com.afternote.core.ui.theme.AfternoteTheme import com.afternote.feature.mindrecord.domain.model.TodayMood import com.afternote.feature.mindrecord.domain.testing.FakeDailyQuestionRepository +import com.afternote.feature.mindrecord.domain.testing.FakeDiaryRepository import com.afternote.feature.mindrecord.presentation.screen.sender.DiaryWriteScreen import com.afternote.feature.mindrecord.presentation.viewmodel.DailyQuestionWriteViewModel import com.afternote.feature.mindrecord.presentation.viewmodel.DiaryWriteViewModel @@ -46,8 +46,15 @@ class MindRecordFlowAndroidTest { @Test fun diaryFailureThenRetry_preservesInputAndSendsExactPayload() { val repository = FakeDiaryRepository() - repository.createResults.addLast(Result.failure(IllegalStateException("temporary"))) - repository.createResults.addLast(Result.success(Unit)) + // 첫 저장은 실패, 재시도는 성공 — 입력이 남는지와 정확한 payload 를 본다. + val createResults = + ArrayDeque( + listOf( + Result.failure(IllegalStateException("temporary")), + Result.success(Unit), + ), + ) + repository.onCreate = { createResults.removeFirst() } val viewModel = diaryViewModel(repository) var successCalls = 0 diff --git a/app/src/androidTest/java/com/afternote/afternote_fe/MindRecordLifecycleAndroidTest.kt b/app/src/androidTest/java/com/afternote/afternote_fe/MindRecordLifecycleAndroidTest.kt index 4c0b28c3f7..b8d131a376 100644 --- a/app/src/androidTest/java/com/afternote/afternote_fe/MindRecordLifecycleAndroidTest.kt +++ b/app/src/androidTest/java/com/afternote/afternote_fe/MindRecordLifecycleAndroidTest.kt @@ -29,10 +29,10 @@ import com.afternote.feature.mindrecord.domain.model.TodayMood import com.afternote.feature.mindrecord.domain.model.WeeklyReport import com.afternote.feature.mindrecord.domain.model.WeeklyReportDailyQuestion import com.afternote.feature.mindrecord.domain.model.WeeklyReportDay -import com.afternote.feature.mindrecord.domain.repository.DiaryRepository -import com.afternote.feature.mindrecord.domain.repository.MindRecordReceiverRepository -import com.afternote.feature.mindrecord.domain.repository.WeeklyReportRepository import com.afternote.feature.mindrecord.domain.testing.FakeDailyQuestionRepository +import com.afternote.feature.mindrecord.domain.testing.FakeDiaryRepository +import com.afternote.feature.mindrecord.domain.testing.FakeMindRecordReceiverRepository +import com.afternote.feature.mindrecord.domain.testing.FakeWeeklyReportRepository import com.afternote.feature.mindrecord.domain.testing.unexpectedCall import com.afternote.feature.mindrecord.presentation.model.DayBackground import com.afternote.feature.mindrecord.presentation.model.DayContent @@ -80,13 +80,14 @@ class MindRecordLifecycleAndroidTest { val currentPublished = diary(101L, "이번 달 공개 일기", currentMonth.atDay(3), isDraft = false) val currentDraft = diary(102L, "숨겨야 할 임시 일기", currentMonth.atDay(4), isDraft = true) val previousPublished = diary(103L, "지난달 삭제할 일기", previousMonth.atDay(5), isDraft = false) - val repository = - PrivateDiaryRepository().apply { - lists[PrivateDiaryQuery(currentMonth.toString(), null)] = - DiaryList(listOf(currentPublished, currentDraft), 2, TodayMood.HAPPY) - lists[PrivateDiaryQuery(previousMonth.toString(), null)] = - DiaryList(listOf(previousPublished), 1, TodayMood.SOSO) - } + val diaryLists = + mutableMapOf( + FakeDiaryRepository.ListQuery(currentMonth.toString(), null) to + DiaryList(listOf(currentPublished, currentDraft), 2, TodayMood.HAPPY), + FakeDiaryRepository.ListQuery(previousMonth.toString(), null) to + DiaryList(listOf(previousPublished), 1, TodayMood.SOSO), + ) + val repository = scriptedDiaryRepository(diaryLists) val viewModel = DiaryListViewModel(repository) composeRule.setContent { @@ -111,14 +112,14 @@ class MindRecordLifecycleAndroidTest { composeRule.onNodeWithContentDescription("더보기 메뉴").performClick() composeRule.onNodeWithText("삭제하기").performClick() composeRule.onNodeWithText("아직 등록된 일기가 없어요.").assertIsDisplayed() - assertEquals(listOf(103L), repository.deleteCalls) + assertEquals(listOf(103L), repository.deletedIds) assertEquals( listOf( - PrivateDiaryQuery(currentMonth.toString(), null), - PrivateDiaryQuery(previousMonth.toString(), null), - PrivateDiaryQuery(previousMonth.toString(), null), + FakeDiaryRepository.ListQuery(currentMonth.toString(), null), + FakeDiaryRepository.ListQuery(previousMonth.toString(), null), + FakeDiaryRepository.ListQuery(previousMonth.toString(), null), ), - repository.listCalls, + repository.listQueries, ) val state = viewModel.uiState.value as DiaryListUiState.Success assertEquals(previousMonth, state.yearMonth) @@ -128,15 +129,16 @@ class MindRecordLifecycleAndroidTest { @Test fun combinedDraftList_routesSelectedAndAllDeletesToExactRepositories() { val currentMonth = YearMonth.now() - val diaryRepository = - PrivateDiaryRepository().apply { - lists[PrivateDiaryQuery(currentMonth.toString(), true)] = + val diaryLists = + mutableMapOf( + FakeDiaryRepository.ListQuery(currentMonth.toString(), true) to DiaryList( diaries = listOf(diary(201L, "일기 임시저장", currentMonth.atDay(8), isDraft = true)), monthDiaryCount = 1, weeklyDominantMood = null, - ) - } + ), + ) + val diaryRepository = scriptedDiaryRepository(diaryLists) val dailyQuestionRepository = FakeDailyQuestionRepository( // 이 시나리오는 임시저장 목록의 조회·삭제만 태운다. @@ -181,7 +183,7 @@ class MindRecordLifecycleAndroidTest { composeRule.onNodeWithText("질문 임시저장").assertDoesNotExist() composeRule.onNodeWithText("일기 임시저장").assertIsDisplayed() assertEquals(listOf(202L), dailyQuestionRepository.deletedIds) - assertTrue(diaryRepository.deleteCalls.isEmpty()) + assertTrue(diaryRepository.deletedIds.isEmpty()) composeRule.waitUntil(timeoutMillis = TIMEOUT) { (viewModel.uiState.value as? DraftListUiState.Success)?.deleteOutcome == null @@ -189,16 +191,16 @@ class MindRecordLifecycleAndroidTest { composeRule.onNodeWithText("선택").performClick() composeRule.onNodeWithText("전체 삭제").performClick() composeRule.onNodeWithText("임시 저장된 항목이 없습니다.").assertIsDisplayed() - assertEquals(listOf(201L), diaryRepository.deleteCalls) + assertEquals(listOf(201L), diaryRepository.deletedIds) assertEquals(listOf(202L), dailyQuestionRepository.deletedIds) - assertEquals(3, diaryRepository.listCalls.size) + assertEquals(3, diaryRepository.listQueries.size) assertEquals(3, dailyQuestionRepository.listQueries.size) assertTrue((viewModel.uiState.value as DraftListUiState.Success).items.isEmpty()) } @Test fun weeklyReport_errorRetry_mapsSparsePartialWeekWithoutIndexDrift() { - val repository = PrivateWeeklyReportRepository() + val repository = FakeWeeklyReportRepository() repository.results.addLast(Result.failure(IllegalStateException("weekly offline"))) val userRepository = privateProfileRepository("테스트 사용자") val viewModel = WeeklyReportViewModel(repository, userRepository) @@ -263,23 +265,25 @@ class MindRecordLifecycleAndroidTest { @Test fun receiverQuestionAndDiary_filterThenReset_reusesSingleRepositorySnapshot() { val repository = - PrivateMindRecordReceiverRepository( - records = - ReceiverMindRecords( - dailyQuestions = - listOf( - record(401L, MindRecordType.DAILY_QUESTION, "범위 밖 질문", "2026-07-01"), - record(402L, MindRecordType.DAILY_QUESTION, "질문 범위 시작", "2026-08-12"), - record(403L, MindRecordType.DAILY_QUESTION, "질문 범위 최신", "2026-08-20"), - record(404L, MindRecordType.DAILY_QUESTION, "숨길 질문 draft", "2026-08-21", true), - ), - diaries = - listOf( - record(411L, MindRecordType.DIARY, "범위 밖 일기", "2026-07-02"), - record(412L, MindRecordType.DIARY, "일기 범위 시작", "2026-08-13"), - record(413L, MindRecordType.DIARY, "일기 범위 최신", "2026-08-19"), - record(414L, MindRecordType.DIARY, "숨길 일기 draft", "2026-08-22", true), - ), + FakeMindRecordReceiverRepository( + result = + Result.success( + ReceiverMindRecords( + dailyQuestions = + listOf( + record(401L, MindRecordType.DAILY_QUESTION, "범위 밖 질문", "2026-07-01"), + record(402L, MindRecordType.DAILY_QUESTION, "질문 범위 시작", "2026-08-12"), + record(403L, MindRecordType.DAILY_QUESTION, "질문 범위 최신", "2026-08-20"), + record(404L, MindRecordType.DAILY_QUESTION, "숨길 질문 draft", "2026-08-21", true), + ), + diaries = + listOf( + record(411L, MindRecordType.DIARY, "범위 밖 일기", "2026-07-02"), + record(412L, MindRecordType.DIARY, "일기 범위 시작", "2026-08-13"), + record(413L, MindRecordType.DIARY, "일기 범위 최신", "2026-08-19"), + record(414L, MindRecordType.DIARY, "숨길 일기 draft", "2026-08-22", true), + ), + ), ), ) val viewModel = ReceiverMindRecordViewModel(repository) @@ -333,63 +337,24 @@ class MindRecordLifecycleAndroidTest { } } -private data class PrivateDiaryQuery( - val yearMonth: String, - val draftOnly: Boolean?, -) - -private class PrivateDiaryRepository : DiaryRepository { - val lists = mutableMapOf() - val listCalls = mutableListOf() - val deleteCalls = mutableListOf() - - override suspend fun getList( - yearMonth: String, - draftOnly: Boolean?, - ): Result { - val query = PrivateDiaryQuery(yearMonth, draftOnly) - listCalls += query - return Result.success(lists[query] ?: DiaryList(emptyList(), 0, null)) - } - - override suspend fun create(payload: DiaryCreatePayload): Result = error("Unexpected diary create") - - override suspend fun update( - id: Long, - payload: DiaryUpdatePayload, - ): Result = error("Unexpected diary update") - - override suspend fun delete(id: Long): Result { - deleteCalls += id - lists.replaceAll { _, list -> - val remaining = list.diaries.filterNot { it.diaryId == id } - list.copy(diaries = remaining, monthDiaryCount = remaining.size) - } - return Result.success(Unit) - } -} - -private class PrivateWeeklyReportRepository : WeeklyReportRepository { - val results = ArrayDeque>() - val requestedDates = mutableListOf() - - override suspend fun getWeeklyReport(date: String): Result { - requestedDates += date - return requireNotNull(results.removeFirstOrNull()) - } -} - -private class PrivateMindRecordReceiverRepository( - private val records: ReceiverMindRecords, -) : MindRecordReceiverRepository { - var getAllCalls = 0 - private set - - override suspend fun getAll(): Result { - getAllCalls += 1 - return Result.success(records) - } -} +/** 달·임시저장 여부마다 다른 목록을 돌려주는 조회. 삭제는 그 목록들에서 함께 지운다. */ +private fun scriptedDiaryRepository(lists: MutableMap) = + FakeDiaryRepository( + onGetList = { yearMonth, draftOnly -> + Result.success( + lists[FakeDiaryRepository.ListQuery(yearMonth, draftOnly)] ?: DiaryList(emptyList(), 0, null), + ) + }, + onCreate = { unexpectedCall("DiaryRepository.create") }, + onUpdate = { _, _ -> unexpectedCall("DiaryRepository.update") }, + onDelete = { id -> + lists.replaceAll { _, list -> + val remaining = list.diaries.filterNot { it.diaryId == id } + list.copy(diaries = remaining, monthDiaryCount = remaining.size) + } + Result.success(Unit) + }, + ) @Suppress("UNCHECKED_CAST") private fun privateProfileRepository(name: String): UserRepository = diff --git a/app/src/androidTest/java/com/afternote/afternote_fe/TimeLetterMindRecordCompletionAndroidTest.kt b/app/src/androidTest/java/com/afternote/afternote_fe/TimeLetterMindRecordCompletionAndroidTest.kt index 190b155582..9b3820c2e7 100644 --- a/app/src/androidTest/java/com/afternote/afternote_fe/TimeLetterMindRecordCompletionAndroidTest.kt +++ b/app/src/androidTest/java/com/afternote/afternote_fe/TimeLetterMindRecordCompletionAndroidTest.kt @@ -47,9 +47,9 @@ import com.afternote.feature.mindrecord.domain.model.WeeklyReport import com.afternote.feature.mindrecord.domain.model.WeeklyReportDailyQuestion import com.afternote.feature.mindrecord.domain.model.WeeklyReportDay import com.afternote.feature.mindrecord.domain.model.WeeklyReportEmotion -import com.afternote.feature.mindrecord.domain.repository.DiaryRepository -import com.afternote.feature.mindrecord.domain.repository.WeeklyReportRepository import com.afternote.feature.mindrecord.domain.testing.FakeDailyQuestionRepository +import com.afternote.feature.mindrecord.domain.testing.FakeDiaryRepository +import com.afternote.feature.mindrecord.domain.testing.FakeWeeklyReportRepository import com.afternote.feature.mindrecord.presentation.screen.memoryspace.MemorySpaceScreen import com.afternote.feature.mindrecord.presentation.screen.sender.DailyQuestionAnswerListScreen import com.afternote.feature.mindrecord.presentation.screen.sender.DailyQuestionWriteScreen @@ -429,7 +429,7 @@ class TimeLetterMindRecordCompletionAndroidTest { DailyQuestionWriteViewModel( repository = repository, photoUploadRepository = CompletionPhotoUploadRepository, - draftLoader = MindRecordDraftLoader(CompletionDiaryRepository(mutableListOf()), repository), + draftLoader = MindRecordDraftLoader(FakeDiaryRepository(), repository), ) } composeRule.waitUntil(timeoutMillis = TIMEOUT) { @@ -459,9 +459,9 @@ class TimeLetterMindRecordCompletionAndroidTest { val currentMonth = YearMonth.now() val draftDate = currentMonth.atDay(12) val repository = - CompletionDiaryRepository( - drafts = - mutableListOf( + FakeDiaryRepository( + initialDiaries = + listOf( Diary( diaryId = 612L, title = "이어 쓸 일기", @@ -539,7 +539,7 @@ class TimeLetterMindRecordCompletionAndroidTest { composeRule.onNode(hasText("등록") and hasClickAction()).performClick() composeRule.waitUntil(timeoutMillis = TIMEOUT) { submitSuccessCalls == 1 } - val update = repository.updateCalls.single() + val update = repository.updatedPayloads.single() assertEquals(612L, update.first) assertEquals("완성한 일기", update.second.title) assertEquals("

완성한 본문

", update.second.content) @@ -547,18 +547,20 @@ class TimeLetterMindRecordCompletionAndroidTest { assertEquals(TodayMood.SOSO, update.second.todayMood) assertEquals(draftDate.toString(), update.second.date) assertEquals("https://afternote.test/draft.jpg", update.second.imageUrl) - assertTrue(repository.createCalls.isEmpty()) - assertEquals(2, repository.listCalls.size) - assertTrue(repository.listCalls.all { it == currentMonth.toString() to true }) + assertTrue(repository.createdPayloads.isEmpty()) + assertEquals(2, repository.listQueries.size) + assertTrue( + repository.listQueries.all { it == FakeDiaryRepository.ListQuery(currentMonth.toString(), true) }, + ) } @Test fun memorySpace_supportedSuccess_opensAndClosesDetailThenNavigatesBack() { val memoryDate = LocalDate.now() val diaryRepository = - CompletionDiaryRepository( - drafts = - mutableListOf( + FakeDiaryRepository( + initialDiaries = + listOf( Diary( diaryId = 501L, title = "추억이 된 하루", @@ -605,7 +607,7 @@ class TimeLetterMindRecordCompletionAndroidTest { @Test fun weeklyReport_errorRetryThenEmptyAndComplete_preservesRequestedWeekContract() { val repository = - CompletionWeeklyReportRepository().apply { + FakeWeeklyReportRepository().apply { results.addLast(Result.failure(IllegalStateException("weekly offline"))) } val userRepository = @@ -779,71 +781,6 @@ private object CompletionFileMetadataRepository : FileMetadataRepository { /** 이 파일의 데일리질문 시나리오가 공유하는 "오늘의 질문". `questionId` 를 단언하는 곳이 있다. */ private fun completionToday() = TodayDailyQuestion(71L, 71, "오늘의 테스트 질문", false) -private class CompletionDiaryRepository( - val drafts: MutableList, -) : DiaryRepository { - val listCalls = mutableListOf>() - val createCalls = mutableListOf() - val updateCalls = mutableListOf>() - - override suspend fun getList( - yearMonth: String, - draftOnly: Boolean?, - ): Result { - listCalls += yearMonth to draftOnly - val matching = drafts.filter { draftOnly == null || it.isDraft == draftOnly } - return Result.success( - DiaryList( - diaries = matching, - monthDiaryCount = matching.size, - weeklyDominantMood = matching.firstOrNull()?.todayMood, - ), - ) - } - - override suspend fun create(payload: DiaryCreatePayload): Result { - createCalls += payload - return Result.success(Unit) - } - - override suspend fun update( - id: Long, - payload: DiaryUpdatePayload, - ): Result { - updateCalls += id to payload - drafts.replaceAll { diary -> - if (diary.diaryId == id) { - diary.copy( - title = payload.title, - content = payload.content, - date = payload.date, - todayMood = payload.todayMood, - imageUrl = payload.imageUrl, - isDraft = payload.isDraft, - ) - } else { - diary - } - } - return Result.success(Unit) - } - - override suspend fun delete(id: Long): Result { - drafts.removeAll { it.diaryId == id } - return Result.success(Unit) - } -} - -private class CompletionWeeklyReportRepository : WeeklyReportRepository { - val results = ArrayDeque>() - val requestedDates = mutableListOf() - - override suspend fun getWeeklyReport(date: String): Result { - requestedDates += date - return requireNotNull(results.removeFirstOrNull()) { "Missing weekly response for $date" } - } -} - private fun dailyQuestion( id: Long, title: String, diff --git a/app/src/androidTest/java/com/afternote/afternote_fe/test/TestFakes.kt b/app/src/androidTest/java/com/afternote/afternote_fe/test/TestFakes.kt index f4d2c47381..24cac3064e 100644 --- a/app/src/androidTest/java/com/afternote/afternote_fe/test/TestFakes.kt +++ b/app/src/androidTest/java/com/afternote/afternote_fe/test/TestFakes.kt @@ -15,10 +15,6 @@ import com.afternote.core.model.user.User import com.afternote.core.model.user.UserConnectedAccount import com.afternote.core.model.user.UserPushSetting import com.afternote.feature.mindrecord.domain.model.DailyQuestion -import com.afternote.feature.mindrecord.domain.model.DiaryCreatePayload -import com.afternote.feature.mindrecord.domain.model.DiaryList -import com.afternote.feature.mindrecord.domain.model.DiaryUpdatePayload -import com.afternote.feature.mindrecord.domain.repository.DiaryRepository import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow @@ -225,34 +221,6 @@ class FakeUserProfileRepository : UserProfileRepository { } } -class FakeDiaryRepository( - var listResult: Result = Result.success(DiaryList(emptyList(), 0, null)), -) : DiaryRepository { - val createdPayloads = mutableListOf() - val updatedPayloads = mutableListOf>() - val createResults = ArrayDeque>() - - override suspend fun getList( - yearMonth: String, - draftOnly: Boolean?, - ): Result = listResult - - override suspend fun create(payload: DiaryCreatePayload): Result { - createdPayloads += payload - return createResults.removeFirstOrNull() ?: Result.success(Unit) - } - - override suspend fun update( - id: Long, - payload: DiaryUpdatePayload, - ): Result { - updatedPayloads += id to payload - return Result.success(Unit) - } - - override suspend fun delete(id: Long): Result = Result.success(Unit) -} - class FakeErrorReporter : ErrorReporter { val failures = mutableListOf>>() diff --git a/app/src/androidTest/java/com/afternote/afternote_fe/test/TestHiltModules.kt b/app/src/androidTest/java/com/afternote/afternote_fe/test/TestHiltModules.kt index e481d24f91..3f8c89c280 100644 --- a/app/src/androidTest/java/com/afternote/afternote_fe/test/TestHiltModules.kt +++ b/app/src/androidTest/java/com/afternote/afternote_fe/test/TestHiltModules.kt @@ -14,6 +14,7 @@ import com.afternote.feature.mindrecord.domain.repository.DiaryRepository import com.afternote.feature.mindrecord.domain.repository.MindRecordReceiverRepository import com.afternote.feature.mindrecord.domain.repository.WeeklyReportRepository import com.afternote.feature.mindrecord.domain.testing.FakeDailyQuestionRepository +import com.afternote.feature.mindrecord.domain.testing.FakeDiaryRepository import dagger.Module import dagger.Provides import dagger.hilt.components.SingletonComponent diff --git a/feature/home/presentation/src/test/kotlin/com/afternote/feature/home/presentation/HomeTabViewModelTest.kt b/feature/home/presentation/src/test/kotlin/com/afternote/feature/home/presentation/HomeTabViewModelTest.kt index 0e19cdb7da..04e08d3a75 100644 --- a/feature/home/presentation/src/test/kotlin/com/afternote/feature/home/presentation/HomeTabViewModelTest.kt +++ b/feature/home/presentation/src/test/kotlin/com/afternote/feature/home/presentation/HomeTabViewModelTest.kt @@ -17,8 +17,8 @@ import com.afternote.feature.mindrecord.domain.model.DiaryCreatePayload import com.afternote.feature.mindrecord.domain.model.DiaryList import com.afternote.feature.mindrecord.domain.model.DiaryUpdatePayload import com.afternote.feature.mindrecord.domain.model.TodayDailyQuestion -import com.afternote.feature.mindrecord.domain.repository.DiaryRepository import com.afternote.feature.mindrecord.domain.testing.FakeDailyQuestionRepository +import com.afternote.feature.mindrecord.domain.testing.FakeDiaryRepository import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -84,7 +84,7 @@ class HomeTabViewModelTest { assertEquals(listOf("효기"), fixture.profile.savedUserNames) assertEquals(1, fixture.server.userRepository.profileCalls) assertEquals(1, fixture.server.userRepository.receiverCalls) - assertEquals(1, fixture.server.diaryRepository.getListCalls) + assertEquals(1, fixture.server.diaryRepository.listQueries.size) assertEquals(1, fixture.server.dailyQuestionRepository.getTodayCalls) } @@ -267,7 +267,12 @@ private class Fixture { private class FakeHomeRepositories { val userRepository = FakeUserRepository() - val diaryRepository = FakeDiaryRepository() + + private val diaryResults = ArrayDeque>>() + val diaryRepository = + FakeDiaryRepository.strict().apply { + onGetList = { _, _ -> diaryResults.takeNext("DiaryRepository.getList").await() } + } /** 완료 시점을 테스트가 쥐고 있어야 병렬 조회의 경합 순서를 만들 수 있다. */ private val questionResults = ArrayDeque>>() @@ -280,7 +285,7 @@ private class FakeHomeRepositories { fun enqueueRequest(): PendingHomeRequest = PendingHomeRequest().also { request -> userRepository.enqueue(request.profile, request.receivers) - diaryRepository.enqueue(request.diary) + diaryResults.addLast(request.diary) questionResults.addLast(request.question) } } @@ -417,34 +422,6 @@ private class FakeUserRepository : UserRepository { ): ReceiverDeliveryConditions = unexpected("updateReceiverDeliveryConditions") } -private class FakeDiaryRepository : DiaryRepository { - private val results = ArrayDeque>>() - - var getListCalls: Int = 0 - private set - - fun enqueue(result: CompletableDeferred>) { - results.addLast(result) - } - - override suspend fun getList( - yearMonth: String, - draftOnly: Boolean?, - ): Result { - getListCalls++ - return results.takeNext("DiaryRepository.getList").await() - } - - override suspend fun create(payload: DiaryCreatePayload): Result = unexpected("DiaryRepository.create") - - override suspend fun update( - id: Long, - payload: DiaryUpdatePayload, - ): Result = unexpected("DiaryRepository.update") - - override suspend fun delete(id: Long): Result = unexpected("DiaryRepository.delete") -} - private class FakeUserProfileRepository : UserProfileRepository { var cachedUserName: String? = null val savedUserNames = mutableListOf() diff --git a/feature/mindrecord/domain/src/testFixtures/kotlin/com/afternote/feature/mindrecord/domain/testing/FakeDiaryRepository.kt b/feature/mindrecord/domain/src/testFixtures/kotlin/com/afternote/feature/mindrecord/domain/testing/FakeDiaryRepository.kt new file mode 100644 index 0000000000..7720e046c9 --- /dev/null +++ b/feature/mindrecord/domain/src/testFixtures/kotlin/com/afternote/feature/mindrecord/domain/testing/FakeDiaryRepository.kt @@ -0,0 +1,97 @@ +package com.afternote.feature.mindrecord.domain.testing + +import com.afternote.feature.mindrecord.domain.model.Diary +import com.afternote.feature.mindrecord.domain.model.DiaryCreatePayload +import com.afternote.feature.mindrecord.domain.model.DiaryList +import com.afternote.feature.mindrecord.domain.model.DiaryUpdatePayload +import com.afternote.feature.mindrecord.domain.repository.DiaryRepository + +/** + * [DiaryRepository] fake 정본. 규약은 [FakeDailyQuestionRepository] 와 같다 — + * 기본은 메모리 저장소, 표현 못 하는 시나리오만 `onX` 람다로 갈아끼운다 (#1030). + * + * 조회는 `yearMonth` 로 거르지 **않는다**. 테스트가 넣는 일기는 대개 조회 대상 달의 + * 것이고, 달을 넘나드는 시나리오는 `onGetList` 로 직접 답하는 편이 읽기 쉽다. + */ +class FakeDiaryRepository( + initialDiaries: List = emptyList(), + var onGetList: (suspend (String, Boolean?) -> Result)? = null, + var onCreate: (suspend (DiaryCreatePayload) -> Result)? = null, + var onUpdate: (suspend (Long, DiaryUpdatePayload) -> Result)? = null, + var onDelete: (suspend (Long) -> Result)? = null, +) : DiaryRepository { + val diaries: MutableList = initialDiaries.toMutableList() + + val listQueries = mutableListOf() + val createdPayloads = mutableListOf() + val updatedPayloads = mutableListOf>() + val deletedIds = mutableListOf() + + data class ListQuery( + val yearMonth: String, + val draftOnly: Boolean?, + ) + + override suspend fun getList( + yearMonth: String, + draftOnly: Boolean?, + ): Result { + listQueries += ListQuery(yearMonth, draftOnly) + onGetList?.let { return it(yearMonth, draftOnly) } + val matching = diaries.filter { draftOnly == null || it.isDraft == draftOnly } + return Result.success( + DiaryList( + diaries = matching, + monthDiaryCount = matching.size, + weeklyDominantMood = matching.firstOrNull()?.todayMood, + ), + ) + } + + override suspend fun create(payload: DiaryCreatePayload): Result { + createdPayloads += payload + onCreate?.let { return it(payload) } + return Result.success(Unit) + } + + override suspend fun update( + id: Long, + payload: DiaryUpdatePayload, + ): Result { + updatedPayloads += id to payload + onUpdate?.let { return it(id, payload) } + diaries.replaceAll { diary -> + if (diary.diaryId == id) { + diary.copy( + title = payload.title, + content = payload.content, + date = payload.date, + todayMood = payload.todayMood, + imageUrl = payload.imageUrl, + isDraft = payload.isDraft, + ) + } else { + diary + } + } + return Result.success(Unit) + } + + override suspend fun delete(id: Long): Result { + deletedIds += id + onDelete?.let { return it(id) } + diaries.removeAll { it.diaryId == id } + return Result.success(Unit) + } + + companion object { + /** 모든 호출을 실패시키고, 시나리오가 실제로 쓰는 것만 `onX` 로 연다. */ + fun strict(): FakeDiaryRepository = + FakeDiaryRepository( + onGetList = { _, _ -> unexpectedCall("DiaryRepository.getList") }, + onCreate = { unexpectedCall("DiaryRepository.create") }, + onUpdate = { _, _ -> unexpectedCall("DiaryRepository.update") }, + onDelete = { unexpectedCall("DiaryRepository.delete") }, + ) + } +} diff --git a/feature/mindrecord/domain/src/testFixtures/kotlin/com/afternote/feature/mindrecord/domain/testing/FakeMindRecordReceiverRepository.kt b/feature/mindrecord/domain/src/testFixtures/kotlin/com/afternote/feature/mindrecord/domain/testing/FakeMindRecordReceiverRepository.kt new file mode 100644 index 0000000000..ed38f57dd5 --- /dev/null +++ b/feature/mindrecord/domain/src/testFixtures/kotlin/com/afternote/feature/mindrecord/domain/testing/FakeMindRecordReceiverRepository.kt @@ -0,0 +1,28 @@ +package com.afternote.feature.mindrecord.domain.testing + +import com.afternote.feature.mindrecord.domain.model.ReceiverMindRecords +import com.afternote.feature.mindrecord.domain.repository.MindRecordReceiverRepository + +/** + * [MindRecordReceiverRepository] fake 정본 (#1030). + * + * 기본은 [result] 를 그대로 돌려준다. 완료 시점을 테스트가 쥐어야 하는 경합 시나리오는 + * `onGetAll` 로 갈아끼운다. + */ +class FakeMindRecordReceiverRepository( + var result: Result = Result.success(EMPTY_RECORDS), + var onGetAll: (suspend () -> Result)? = null, +) : MindRecordReceiverRepository { + var getAllCalls: Int = 0 + private set + + override suspend fun getAll(): Result { + getAllCalls += 1 + onGetAll?.let { return it() } + return result + } + + companion object { + private val EMPTY_RECORDS = ReceiverMindRecords(dailyQuestions = emptyList(), diaries = emptyList()) + } +} diff --git a/feature/mindrecord/domain/src/testFixtures/kotlin/com/afternote/feature/mindrecord/domain/testing/FakeWeeklyReportRepository.kt b/feature/mindrecord/domain/src/testFixtures/kotlin/com/afternote/feature/mindrecord/domain/testing/FakeWeeklyReportRepository.kt new file mode 100644 index 0000000000..7ef1e0f82f --- /dev/null +++ b/feature/mindrecord/domain/src/testFixtures/kotlin/com/afternote/feature/mindrecord/domain/testing/FakeWeeklyReportRepository.kt @@ -0,0 +1,21 @@ +package com.afternote.feature.mindrecord.domain.testing + +import com.afternote.feature.mindrecord.domain.model.WeeklyReport +import com.afternote.feature.mindrecord.domain.repository.WeeklyReportRepository + +/** + * [WeeklyReportRepository] fake 정본 (#1030). + * + * 이 화면의 테스트는 "요청한 주" 와 "응답 순서" 를 함께 본다 — 응답을 [results] 로 + * 밀어 넣고 [requestedDates] 로 무엇을 요청했는지 확인한다. 큐가 비면 실패시킨다: + * 조용히 빈 리포트를 돌려주면 요청 횟수가 어긋난 것을 놓친다. + */ +class FakeWeeklyReportRepository : WeeklyReportRepository { + val results = ArrayDeque>() + val requestedDates = mutableListOf() + + override suspend fun getWeeklyReport(date: String): Result { + requestedDates += date + return requireNotNull(results.removeFirstOrNull()) { "$date 주간 리포트 응답이 큐에 없다" } + } +} diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionWriteViewModelTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionWriteViewModelTest.kt index 1bb2c948f6..b2d3b14126 100644 --- a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionWriteViewModelTest.kt +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionWriteViewModelTest.kt @@ -2,12 +2,10 @@ package com.afternote.feature.mindrecord.presentation.viewmodel import com.afternote.core.domain.repository.PhotoUploadRepository import com.afternote.feature.mindrecord.domain.model.DailyQuestion -import com.afternote.feature.mindrecord.domain.model.DiaryCreatePayload import com.afternote.feature.mindrecord.domain.model.DiaryList -import com.afternote.feature.mindrecord.domain.model.DiaryUpdatePayload import com.afternote.feature.mindrecord.domain.model.TodayDailyQuestion -import com.afternote.feature.mindrecord.domain.repository.DiaryRepository import com.afternote.feature.mindrecord.domain.testing.FakeDailyQuestionRepository +import com.afternote.feature.mindrecord.domain.testing.FakeDiaryRepository import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.runBlocking @@ -174,25 +172,14 @@ private object NoopPhotoUploadRepository : PhotoUploadRepository { /** 툴바 카운트는 이 테스트의 관심사가 아니다 — 0건으로 고정한다 (#769). */ private fun noopDraftLoader() = MindRecordDraftLoader( - diaryRepository = EmptyDraftDiaryRepository, + diaryRepository = + FakeDiaryRepository.strict().apply { + onGetList = { _, _ -> + Result.success(DiaryList(diaries = emptyList(), monthDiaryCount = 0, weeklyDominantMood = null)) + } + }, dailyQuestionRepository = FakeDailyQuestionRepository.strict().apply { onGetList = { _, _ -> Result.success(emptyList()) } }, ) - -private object EmptyDraftDiaryRepository : DiaryRepository { - override suspend fun getList( - yearMonth: String, - draftOnly: Boolean?, - ): Result = Result.success(DiaryList(diaries = emptyList(), monthDiaryCount = 0, weeklyDominantMood = null)) - - override suspend fun create(payload: DiaryCreatePayload): Result = error("호출되면 안 됨") - - override suspend fun update( - id: Long, - payload: DiaryUpdatePayload, - ): Result = error("호출되면 안 됨") - - override suspend fun delete(id: Long): Result = error("호출되면 안 됨") -} diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DraftListDeleteTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DraftListDeleteTest.kt index b3f851f12d..62c7de03fd 100644 --- a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DraftListDeleteTest.kt +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DraftListDeleteTest.kt @@ -2,13 +2,11 @@ package com.afternote.feature.mindrecord.presentation.viewmodel import com.afternote.feature.mindrecord.domain.model.DailyQuestion import com.afternote.feature.mindrecord.domain.model.Diary -import com.afternote.feature.mindrecord.domain.model.DiaryCreatePayload import com.afternote.feature.mindrecord.domain.model.DiaryList -import com.afternote.feature.mindrecord.domain.model.DiaryUpdatePayload import com.afternote.feature.mindrecord.domain.model.TodayDailyQuestion import com.afternote.feature.mindrecord.domain.repository.DailyQuestionRepository -import com.afternote.feature.mindrecord.domain.repository.DiaryRepository import com.afternote.feature.mindrecord.domain.testing.FakeDailyQuestionRepository +import com.afternote.feature.mindrecord.domain.testing.FakeDiaryRepository import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.UnconfinedTestDispatcher @@ -101,10 +99,10 @@ class DraftListDeleteTest { // 저장소를 넘겨 종전과 같은 조회 경로를 그대로 태운다. loader = MindRecordDraftLoader( - diaryRepository = EmptyDiaryDraftRepository, + diaryRepository = emptyDiaryDrafts(), dailyQuestionRepository = dailyQuestionRepository, ), - diaryRepository = EmptyDiaryDraftRepository, + diaryRepository = emptyDiaryDrafts(), dailyQuestionRepository = dailyQuestionRepository, ) @@ -119,18 +117,10 @@ class DraftListDeleteTest { ) } -private object EmptyDiaryDraftRepository : DiaryRepository { - override suspend fun getList( - yearMonth: String, - draftOnly: Boolean?, - ): Result = Result.success(DiaryList(diaries = emptyList(), monthDiaryCount = 0, weeklyDominantMood = null)) - - override suspend fun create(payload: DiaryCreatePayload): Result = error("호출되면 안 됨") - - override suspend fun update( - id: Long, - payload: DiaryUpdatePayload, - ): Result = error("호출되면 안 됨") - - override suspend fun delete(id: Long): Result = error("호출되면 안 됨") -} +/** 일기 임시저장은 이 테스트의 관심사가 아니다 — 0건으로 고정하고 나머지 호출은 막는다. */ +private fun emptyDiaryDrafts() = + FakeDiaryRepository.strict().apply { + onGetList = { _, _ -> + Result.success(DiaryList(diaries = emptyList(), monthDiaryCount = 0, weeklyDominantMood = null)) + } + } diff --git a/feature/receiver/presentation/build.gradle.kts b/feature/receiver/presentation/build.gradle.kts index b9a74a15d8..16ced494a6 100644 --- a/feature/receiver/presentation/build.gradle.kts +++ b/feature/receiver/presentation/build.gradle.kts @@ -34,6 +34,7 @@ dependencies { implementation(libs.androidx.paging.compose) testImplementation(libs.coroutines.test) + testImplementation(testFixtures(projects.feature.mindrecord.domain)) testImplementation(libs.robolectric) screenshotTestImplementation(libs.screenshot.validation.api) diff --git a/feature/receiver/presentation/src/test/kotlin/com/afternote/feature/receiver/presentation/home/ReceiverHomeViewModelTest.kt b/feature/receiver/presentation/src/test/kotlin/com/afternote/feature/receiver/presentation/home/ReceiverHomeViewModelTest.kt index d9277aa632..d44aeeb922 100644 --- a/feature/receiver/presentation/src/test/kotlin/com/afternote/feature/receiver/presentation/home/ReceiverHomeViewModelTest.kt +++ b/feature/receiver/presentation/src/test/kotlin/com/afternote/feature/receiver/presentation/home/ReceiverHomeViewModelTest.kt @@ -3,7 +3,7 @@ package com.afternote.feature.receiver.presentation.home import com.afternote.core.common.reporting.ErrorReporter import com.afternote.feature.mindrecord.domain.model.MindRecordType import com.afternote.feature.mindrecord.domain.model.ReceiverMindRecords -import com.afternote.feature.mindrecord.domain.repository.MindRecordReceiverRepository +import com.afternote.feature.mindrecord.domain.testing.FakeMindRecordReceiverRepository import com.afternote.feature.receiver.domain.model.AfterNotesListResult import com.afternote.feature.receiver.domain.model.ReceivedAfternoteDetail import com.afternote.feature.receiver.domain.model.ReceivedExportBundle @@ -183,12 +183,6 @@ private class FakeReceiverRepository : ReceiverRepository { override suspend fun loadSenderMessage(): Result = message } -private class FakeMindRecordReceiverRepository : MindRecordReceiverRepository { - var result: Result = Result.success(mindRecords(0, 0)) - - override suspend fun getAll(): Result = result -} - private class FakeReceiverTimeLetterRepository : ReceiverTimeLetterRepository { var result: Result = Result.success(timeLetters(0))