diff --git a/app/src/test/java/com/happyseal/zenplayer/core/model/TrackFixtures.kt b/app/src/test/java/com/happyseal/zenplayer/core/model/TrackFixtures.kt new file mode 100644 index 00000000..30dc3f17 --- /dev/null +++ b/app/src/test/java/com/happyseal/zenplayer/core/model/TrackFixtures.kt @@ -0,0 +1,17 @@ +package com.happyseal.zenplayer.core.model + +fun testTrack( + id: Long = 1L, + title: String = "Track $id", + folder: String = "", + albumArtUri: String? = null, +) = Track( + id = id, + fileUri = "content://media/$id", + title = title, + artist = "Artist", + album = "Album", + durationMs = 180_000L, + folder = folder, + albumArtUri = albumArtUri, +) diff --git a/app/src/test/java/com/happyseal/zenplayer/features/music/data/FallbackAwareMusicRepositoryTest.kt b/app/src/test/java/com/happyseal/zenplayer/features/music/data/FallbackAwareMusicRepositoryTest.kt index 5886a1f7..1dc71283 100644 --- a/app/src/test/java/com/happyseal/zenplayer/features/music/data/FallbackAwareMusicRepositoryTest.kt +++ b/app/src/test/java/com/happyseal/zenplayer/features/music/data/FallbackAwareMusicRepositoryTest.kt @@ -1,121 +1,111 @@ package com.happyseal.zenplayer.features.music.data import app.cash.turbine.test -import com.happyseal.zenplayer.core.model.Track +import com.happyseal.zenplayer.core.model.testTrack import io.kotest.core.spec.style.BehaviorSpec import io.kotest.matchers.shouldBe -import kotlinx.coroutines.test.runTest class FallbackAwareMusicRepositoryTest : BehaviorSpec({ + coroutineTestScope = true - fun makeTrack(id: Long) = Track( - id = id, - fileUri = "content://media/$id", - title = "Track $id", - artist = "Artist", - album = "Album", - durationMs = 180_000L, - ) - - val deviceTracks = listOf(makeTrack(1), makeTrack(2), makeTrack(3)) - val sampleTracks = listOf(makeTrack(101), makeTrack(102)) + val deviceTracks = listOf(testTrack(id = 1), testTrack(id = 2), testTrack(id = 3)) + val sampleTracks = listOf(testTrack(id = 101), testTrack(id = 102)) Given("기기 음원에 정상 접근 가능한 상태") { + val repo = FallbackAwareMusicRepository( + primary = FakeMusicRepository(tracks = deviceTracks), + fallback = FakeMusicRepository(tracks = sampleTracks), + ) + When("음악 목록을 불러오면") { - Then("기기 음원 목록이 반환되고 isFallback이 false다") { - runTest { - val repo = FallbackAwareMusicRepository( - primary = FakeMusicRepository(tracks = deviceTracks), - fallback = FakeMusicRepository(tracks = sampleTracks), - ) - - val result = repo.getAllTracks() - - result.getOrNull() shouldBe deviceTracks - repo.isFallback.test { - awaitItem() shouldBe false - cancelAndIgnoreRemainingEvents() - } + val result = repo.getAllTracks() + + Then("기기 음원 목록이 반환된다") { + result.getOrNull() shouldBe deviceTracks + } + + Then("isFallback이 false다") { + repo.isFallback.test { + awaitItem() shouldBe false + cancelAndIgnoreRemainingEvents() } } } } Given("기기 음원이 비어 있는 상태") { + val repo = FallbackAwareMusicRepository( + primary = FakeMusicRepository(tracks = emptyList()), + fallback = FakeMusicRepository(tracks = sampleTracks), + ) + When("음악 목록을 불러오면") { + val result = repo.getAllTracks() + Then("내장 기본 음원 목록으로 대체된다") { - runTest { - val repo = FallbackAwareMusicRepository( - primary = FakeMusicRepository(tracks = emptyList()), - fallback = FakeMusicRepository(tracks = sampleTracks), - ) - - val result = repo.getAllTracks() - - result.getOrNull() shouldBe sampleTracks - repo.isFallback.test { - awaitItem() shouldBe true - cancelAndIgnoreRemainingEvents() - } + result.getOrNull() shouldBe sampleTracks + } + + Then("isFallback이 true다") { + repo.isFallback.test { + awaitItem() shouldBe true + cancelAndIgnoreRemainingEvents() } } } } Given("기기 음원 접근 중 예외가 발생한 상태") { + val repo = FallbackAwareMusicRepository( + primary = FakeMusicRepository(throwOnGetAll = RuntimeException("권한 없음")), + fallback = FakeMusicRepository(tracks = sampleTracks), + ) + When("음악 목록을 불러오면") { + val result = repo.getAllTracks() + Then("내장 기본 음원 목록으로 대체된다") { - runTest { - val repo = FallbackAwareMusicRepository( - primary = FakeMusicRepository(throwOnGetAll = RuntimeException("권한 없음")), - fallback = FakeMusicRepository(tracks = sampleTracks), - ) - - val result = repo.getAllTracks() - - result.getOrNull() shouldBe sampleTracks - repo.isFallback.test { - awaitItem() shouldBe true - cancelAndIgnoreRemainingEvents() - } + result.getOrNull() shouldBe sampleTracks + } + + Then("isFallback이 true다") { + repo.isFallback.test { + awaitItem() shouldBe true + cancelAndIgnoreRemainingEvents() } } } } Given("기본 음원 모드인 상태") { - When("특정 곡을 조회하면") { - Then("내장 음원에서 해당 곡을 찾아 반환한다") { - runTest { - val repo = FallbackAwareMusicRepository( - primary = FakeMusicRepository(tracks = emptyList()), - fallback = FakeMusicRepository(tracks = sampleTracks), - ) - repo.getAllTracks() // isFallback = true 로 전환 + val repo = FallbackAwareMusicRepository( + primary = FakeMusicRepository(tracks = emptyList()), + fallback = FakeMusicRepository(tracks = sampleTracks), + ) + repo.getAllTracks() // isFallback = true 로 전환 - val result = repo.getTrackById(101L) + When("특정 곡을 조회하면") { + val result = repo.getTrackById(101L) - result.getOrNull() shouldBe sampleTracks[0] - } + Then("내장 음원에서 해당 곡을 찾아 반환한다") { + result.getOrNull() shouldBe sampleTracks[0] } } } Given("기기 음원 모드인 상태") { - When("특정 곡을 조회하면") { - Then("기기 음원에서 해당 곡을 찾아 반환한다") { - runTest { - val repo = FallbackAwareMusicRepository( - primary = FakeMusicRepository(tracks = deviceTracks), - fallback = FakeMusicRepository(tracks = sampleTracks), - ) - repo.getAllTracks() // isFallback = false 유지 + val repo = FallbackAwareMusicRepository( + primary = FakeMusicRepository(tracks = deviceTracks), + fallback = FakeMusicRepository(tracks = sampleTracks), + ) + repo.getAllTracks() // isFallback = false 유지 - val result = repo.getTrackById(2L) + When("특정 곡을 조회하면") { + val result = repo.getTrackById(2L) - result.getOrNull() shouldBe deviceTracks[1] - } + Then("기기 음원에서 해당 곡을 찾아 반환한다") { + result.getOrNull() shouldBe deviceTracks[1] } } } diff --git a/app/src/test/java/com/happyseal/zenplayer/features/player/data/PlayQueueManagerTest.kt b/app/src/test/java/com/happyseal/zenplayer/features/player/data/PlayQueueManagerTest.kt index 78857785..3c58f330 100644 --- a/app/src/test/java/com/happyseal/zenplayer/features/player/data/PlayQueueManagerTest.kt +++ b/app/src/test/java/com/happyseal/zenplayer/features/player/data/PlayQueueManagerTest.kt @@ -1,7 +1,7 @@ package com.happyseal.zenplayer.features.player.data import app.cash.turbine.test -import com.happyseal.zenplayer.core.model.Track +import com.happyseal.zenplayer.core.model.testTrack import com.happyseal.zenplayer.features.player.domain.PlaylistEvent import com.happyseal.zenplayer.features.player.domain.RepeatMode import io.kotest.assertions.throwables.shouldThrow @@ -15,19 +15,7 @@ class PlayQueueManagerTest : coroutineTestScope = true - fun makeTrack( - id: Long, - title: String, - ) = Track( - id = id, - fileUri = "content://media/$id", - title = title, - artist = "Artist", - album = "Album", - durationMs = 180_000L, - ) - - fun makeTracks(count: Int) = (1..count).map { makeTrack(it.toLong(), "Track $it") } + fun makeTracks(count: Int) = (1..count).map { testTrack(id = it.toLong()) } fun makeManager( audioController: FakeAudioController = FakeAudioController(), @@ -113,38 +101,36 @@ class PlayQueueManagerTest : } } - When("셔플을 켜면(seed 기반)") { - val seed = 42L - val manager = makeManager(random = Random(seed)) + When("셔플을 켠 상태에서 5번 playNext()를 호출하면") { + val manager = makeManager() val tracks = makeTracks(5) manager.setPlayQueue(tracks, startIndex = 0) manager.toggleShuffle() + val visited = mutableListOf(manager.playQueue.value.shouldNotBeNull().currentIndex) + repeat(4) { + manager.playNext() + visited.add(manager.playQueue.value.shouldNotBeNull().currentIndex) + } - Then("재생 순서가 seed 기반 무작위 순서로 바뀐다") { - // 같은 seed로 예상 순서 계산 - val expectedOrder = buildList { - val rest = (1..4).toMutableList() - rest.shuffle(Random(seed)) - add(0) - addAll(rest) - } + Then("모든 곡이 정확히 한 번씩 방문된다") { + visited.toSet().size shouldBe 5 + } + } + } - // playNext()로 순서대로 방문 - val visitedIndices = mutableListOf( - manager.playQueue.value - .shouldNotBeNull() - .currentIndex, - ) - repeat(4) { - manager.playNext() - visitedIndices.add( - manager.playQueue.value - .shouldNotBeNull() - .currentIndex, - ) - } + Given("셔플 ON 상태에서 재생 중일 때") { + val manager = makeManager(random = Random(seed = 42L)) + val tracks = makeTracks(5) + manager.setPlayQueue(tracks, startIndex = 0) + manager.toggleShuffle() + manager.playNext() // 셔플 순서로 이동 + + When("셔플을 OFF로 토글하면") { + val indexBeforeToggle = manager.playQueue.value.shouldNotBeNull().currentIndex + manager.toggleShuffle() - visitedIndices shouldBe expectedOrder + Then("currentIndex가 셔플 전 재생 중이던 트랙의 원래 인덱스를 유지한다") { + manager.playQueue.value?.currentIndex shouldBe indexBeforeToggle } } } @@ -191,8 +177,8 @@ class PlayQueueManagerTest : Given("재생 큐가 없는 초기 상태") { When("playNext()를 호출하면") { + val manager = makeManager() Then("IllegalStateException이 발생한다") { - val manager = makeManager() shouldThrow { manager.playNext() } @@ -200,8 +186,8 @@ class PlayQueueManagerTest : } When("playPrevious()를 호출하면") { + val manager = makeManager() Then("IllegalStateException이 발생한다") { - val manager = makeManager() shouldThrow { manager.playPrevious() } @@ -209,8 +195,8 @@ class PlayQueueManagerTest : } When("playCurrent()를 호출하면") { + val manager = makeManager() Then("IllegalStateException이 발생한다") { - val manager = makeManager() shouldThrow { manager.playCurrent() } diff --git a/app/src/test/java/com/happyseal/zenplayer/features/playlist/ui/PlaylistDetailViewModelTest.kt b/app/src/test/java/com/happyseal/zenplayer/features/playlist/ui/PlaylistDetailViewModelTest.kt index 3fc5c19d..516027a9 100644 --- a/app/src/test/java/com/happyseal/zenplayer/features/playlist/ui/PlaylistDetailViewModelTest.kt +++ b/app/src/test/java/com/happyseal/zenplayer/features/playlist/ui/PlaylistDetailViewModelTest.kt @@ -1,7 +1,7 @@ package com.happyseal.zenplayer.features.playlist.ui import app.cash.turbine.test -import com.happyseal.zenplayer.core.model.Track +import com.happyseal.zenplayer.core.model.testTrack import com.happyseal.zenplayer.features.playlist.data.FakePlaylistRepository import com.happyseal.zenplayer.zen.FakeZenPlayerBinder import com.happyseal.zenplayer.zen.FakeZenPlayerServiceConnection @@ -24,6 +24,7 @@ class PlaylistDetailViewModelTest : BehaviorSpec({ val testScheduler = TestCoroutineScheduler() val testDispatcher = StandardTestDispatcher(testScheduler) + beforeTest { Dispatchers.setMain(testDispatcher) } afterTest { Dispatchers.resetMain() } fun makeViewModel( @@ -31,21 +32,12 @@ class PlaylistDetailViewModelTest : BehaviorSpec({ binder: FakeZenPlayerBinder? = null, ): PlaylistDetailViewModel = PlaylistDetailViewModel(repo, FakeZenPlayerServiceConnection(binder)) - fun makeTrack(id: Long) = Track( - id = id, - fileUri = "content://media/$id", - title = "Track $id", - artist = "Artist", - album = "Album", - durationMs = 180_000L, - ) - Given("플레이리스트 \"Chill Beats\"와 트랙 2개가 있을 때") { - Dispatchers.setMain(testDispatcher) + val repo = FakePlaylistRepository() val playlistId = repo.createPlaylist("Chill Beats").getOrThrow() - repo.addTrackToPlaylist(playlistId, makeTrack(1)) - repo.addTrackToPlaylist(playlistId, makeTrack(2)) + repo.addTrackToPlaylist(playlistId, testTrack(id = 1)) + repo.addTrackToPlaylist(playlistId, testTrack(id = 2)) val viewModel = makeViewModel(repo) When("onAction(SelectPlaylist(playlistId))를 호출하면") { @@ -61,7 +53,7 @@ class PlaylistDetailViewModelTest : BehaviorSpec({ } Given("존재하지 않는 playlistId가 있을 때") { - Dispatchers.setMain(testDispatcher) + val viewModel = makeViewModel() When("onAction(SelectPlaylist(999L))를 호출하면") { @@ -75,11 +67,11 @@ class PlaylistDetailViewModelTest : BehaviorSpec({ } Given("플레이리스트 \"Work Out\"과 트랙 2개가 있을 때") { - Dispatchers.setMain(testDispatcher) + val repo = FakePlaylistRepository() val playlistId = repo.createPlaylist("Work Out").getOrThrow() - repo.addTrackToPlaylist(playlistId, makeTrack(1)) - repo.addTrackToPlaylist(playlistId, makeTrack(2)) + repo.addTrackToPlaylist(playlistId, testTrack(id = 1)) + repo.addTrackToPlaylist(playlistId, testTrack(id = 2)) val binder = FakeZenPlayerBinder() val viewModel = makeViewModel(repo = repo, binder = binder) viewModel.onAction(PlaylistDetailAction.SelectPlaylist(playlistId)) @@ -97,8 +89,30 @@ class PlaylistDetailViewModelTest : BehaviorSpec({ } } + Given("플레이리스트에 트랙 2개가 있을 때") { + + val repo = FakePlaylistRepository() + val playlistId = repo.createPlaylist("My List").getOrThrow() + repo.addTrackToPlaylist(playlistId, testTrack(id = 1)) // rowId=1 + repo.addTrackToPlaylist(playlistId, testTrack(id = 2)) // rowId=2 + val viewModel = makeViewModel(repo) + viewModel.onAction(PlaylistDetailAction.SelectPlaylist(playlistId)) + testScheduler.advanceUntilIdle() + + When("첫 번째 트랙(rowId=1)을 RemoveTrack으로 제거하면") { + viewModel.onAction(PlaylistDetailAction.RemoveTrack(1L)) + testScheduler.advanceUntilIdle() + + Then("detailUiState.Success.tracks에서 해당 트랙이 사라진다") { + val state = viewModel.detailUiState.value.shouldBeInstanceOf() + state.tracks.size shouldBe 1 + state.tracks.none { it.track.id == 1L } shouldBe true + } + } + } + Given("removeTrackFromPlaylist가 실패하는 Repository가 있을 때") { - Dispatchers.setMain(testDispatcher) + val repo = FakePlaylistRepository(throwOnRemoveTrack = RuntimeException("DB 오류")) val viewModel = makeViewModel(repo) @@ -117,7 +131,7 @@ class PlaylistDetailViewModelTest : BehaviorSpec({ } Given("잘못된 인자를 전달할 때") { - Dispatchers.setMain(testDispatcher) + val viewModel = makeViewModel() When("onAction(SelectPlaylist(0))을 호출하면") { diff --git a/app/src/test/java/com/happyseal/zenplayer/features/timer/domain/PomodoroManagerTest.kt b/app/src/test/java/com/happyseal/zenplayer/features/timer/domain/PomodoroManagerTest.kt index fd7cf8a9..d73194da 100644 --- a/app/src/test/java/com/happyseal/zenplayer/features/timer/domain/PomodoroManagerTest.kt +++ b/app/src/test/java/com/happyseal/zenplayer/features/timer/domain/PomodoroManagerTest.kt @@ -8,12 +8,17 @@ import io.kotest.core.spec.style.BehaviorSpec import io.kotest.matchers.shouldBe import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.advanceUntilIdle -import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.cancel +import kotlinx.coroutines.test.TestCoroutineScheduler +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlin.time.Duration.Companion.minutes @OptIn(ExperimentalCoroutinesApi::class) class PomodoroManagerTest : BehaviorSpec({ + coroutineTestScope = true + + val testScheduler = TestCoroutineScheduler() fun makeManager( pomodoroRepo: FakePomodoroSnapshotRepository, @@ -34,268 +39,295 @@ class PomodoroManagerTest : Given("IDLE 상태의 PomodoroManager가 있을 때") { When("start()를 호출하면") { + val scope = CoroutineScope(UnconfinedTestDispatcher(testScheduler)) + val manager = makeManager( + FakePomodoroSnapshotRepository(), + FakeTimerSnapshotRepository(), + FakeTimeSource(), + scope, + ) + manager.start() + Then("timerState가 RUNNING으로 전환된다") { - runTest { - val manager = makeManager( - FakePomodoroSnapshotRepository(), - FakeTimerSnapshotRepository(), - FakeTimeSource(), - backgroundScope, - ) - - manager.start() - advanceUntilIdle() - - manager.pomodoroState.value.timerState shouldBe TimerState.RUNNING - } + manager.pomodoroState.value.timerState shouldBe TimerState.RUNNING + manager.dispose() + scope.cancel() } } } Given("RUNNING 상태의 PomodoroManager가 있을 때") { When("pause()를 호출하면") { + val scope = CoroutineScope(UnconfinedTestDispatcher(testScheduler)) + val manager = makeManager( + FakePomodoroSnapshotRepository(), + FakeTimerSnapshotRepository(), + FakeTimeSource(), + scope, + ) + manager.start() + manager.pause() + Then("timerState가 PAUSED로 전환된다") { - runTest { - val manager = makeManager( - FakePomodoroSnapshotRepository(), - FakeTimerSnapshotRepository(), - FakeTimeSource(), - backgroundScope, - ) - manager.start() - - manager.pause() - advanceUntilIdle() - - manager.pomodoroState.value.timerState shouldBe TimerState.PAUSED - } + manager.pomodoroState.value.timerState shouldBe TimerState.PAUSED + manager.dispose() + scope.cancel() } } When("reset()을 호출하면") { + val scope = CoroutineScope(UnconfinedTestDispatcher(testScheduler)) + val manager = makeManager( + FakePomodoroSnapshotRepository(), + FakeTimerSnapshotRepository(), + FakeTimeSource(), + scope, + ) + manager.start() + manager.reset() + Then("timerState가 IDLE, session이 FOCUS, loopCount가 0으로 초기화된다") { - runTest { - val manager = makeManager( - FakePomodoroSnapshotRepository(), - FakeTimerSnapshotRepository(), - FakeTimeSource(), - backgroundScope, - ) - manager.start() - - manager.reset() - advanceUntilIdle() - - manager.pomodoroState.value.timerState shouldBe TimerState.IDLE - manager.pomodoroState.value.session shouldBe PomodoroSession.FOCUS - manager.pomodoroState.value.loopCount shouldBe 0 - } + manager.pomodoroState.value.timerState shouldBe TimerState.IDLE + manager.pomodoroState.value.session shouldBe PomodoroSession.FOCUS + manager.pomodoroState.value.loopCount shouldBe 0 + manager.dispose() + scope.cancel() } } } Given("PAUSED 상태의 PomodoroManager가 있을 때") { When("resume()을 호출하면") { + val scope = CoroutineScope(UnconfinedTestDispatcher(testScheduler)) + val manager = makeManager( + FakePomodoroSnapshotRepository(), + FakeTimerSnapshotRepository(), + FakeTimeSource(), + scope, + ) + manager.start() + manager.pause() + manager.resume() + Then("timerState가 RUNNING으로 전환된다") { - runTest { - val manager = makeManager( - FakePomodoroSnapshotRepository(), - FakeTimerSnapshotRepository(), - FakeTimeSource(), - backgroundScope, - ) - manager.start() - manager.pause() - - manager.resume() - advanceUntilIdle() - - manager.pomodoroState.value.timerState shouldBe TimerState.RUNNING - } + manager.pomodoroState.value.timerState shouldBe TimerState.RUNNING + manager.dispose() + scope.cancel() } } } Given("loopsPerRound=4, 1번째 FOCUS 세션이 RUNNING 중일 때") { + val fakeTimeSource1 = FakeTimeSource() + val scope1 = CoroutineScope(UnconfinedTestDispatcher(testScheduler)) + val manager1 = makeManager( + FakePomodoroSnapshotRepository(), + FakeTimerSnapshotRepository(), + fakeTimeSource1, + scope1, + ) + manager1.start() + When("타이머가 완료되면") { + elapse(fakeTimeSource1, PomodoroConfig.Default.focusDuration, testScheduler) + Then("session이 BREAK로 전환되고 loopCount는 0 유지된다") { - runTest { - val fakeTimeSource = FakeTimeSource() - val manager = makeManager( - FakePomodoroSnapshotRepository(), - FakeTimerSnapshotRepository(), - fakeTimeSource, - backgroundScope, - ) - manager.start() - elapse(fakeTimeSource, PomodoroConfig.Default.focusDuration) - advanceUntilIdle() - - manager.pomodoroState.value.session shouldBe PomodoroSession.BREAK - manager.pomodoroState.value.loopCount shouldBe 0 - } + manager1.pomodoroState.value.session shouldBe PomodoroSession.BREAK + manager1.pomodoroState.value.loopCount shouldBe 0 + manager1.dispose() + scope1.cancel() } } } Given("loopsPerRound=4, 1번째 BREAK 세션이 RUNNING 중일 때") { + val fakeTimeSource2 = FakeTimeSource() + val scope2 = CoroutineScope(UnconfinedTestDispatcher(testScheduler)) + val manager2 = makeManager( + FakePomodoroSnapshotRepository(), + FakeTimerSnapshotRepository(), + fakeTimeSource2, + scope2, + ) + manager2.start() + manager2.skip() // FOCUS → BREAK (loopCount 0 유지) + When("타이머가 완료되면") { + elapse(fakeTimeSource2, PomodoroConfig.Default.breakDuration, testScheduler) + Then("session이 FOCUS로 전환되고 loopCount가 1이 된다") { - runTest { - val fakeTimeSource = FakeTimeSource() - val manager = makeManager( - FakePomodoroSnapshotRepository(), - FakeTimerSnapshotRepository(), - fakeTimeSource, - backgroundScope, - ) - manager.start() - manager.skip() // FOCUS → BREAK (loopCount 0 유지) - elapse(fakeTimeSource, PomodoroConfig.Default.breakDuration) - advanceUntilIdle() - - manager.pomodoroState.value.session shouldBe PomodoroSession.FOCUS - manager.pomodoroState.value.loopCount shouldBe 1 - } + manager2.pomodoroState.value.session shouldBe PomodoroSession.FOCUS + manager2.pomodoroState.value.loopCount shouldBe 1 + manager2.dispose() + scope2.cancel() } } } Given("loopsPerRound=2, 마지막(2번째) FOCUS 세션이 RUNNING 중일 때") { + val fakeTimeSource3 = FakeTimeSource() + val configRepo = FakePomodoroConfigRepository( + PomodoroConfig( + focusDuration = PomodoroConfig.Default.focusDuration, + breakDuration = PomodoroConfig.Default.breakDuration, + loopsPerRound = 2, + ), + ) + val scope3 = CoroutineScope(UnconfinedTestDispatcher(testScheduler)) + val manager3 = makeManager( + FakePomodoroSnapshotRepository(), + FakeTimerSnapshotRepository(), + fakeTimeSource3, + scope3, + configRepo, + ) + manager3.start() + manager3.skip() // FOCUS(loopCount=0) → BREAK(loopCount=0 유지) + manager3.skip() // BREAK → FOCUS (loopCount=1) + When("타이머가 완료되면") { + // 2번째 FOCUS 완료: loopCount(1)+1 == loopsPerRound(2) → IDLE 정지 + elapse(fakeTimeSource3, PomodoroConfig.Default.focusDuration, testScheduler) + Then("IDLE로 정지되고 session=FOCUS, loopCount=0으로 리셋된다") { - runTest { - val fakeTimeSource = FakeTimeSource() - val configRepo = FakePomodoroConfigRepository( - PomodoroConfig( - focusDuration = PomodoroConfig.Default.focusDuration, - breakDuration = PomodoroConfig.Default.breakDuration, - loopsPerRound = 2, - ), - ) - val manager = makeManager( - FakePomodoroSnapshotRepository(), - FakeTimerSnapshotRepository(), - fakeTimeSource, - backgroundScope, - configRepo, - ) - manager.start() - manager.skip() // FOCUS(loopCount=0) → BREAK(loopCount=0 유지) - manager.skip() // BREAK → FOCUS (loopCount=1) - // 2번째 FOCUS 완료: loopCount(1)+1 == loopsPerRound(2) → IDLE 정지 - elapse(fakeTimeSource, PomodoroConfig.Default.focusDuration) - advanceUntilIdle() - - manager.pomodoroState.value.timerState shouldBe TimerState.IDLE - manager.pomodoroState.value.session shouldBe PomodoroSession.FOCUS - manager.pomodoroState.value.loopCount shouldBe 0 - } + manager3.pomodoroState.value.timerState shouldBe TimerState.IDLE + manager3.pomodoroState.value.session shouldBe PomodoroSession.FOCUS + manager3.pomodoroState.value.loopCount shouldBe 0 + manager3.dispose() + scope3.cancel() } } } Given("loopsPerRound=4, BREAK 세션이 RUNNING 중일 때") { + val fakeTimeSource4 = FakeTimeSource() + val scope4 = CoroutineScope(UnconfinedTestDispatcher(testScheduler)) + val manager4 = makeManager( + FakePomodoroSnapshotRepository(), + FakeTimerSnapshotRepository(), + fakeTimeSource4, + scope4, + ) + // FOCUS → skip → BREAK 상태로 이동 (skip은 자동으로 다음 세션 start) + manager4.start() + manager4.skip() // BREAK로 이동, 자동 start + When("타이머가 완료되면") { + elapse(fakeTimeSource4, PomodoroConfig.Default.breakDuration, testScheduler) + Then("session이 FOCUS로 전환되고 loopCount가 1이 된다") { - runTest { - val fakeTimeSource = FakeTimeSource() - val manager = makeManager( - FakePomodoroSnapshotRepository(), - FakeTimerSnapshotRepository(), - fakeTimeSource, - backgroundScope, - ) - // FOCUS → skip → BREAK 상태로 이동 (skip은 자동으로 다음 세션 start) - manager.start() - manager.skip() // BREAK로 이동, 자동 start - - elapse(fakeTimeSource, PomodoroConfig.Default.breakDuration) - advanceUntilIdle() - - manager.pomodoroState.value.session shouldBe PomodoroSession.FOCUS - manager.pomodoroState.value.loopCount shouldBe 1 - } + manager4.pomodoroState.value.session shouldBe PomodoroSession.FOCUS + manager4.pomodoroState.value.loopCount shouldBe 1 + manager4.dispose() + scope4.cancel() } } } Given("FOCUS 세션이 RUNNING 중일 때") { When("skip()을 호출하면") { + val scope = CoroutineScope(UnconfinedTestDispatcher(testScheduler)) + val manager = makeManager( + FakePomodoroSnapshotRepository(), + FakeTimerSnapshotRepository(), + FakeTimeSource(), + scope, + ) + manager.start() + manager.skip() + Then("session이 BREAK로 전환되고 timerState가 RUNNING이 된다") { - runTest { - val manager = makeManager( - FakePomodoroSnapshotRepository(), - FakeTimerSnapshotRepository(), - FakeTimeSource(), - backgroundScope, - ) - manager.start() - - manager.skip() - advanceUntilIdle() - - manager.pomodoroState.value.session shouldBe PomodoroSession.BREAK - manager.pomodoroState.value.timerState shouldBe TimerState.RUNNING - } + manager.pomodoroState.value.session shouldBe PomodoroSession.BREAK + manager.pomodoroState.value.timerState shouldBe TimerState.RUNNING + manager.dispose() + scope.cancel() } } } Given("IDLE 상태이고 스냅샷(session=BREAK, loopCount=2)이 저장되어 있을 때") { + val pomodoroRepo = FakePomodoroSnapshotRepository().apply { + snapshot = PomodoroSnapshot( + session = PomodoroSession.BREAK, + loopCount = 2, + ) + } + When("restore()를 호출하면") { + val scope = CoroutineScope(UnconfinedTestDispatcher(testScheduler)) + val manager = makeManager( + pomodoroRepo, + FakeTimerSnapshotRepository(), + FakeTimeSource(), + scope, + ) + manager.restore() + Then("session과 loopCount가 스냅샷 값으로 복원된다") { - runTest { - val pomodoroRepo = FakePomodoroSnapshotRepository().apply { - snapshot = PomodoroSnapshot( - session = PomodoroSession.BREAK, - loopCount = 2, - ) - } - val manager = makeManager( - pomodoroRepo, - FakeTimerSnapshotRepository(), - FakeTimeSource(), - backgroundScope, - ) - - manager.restore() - advanceUntilIdle() - - manager.pomodoroState.value.session shouldBe PomodoroSession.BREAK - manager.pomodoroState.value.loopCount shouldBe 2 - } + manager.pomodoroState.value.session shouldBe PomodoroSession.BREAK + manager.pomodoroState.value.loopCount shouldBe 2 + manager.dispose() + scope.cancel() + } + } + } + + Given("타이머가 RUNNING 중일 때") { + val configRepo = FakePomodoroConfigRepository() + val fakeTimeSource = FakeTimeSource() + val scope = CoroutineScope(UnconfinedTestDispatcher(testScheduler)) + val manager = makeManager( + FakePomodoroSnapshotRepository(), + FakeTimerSnapshotRepository(), + fakeTimeSource, + scope, + configRepo, + ) + manager.start() + + When("focusDuration 설정을 30분으로 변경하면") { + configRepo.saveConfig( + PomodoroConfig( + focusDuration = 30.minutes, + breakDuration = PomodoroConfig.Default.breakDuration, + loopsPerRound = PomodoroConfig.Default.loopsPerRound, + ), + ) + + Then("현재 진행 중인 세션의 timerState는 RUNNING을 유지한다") { + manager.pomodoroState.value.timerState shouldBe TimerState.RUNNING + manager.dispose() + scope.cancel() } } } Given("RUNNING 상태이고 스냅샷이 저장되어 있을 때") { + val pomodoroRepo = FakePomodoroSnapshotRepository().apply { + snapshot = PomodoroSnapshot( + session = PomodoroSession.BREAK, + loopCount = 2, + ) + } + When("restore()를 호출하면") { + val scope = CoroutineScope(UnconfinedTestDispatcher(testScheduler)) + val manager = makeManager( + pomodoroRepo, + FakeTimerSnapshotRepository(), + FakeTimeSource(), + scope, + ) + manager.start() + manager.restore() + Then("상태가 변하지 않고 무시된다") { - runTest { - val pomodoroRepo = FakePomodoroSnapshotRepository().apply { - snapshot = PomodoroSnapshot( - session = PomodoroSession.BREAK, - loopCount = 2, - ) - } - val manager = makeManager( - pomodoroRepo, - FakeTimerSnapshotRepository(), - FakeTimeSource(), - backgroundScope, - ) - manager.start() - advanceUntilIdle() - - manager.restore() - advanceUntilIdle() - - // 스냅샷 값이 아닌 현재 상태(FOCUS, RUNNING) 유지 - manager.pomodoroState.value.session shouldBe PomodoroSession.FOCUS - manager.pomodoroState.value.timerState shouldBe TimerState.RUNNING - manager.pomodoroState.value.loopCount shouldBe 0 - } + // 스냅샷 값이 아닌 현재 상태(FOCUS, RUNNING) 유지 + manager.pomodoroState.value.session shouldBe PomodoroSession.FOCUS + manager.pomodoroState.value.timerState shouldBe TimerState.RUNNING + manager.pomodoroState.value.loopCount shouldBe 0 + manager.dispose() + scope.cancel() } } } diff --git a/app/src/test/java/com/happyseal/zenplayer/features/timer/domain/TimerEngineTest.kt b/app/src/test/java/com/happyseal/zenplayer/features/timer/domain/TimerEngineTest.kt index 306d8b15..523ddaee 100644 --- a/app/src/test/java/com/happyseal/zenplayer/features/timer/domain/TimerEngineTest.kt +++ b/app/src/test/java/com/happyseal/zenplayer/features/timer/domain/TimerEngineTest.kt @@ -6,8 +6,9 @@ import io.kotest.core.spec.style.BehaviorSpec import io.kotest.matchers.shouldBe import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.advanceTimeBy -import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.cancel +import kotlinx.coroutines.test.TestCoroutineScheduler +import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds @@ -15,6 +16,9 @@ import kotlin.time.Duration.Companion.seconds @OptIn(ExperimentalCoroutinesApi::class) class TimerEngineTest : BehaviorSpec({ + coroutineTestScope = true + + val testScheduler = TestCoroutineScheduler() fun makeEngine( repository: FakeTimerSnapshotRepository, @@ -28,278 +32,289 @@ class TimerEngineTest : Given("IDLE 상태의 타이머가 있을 때") { When("start를 호출하면") { - Then("RUNNING 상태로 전환된다") { - runTest { - val engine = makeEngine(FakeTimerSnapshotRepository(), FakeTimeSource(), backgroundScope) - engine.start(1.minutes) + val scope = CoroutineScope(UnconfinedTestDispatcher(testScheduler)) + val engine = makeEngine(FakeTimerSnapshotRepository(), FakeTimeSource(), scope) + engine.start(1.minutes) - engine.timerState.value shouldBe TimerState.RUNNING - } + Then("RUNNING 상태로 전환된다") { + engine.timerState.value shouldBe TimerState.RUNNING + engine.dispose() + scope.cancel() } } } Given("RUNNING 상태의 타이머가 있을 때") { When("pause를 호출하면") { - Then("PAUSED 상태로 전환된다") { - runTest { - val engine = makeEngine(FakeTimerSnapshotRepository(), FakeTimeSource(), backgroundScope) - engine.start(1.minutes) - engine.pause() + val scope = CoroutineScope(UnconfinedTestDispatcher(testScheduler)) + val engine = makeEngine(FakeTimerSnapshotRepository(), FakeTimeSource(), scope) + engine.start(1.minutes) + engine.pause() - engine.timerState.value shouldBe TimerState.PAUSED - } + Then("PAUSED 상태로 전환된다") { + engine.timerState.value shouldBe TimerState.PAUSED + engine.dispose() + scope.cancel() } } When("reset을 호출하면") { - Then("IDLE 상태로 전환된다") { - runTest { - val engine = makeEngine(FakeTimerSnapshotRepository(), FakeTimeSource(), backgroundScope) - engine.start(1.minutes) - engine.reset() + val scope = CoroutineScope(UnconfinedTestDispatcher(testScheduler)) + val engine = makeEngine(FakeTimerSnapshotRepository(), FakeTimeSource(), scope) + engine.start(1.minutes) + engine.reset() - engine.timerState.value shouldBe TimerState.IDLE - } + Then("IDLE 상태로 전환된다") { + engine.timerState.value shouldBe TimerState.IDLE } Then("remaining이 0이 된다") { - runTest { - val engine = makeEngine(FakeTimerSnapshotRepository(), FakeTimeSource(), backgroundScope) - engine.start(1.minutes) - engine.reset() - - engine.remaining.value shouldBe 0.milliseconds - } + engine.remaining.value shouldBe 0.milliseconds + engine.dispose() + scope.cancel() } } When("start를 다시 호출하면") { - Then("무시된다") { - runTest { - val engine = makeEngine(FakeTimerSnapshotRepository(), FakeTimeSource(), backgroundScope) - engine.start(1.minutes) - engine.start(30.seconds) // 무시 + val scope = CoroutineScope(UnconfinedTestDispatcher(testScheduler)) + val engine = makeEngine(FakeTimerSnapshotRepository(), FakeTimeSource(), scope) + engine.start(1.minutes) + engine.start(30.seconds) // 무시 - engine.timerState.value shouldBe TimerState.RUNNING - engine.remaining.value shouldBe 1.minutes - } + Then("무시된다") { + engine.timerState.value shouldBe TimerState.RUNNING + engine.remaining.value shouldBe 1.minutes + engine.dispose() + scope.cancel() } } } Given("PAUSED 상태의 타이머가 있을 때") { When("resume을 호출하면") { - Then("RUNNING 상태로 전환된다") { - runTest { - val engine = makeEngine(FakeTimerSnapshotRepository(), FakeTimeSource(), backgroundScope) - engine.start(1.minutes) - engine.pause() - engine.resume() + val scope = CoroutineScope(UnconfinedTestDispatcher(testScheduler)) + val engine = makeEngine(FakeTimerSnapshotRepository(), FakeTimeSource(), scope) + engine.start(1.minutes) + engine.pause() + engine.resume() - engine.timerState.value shouldBe TimerState.RUNNING - } + Then("RUNNING 상태로 전환된다") { + engine.timerState.value shouldBe TimerState.RUNNING + engine.dispose() + scope.cancel() } } } Given("500ms 타이머가 RUNNING 중일 때") { - When("600ms가 경과하면") { - Then("COMPLETED 상태로 전환된다") { - runTest { - val fakeTimeSource = FakeTimeSource() - val engine = makeEngine(FakeTimerSnapshotRepository(), fakeTimeSource, backgroundScope) + val fakeTimeSource1 = FakeTimeSource() + val scope1 = CoroutineScope(UnconfinedTestDispatcher(testScheduler)) + val engine1 = makeEngine(FakeTimerSnapshotRepository(), fakeTimeSource1, scope1) + engine1.start(500.milliseconds) - engine.start(500.milliseconds) - fakeTimeSource.advanceBy(600) - advanceTimeBy(700) + When("600ms가 경과하면") { + fakeTimeSource1.advanceBy(600) + testScheduler.advanceTimeBy(700) - engine.timerState.value shouldBe TimerState.COMPLETED - } + Then("COMPLETED 상태로 전환된다") { + engine1.timerState.value shouldBe TimerState.COMPLETED + engine1.dispose() + scope1.cancel() } } } Given("10초 타이머가 RUNNING 중일 때") { When("2초 경과 후 pause하고, 추가로 3초가 경과하면") { - Then("일시정지 중 경과한 시간은 remaining에 포함되지 않는다") { - runTest { - val fakeTimeSource = FakeTimeSource() - val engine = makeEngine(FakeTimerSnapshotRepository(), fakeTimeSource, backgroundScope) - - engine.start(10.seconds) - - fakeTimeSource.advanceBy(2_000) - advanceTimeBy(2_100) - engine.pause() + val fakeTimeSource = FakeTimeSource() + val scope = CoroutineScope(UnconfinedTestDispatcher(testScheduler)) + val engine = makeEngine(FakeTimerSnapshotRepository(), fakeTimeSource, scope) + engine.start(10.seconds) + fakeTimeSource.advanceBy(2_000) + testScheduler.advanceTimeBy(2_100) + engine.pause() + val remainingAfterPause = engine.remaining.value + + fakeTimeSource.advanceBy(3_000) + testScheduler.advanceTimeBy(3_100) - val remainingAfterPause = engine.remaining.value - - fakeTimeSource.advanceBy(3_000) - advanceTimeBy(3_100) - - engine.remaining.value shouldBe remainingAfterPause - } + Then("일시정지 중 경과한 시간은 remaining에 포함되지 않는다") { + engine.remaining.value shouldBe remainingAfterPause + engine.dispose() + scope.cancel() } } When("3초가 경과하면") { - Then("remaining이 7초로 감소한다") { - runTest { - val fakeTimeSource = FakeTimeSource() - val engine = makeEngine(FakeTimerSnapshotRepository(), fakeTimeSource, backgroundScope) + val fakeTimeSource = FakeTimeSource() + val scope = CoroutineScope(UnconfinedTestDispatcher(testScheduler)) + val engine = makeEngine(FakeTimerSnapshotRepository(), fakeTimeSource, scope) + engine.start(10.seconds) - engine.start(10.seconds) - fakeTimeSource.advanceBy(3_000) - advanceTimeBy(3_100) + fakeTimeSource.advanceBy(3_000) + testScheduler.advanceTimeBy(3_100) - engine.remaining.value.inWholeSeconds shouldBe 7L - } + Then("remaining이 7초로 감소한다") { + engine.remaining.value.inWholeSeconds shouldBe 7L + engine.dispose() + scope.cancel() } } } Given("0 duration으로 start를 호출하면") { When("start(0ms)를 호출하면") { + val scope = CoroutineScope(UnconfinedTestDispatcher(testScheduler)) + val engine = makeEngine(FakeTimerSnapshotRepository(), FakeTimeSource(), scope) + Then("IllegalArgumentException이 전파된다") { - runTest { - val engine = makeEngine(FakeTimerSnapshotRepository(), FakeTimeSource(), backgroundScope) - shouldThrow { - engine.start(0.milliseconds) - } + shouldThrow { + engine.start(0.milliseconds) } + engine.dispose() + scope.cancel() } } } Given("remaining이 0인 PAUSED 상태의 타이머가 있을 때") { + val repository = FakeTimerSnapshotRepository().apply { + restoredSnapshot = RestoredTimerSnapshot( + duration = 1.minutes, + elapsedMillis = 60_000L, + wasRunning = false, + isCompleted = false, + ) + } + When("resume을 호출하면") { + val scope = CoroutineScope(UnconfinedTestDispatcher(testScheduler)) + val engine = makeEngine(repository, FakeTimeSource(), scope) + engine.restore() // remaining = 0, PAUSED + Then("IllegalArgumentException이 전파된다") { - runTest { - val repository = FakeTimerSnapshotRepository().apply { - restoredSnapshot = RestoredTimerSnapshot( - duration = 1.minutes, - elapsedMillis = 60_000L, - wasRunning = false, - isCompleted = false, - ) - } - val engine = makeEngine(repository, FakeTimeSource(), backgroundScope) - engine.restore() // remaining = 0, PAUSED - shouldThrow { - engine.resume() - } + shouldThrow { + engine.resume() } + engine.dispose() + scope.cancel() } } } Given("RUNNING 상태에서 restore를 호출하면") { + val repository = FakeTimerSnapshotRepository().apply { + restoredSnapshot = RestoredTimerSnapshot( + duration = 1.minutes, + elapsedMillis = 0L, + wasRunning = true, + isCompleted = false, + ) + } + When("restore를 호출하면") { + val scope = CoroutineScope(UnconfinedTestDispatcher(testScheduler)) + val engine = makeEngine(repository, FakeTimeSource(), scope) + engine.start(1.minutes) // RUNNING 상태 + Then("IllegalStateException이 전파된다") { - runTest { - val repository = FakeTimerSnapshotRepository().apply { - restoredSnapshot = RestoredTimerSnapshot( - duration = 1.minutes, - elapsedMillis = 0L, - wasRunning = true, - isCompleted = false, - ) - } - val engine = makeEngine(repository, FakeTimeSource(), backgroundScope) - engine.start(1.minutes) // RUNNING 상태 - shouldThrow { - engine.restore() - } + shouldThrow { + engine.restore() } + engine.dispose() + scope.cancel() } } } Given("wasRunning=false 스냅샷이 존재할 때") { - When("restore를 호출하면") { - Then("PAUSED 상태로 복원된다") { - runTest { - val repository = FakeTimerSnapshotRepository().apply { - restoredSnapshot = RestoredTimerSnapshot( - duration = 25.minutes, - elapsedMillis = 5 * 60 * 1000L, - wasRunning = false, - isCompleted = false, - ) - } - val engine = makeEngine(repository, FakeTimeSource(), backgroundScope) + val repository = FakeTimerSnapshotRepository().apply { + restoredSnapshot = RestoredTimerSnapshot( + duration = 25.minutes, + elapsedMillis = 5 * 60 * 1000L, + wasRunning = false, + isCompleted = false, + ) + } - engine.restore() + When("restore를 호출하면") { + val scope = CoroutineScope(UnconfinedTestDispatcher(testScheduler)) + val engine = makeEngine(repository, FakeTimeSource(), scope) + engine.restore() - engine.timerState.value shouldBe TimerState.PAUSED - } + Then("PAUSED 상태로 복원된다") { + engine.timerState.value shouldBe TimerState.PAUSED + engine.dispose() + scope.cancel() } } } Given("wasRunning=true 스냅샷이 존재할 때") { - When("restore를 호출하면") { - Then("RUNNING 상태로 복원된다") { - runTest { - val repository = FakeTimerSnapshotRepository().apply { - restoredSnapshot = RestoredTimerSnapshot( - duration = 25.minutes, - elapsedMillis = 5 * 60 * 1000L, - wasRunning = true, - isCompleted = false, - ) - } - val engine = makeEngine(repository, FakeTimeSource(), backgroundScope) + val repository = FakeTimerSnapshotRepository().apply { + restoredSnapshot = RestoredTimerSnapshot( + duration = 25.minutes, + elapsedMillis = 5 * 60 * 1000L, + wasRunning = true, + isCompleted = false, + ) + } - engine.restore() + When("restore를 호출하면") { + val scope = CoroutineScope(UnconfinedTestDispatcher(testScheduler)) + val engine = makeEngine(repository, FakeTimeSource(), scope) + engine.restore() - engine.timerState.value shouldBe TimerState.RUNNING - } + Then("RUNNING 상태로 복원된다") { + engine.timerState.value shouldBe TimerState.RUNNING + engine.dispose() + scope.cancel() } } } Given("isCompleted=true 스냅샷이 존재할 때") { - When("restore를 호출하면") { - Then("COMPLETED 상태로 복원된다") { - runTest { - val repository = FakeTimerSnapshotRepository().apply { - restoredSnapshot = RestoredTimerSnapshot( - duration = 25.minutes, - elapsedMillis = 25 * 60 * 1000L, - wasRunning = false, - isCompleted = true, - ) - } - val engine = makeEngine(repository, FakeTimeSource(), backgroundScope) + val repository = FakeTimerSnapshotRepository().apply { + restoredSnapshot = RestoredTimerSnapshot( + duration = 25.minutes, + elapsedMillis = 25 * 60 * 1000L, + wasRunning = false, + isCompleted = true, + ) + } - engine.restore() + When("restore를 호출하면") { + val scope = CoroutineScope(UnconfinedTestDispatcher(testScheduler)) + val engine = makeEngine(repository, FakeTimeSource(), scope) + engine.restore() - engine.timerState.value shouldBe TimerState.COMPLETED - } + Then("COMPLETED 상태로 복원된다") { + engine.timerState.value shouldBe TimerState.COMPLETED + engine.dispose() + scope.cancel() } } } Given("10분 타이머에 3분 경과 스냅샷이 존재할 때") { - When("restore를 호출하면") { - Then("remaining이 7분으로 설정된다") { - runTest { - val repository = FakeTimerSnapshotRepository().apply { - restoredSnapshot = RestoredTimerSnapshot( - duration = 10.minutes, - elapsedMillis = 3 * 60 * 1000L, - wasRunning = false, - isCompleted = false, - ) - } - val engine = makeEngine(repository, FakeTimeSource(), backgroundScope) + val repository = FakeTimerSnapshotRepository().apply { + restoredSnapshot = RestoredTimerSnapshot( + duration = 10.minutes, + elapsedMillis = 3 * 60 * 1000L, + wasRunning = false, + isCompleted = false, + ) + } - engine.restore() + When("restore를 호출하면") { + val scope = CoroutineScope(UnconfinedTestDispatcher(testScheduler)) + val engine = makeEngine(repository, FakeTimeSource(), scope) + engine.restore() - engine.remaining.value shouldBe 7.minutes - } + Then("remaining이 7분으로 설정된다") { + engine.remaining.value shouldBe 7.minutes + engine.dispose() + scope.cancel() } } } diff --git a/app/src/test/java/com/happyseal/zenplayer/features/timer/domain/TimerTestUtils.kt b/app/src/test/java/com/happyseal/zenplayer/features/timer/domain/TimerTestUtils.kt index c546d54e..dad4fd6d 100644 --- a/app/src/test/java/com/happyseal/zenplayer/features/timer/domain/TimerTestUtils.kt +++ b/app/src/test/java/com/happyseal/zenplayer/features/timer/domain/TimerTestUtils.kt @@ -1,6 +1,7 @@ package com.happyseal.zenplayer.features.timer.domain import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestCoroutineScheduler import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.advanceTimeBy import kotlin.time.Duration @@ -24,3 +25,18 @@ fun TestScope.elapse( fakeTimeSource.advanceBy(millis + 100) advanceTimeBy(millis + 200) } + +/** + * TestScope 없이 코루틴 스케줄러를 직접 전달받아 시간을 전진시키는 버전. + * coroutineTestScope = true 환경에서 Given/When 블록 내 타이머 완료 시뮬레이션에 사용한다. + */ +@OptIn(ExperimentalCoroutinesApi::class) +fun elapse( + fakeTimeSource: FakeTimeSource, + duration: Duration, + scheduler: TestCoroutineScheduler, +) { + val millis = duration.inWholeMilliseconds + fakeTimeSource.advanceBy(millis + 100) + scheduler.advanceTimeBy(millis + 200) +} diff --git a/app/src/test/java/com/happyseal/zenplayer/zen/FakeZenPlayerBinder.kt b/app/src/test/java/com/happyseal/zenplayer/zen/FakeZenPlayerBinder.kt index 3fbc6b1f..82ab3535 100644 --- a/app/src/test/java/com/happyseal/zenplayer/zen/FakeZenPlayerBinder.kt +++ b/app/src/test/java/com/happyseal/zenplayer/zen/FakeZenPlayerBinder.kt @@ -8,6 +8,7 @@ import com.happyseal.zenplayer.features.player.domain.PlaybackState import com.happyseal.zenplayer.features.player.domain.PlaylistEvent import com.happyseal.zenplayer.features.player.domain.RepeatMode import com.happyseal.zenplayer.features.timer.domain.PomodoroConfig +import com.happyseal.zenplayer.features.timer.domain.PomodoroSession import com.happyseal.zenplayer.features.timer.domain.PomodoroState import com.happyseal.zenplayer.features.timer.domain.TimerState import kotlinx.coroutines.flow.MutableSharedFlow @@ -53,7 +54,11 @@ class FakeZenPlayerBinder : ) } - override fun skipPomodoro() = Unit + override fun skipPomodoro() { + val current = pomodoroState.value + val nextSession = if (current.session == PomodoroSession.FOCUS) PomodoroSession.BREAK else PomodoroSession.FOCUS + pomodoroState.value = current.copy(session = nextSession, timerState = TimerState.RUNNING) + } override fun pauseAudio() { playbackState.value = PlaybackState.PAUSED diff --git a/app/src/test/java/com/happyseal/zenplayer/zen/MiniPlayerViewModelTest.kt b/app/src/test/java/com/happyseal/zenplayer/zen/MiniPlayerViewModelTest.kt index 47567387..beb2fb88 100644 --- a/app/src/test/java/com/happyseal/zenplayer/zen/MiniPlayerViewModelTest.kt +++ b/app/src/test/java/com/happyseal/zenplayer/zen/MiniPlayerViewModelTest.kt @@ -2,7 +2,7 @@ package com.happyseal.zenplayer.zen import app.cash.turbine.test import com.happyseal.zenplayer.core.model.MiniPlayerAction -import com.happyseal.zenplayer.core.model.Track +import com.happyseal.zenplayer.core.model.testTrack import com.happyseal.zenplayer.features.player.domain.PlayQueue import com.happyseal.zenplayer.features.player.domain.PlaybackState import com.happyseal.zenplayer.zen.miniplayer.MiniPlayerViewModel @@ -24,6 +24,7 @@ class MiniPlayerViewModelTest : val testScheduler = TestCoroutineScheduler() val testDispatcher = StandardTestDispatcher(testScheduler) + beforeTest { Dispatchers.setMain(testDispatcher) } afterTest { Dispatchers.resetMain() } fun makeViewModel(binder: FakeZenPlayerBinder = FakeZenPlayerBinder()): MiniPlayerViewModel { @@ -31,17 +32,8 @@ class MiniPlayerViewModelTest : return MiniPlayerViewModel(connection) } - val fakeTrack = Track( - id = 1L, - fileUri = "content://media/1", - title = "Test Track", - artist = "Artist", - album = "Album", - durationMs = 180_000L, - ) - Given("playQueue가 변경될 때") { - Dispatchers.setMain(testDispatcher) + val fakeTrack = testTrack(id = 1L, title = "Test Track") When("현재 트랙이 있으면") { val binder = FakeZenPlayerBinder() @@ -96,7 +88,7 @@ class MiniPlayerViewModelTest : } Given("PLAYING 상태일 때") { - Dispatchers.setMain(testDispatcher) + val binder = FakeZenPlayerBinder().apply { playbackState.value = PlaybackState.PLAYING } @@ -113,7 +105,7 @@ class MiniPlayerViewModelTest : } Given("PAUSED 상태일 때") { - Dispatchers.setMain(testDispatcher) + val binder = FakeZenPlayerBinder().apply { playbackState.value = PlaybackState.PAUSED } @@ -130,7 +122,7 @@ class MiniPlayerViewModelTest : } Given("ERROR 상태일 때") { - Dispatchers.setMain(testDispatcher) + val binder = FakeZenPlayerBinder().apply { playbackState.value = PlaybackState.ERROR } @@ -147,7 +139,7 @@ class MiniPlayerViewModelTest : } Given("skipNext()를 호출할 때") { - Dispatchers.setMain(testDispatcher) + val binder = FakeZenPlayerBinder() val viewModel = makeViewModel(binder) diff --git a/app/src/test/java/com/happyseal/zenplayer/zen/ZenViewModelTest.kt b/app/src/test/java/com/happyseal/zenplayer/zen/ZenViewModelTest.kt index acc5095a..1497dc6d 100644 --- a/app/src/test/java/com/happyseal/zenplayer/zen/ZenViewModelTest.kt +++ b/app/src/test/java/com/happyseal/zenplayer/zen/ZenViewModelTest.kt @@ -5,6 +5,7 @@ import app.cash.turbine.test import com.happyseal.zenplayer.features.player.domain.PlaybackState import com.happyseal.zenplayer.features.player.domain.RepeatMode import com.happyseal.zenplayer.features.timer.data.FakePomodoroConfigRepository +import com.happyseal.zenplayer.features.timer.domain.PomodoroSession import com.happyseal.zenplayer.features.timer.domain.PomodoroState import com.happyseal.zenplayer.features.timer.domain.TimerState import io.kotest.core.spec.style.BehaviorSpec @@ -25,6 +26,7 @@ class ZenViewModelTest : val testScheduler = TestCoroutineScheduler() val testDispatcher = StandardTestDispatcher(testScheduler) + beforeTest { Dispatchers.setMain(testDispatcher) } afterTest { Dispatchers.resetMain() } fun makeViewModel( @@ -42,7 +44,7 @@ class ZenViewModelTest : } Given("타이머가 멈춰있는(IDLE) 상태") { - Dispatchers.setMain(testDispatcher) + val binder = FakeZenPlayerBinder() val viewModel = makeViewModel(binder) @@ -61,7 +63,7 @@ class ZenViewModelTest : } Given("타이머가 진행 중인(RUNNING) 상태") { - Dispatchers.setMain(testDispatcher) + val binder = FakeZenPlayerBinder().apply { pomodoroState.value = PomodoroState.Idle.copy(timerState = TimerState.RUNNING) } @@ -83,7 +85,7 @@ class ZenViewModelTest : } Given("타이머가 일시정지된(PAUSED) 상태") { - Dispatchers.setMain(testDispatcher) + val binder = FakeZenPlayerBinder().apply { pomodoroState.value = PomodoroState.Idle.copy(timerState = TimerState.PAUSED) } @@ -105,7 +107,7 @@ class ZenViewModelTest : } Given("집중 세션이 완료된(COMPLETED) 상태") { - Dispatchers.setMain(testDispatcher) + val binder = FakeZenPlayerBinder().apply { pomodoroState.value = PomodoroState.Idle.copy(timerState = TimerState.COMPLETED) } @@ -127,7 +129,7 @@ class ZenViewModelTest : } Given("기본 집중 시간 설정 상태") { - Dispatchers.setMain(testDispatcher) + val configRepo = FakePomodoroConfigRepository() val viewModel = makeViewModel(configRepo = configRepo) @@ -146,7 +148,7 @@ class ZenViewModelTest : } Given("반복 없음(NONE) 모드 상태") { - Dispatchers.setMain(testDispatcher) + val binder = FakeZenPlayerBinder() val viewModel = makeViewModel(binder) @@ -165,7 +167,7 @@ class ZenViewModelTest : } Given("오디오가 PLAYING 상태일 때") { - Dispatchers.setMain(testDispatcher) + val binder = FakeZenPlayerBinder().apply { playbackState.value = PlaybackState.PLAYING } @@ -187,7 +189,7 @@ class ZenViewModelTest : } Given("오디오가 PAUSED 상태일 때") { - Dispatchers.setMain(testDispatcher) + val binder = FakeZenPlayerBinder().apply { playbackState.value = PlaybackState.PAUSED } @@ -208,8 +210,32 @@ class ZenViewModelTest : } } + Given("FOCUS 세션이 RUNNING 상태일 때") { + + val binder = FakeZenPlayerBinder().apply { + pomodoroState.value = PomodoroState.Idle.copy(timerState = TimerState.RUNNING) + } + val viewModel = makeViewModel(binder) + + When("PomodoroSkip 액션을 호출하면") { + Then("세션이 BREAK로 전환되고 RUNNING 상태를 유지한다") { + viewModel.uiState.test { + awaitInitialState() + + viewModel.onAction(ZenAction.PomodoroSkip) + testScheduler.advanceUntilIdle() + + val state = awaitItem() + state.session shouldBe PomodoroSession.BREAK + state.timerState shouldBe TimerState.RUNNING + cancelAndIgnoreRemainingEvents() + } + } + } + } + Given("오디오가 ERROR 상태일 때") { - Dispatchers.setMain(testDispatcher) + val binder = FakeZenPlayerBinder().apply { playbackState.value = PlaybackState.ERROR } diff --git a/app/src/test/java/com/happyseal/zenplayer/zen/musicselect/ZenMusicSelectViewModelTest.kt b/app/src/test/java/com/happyseal/zenplayer/zen/musicselect/ZenMusicSelectViewModelTest.kt index b6f22345..27e9d5c5 100644 --- a/app/src/test/java/com/happyseal/zenplayer/zen/musicselect/ZenMusicSelectViewModelTest.kt +++ b/app/src/test/java/com/happyseal/zenplayer/zen/musicselect/ZenMusicSelectViewModelTest.kt @@ -2,6 +2,7 @@ package com.happyseal.zenplayer.zen.musicselect import app.cash.turbine.test import com.happyseal.zenplayer.core.model.Track +import com.happyseal.zenplayer.core.model.testTrack import com.happyseal.zenplayer.features.music.data.FakeMusicRepository import com.happyseal.zenplayer.features.music.domain.AudioPermissionStatus import com.happyseal.zenplayer.features.music.ui.AudioPermissionHandler @@ -10,7 +11,6 @@ import com.happyseal.zenplayer.features.playlist.data.FakePlaylistRepository import com.happyseal.zenplayer.features.playlist.ui.PlaylistListUiState import com.happyseal.zenplayer.zen.FakeZenPlayerBinder import com.happyseal.zenplayer.zen.FakeZenPlayerServiceConnection -import io.kotest.assertions.fail import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.BehaviorSpec import io.kotest.matchers.collections.shouldHaveSize @@ -31,22 +31,9 @@ class ZenMusicSelectViewModelTest : BehaviorSpec({ val testScheduler = TestCoroutineScheduler() val testDispatcher = StandardTestDispatcher(testScheduler) + beforeTest { Dispatchers.setMain(testDispatcher) } afterTest { Dispatchers.resetMain() } - fun makeTrack( - id: Long = 1L, - title: String = "Track $id", - folder: String = "", - ) = Track( - id = id, - fileUri = "uri://$id", - title = title, - artist = "Artist", - album = "Album", - durationMs = 180_000L, - folder = folder, - ) - fun makeViewModel( tracks: List = emptyList(), throwOnGetAll: Throwable? = null, @@ -71,8 +58,8 @@ class ZenMusicSelectViewModelTest : BehaviorSpec({ // ───────────────────────────────────────── Given("권한이 허용되지 않은 초기 상태일 때") { - Dispatchers.setMain(testDispatcher) - val tracks = listOf(makeTrack(1L), makeTrack(2L)) + + val tracks = listOf(testTrack(id = 1L), testTrack(id = 2L)) When("권한 허용 액션을 전달하면") { Then("트랙 목록이 uiState에 반영된다") { @@ -102,7 +89,7 @@ class ZenMusicSelectViewModelTest : BehaviorSpec({ } Given("권한이 허용되지 않은 초기 상태에서 권한 거부가 발생하면") { - Dispatchers.setMain(testDispatcher) + val viewModel = makeViewModel() When("권한 거부 액션을 전달하면") { @@ -119,7 +106,7 @@ class ZenMusicSelectViewModelTest : BehaviorSpec({ } Given("트랙 로드가 실패하는 환경일 때") { - Dispatchers.setMain(testDispatcher) + val error = RuntimeException("로드 실패") val viewModel = makeViewModel(throwOnGetAll = error) @@ -137,9 +124,9 @@ class ZenMusicSelectViewModelTest : BehaviorSpec({ } Given("트랙 로드가 한 번 실패한 상태일 때") { - Dispatchers.setMain(testDispatcher) + var shouldFail = true - val tracks = listOf(makeTrack(1L)) + val tracks = listOf(testTrack(id = 1L)) val repo = object : com.happyseal.zenplayer.features.music.domain.MusicRepository { override val isFallback = kotlinx.coroutines.flow.flowOf(false) override suspend fun getAllTracks() = if (shouldFail) Result.failure(RuntimeException("실패")) else Result.success(tracks) @@ -162,16 +149,8 @@ class ZenMusicSelectViewModelTest : BehaviorSpec({ shouldFail = false viewModel.onAction(ZenMusicSelectAction.RetryLoadTracks) testScheduler.advanceUntilIdle() - // Retry 이후 Loading → Success 등 여러 번 상태가 갱신될 수 있으므로 - // 기대하는 최종 상태(트랙 1개)가 나올 때까지 대기한다. var finalState = awaitItem() - var retryCount = 0 - val maxRetries = 10 - while (finalState.musicListUiState.tracks.size != 1) { - retryCount++ - if (retryCount >= maxRetries) { - fail("최대 재시도 횟수($maxRetries)를 초과했습니다. 현재 트랙 수: ${finalState.musicListUiState.tracks.size}") - } + if (finalState.musicListUiState.trackLoadState is TrackLoadState.Loading) { finalState = awaitItem() } finalState.musicListUiState.tracks shouldHaveSize 1 @@ -182,11 +161,11 @@ class ZenMusicSelectViewModelTest : BehaviorSpec({ } Given("두 폴더에 걸쳐 트랙이 있을 때") { - Dispatchers.setMain(testDispatcher) + val tracks = listOf( - makeTrack(1L, folder = "Jazz"), - makeTrack(2L, folder = "Jazz"), - makeTrack(3L, folder = "Pop"), + testTrack(id = 1L, folder = "Jazz"), + testTrack(id = 2L, folder = "Jazz"), + testTrack(id = 3L, folder = "Pop"), ) val viewModel = makeViewModel(tracks = tracks) @@ -207,8 +186,8 @@ class ZenMusicSelectViewModelTest : BehaviorSpec({ } Given("트랙 목록이 로드된 상태이고 binder가 연결된 상태일 때") { - Dispatchers.setMain(testDispatcher) - val tracks = listOf(makeTrack(1L), makeTrack(2L), makeTrack(3L)) + + val tracks = listOf(testTrack(id = 1L), testTrack(id = 2L), testTrack(id = 3L)) val binder = FakeZenPlayerBinder() val viewModel = makeViewModel(tracks = tracks, binder = binder) @@ -233,7 +212,7 @@ class ZenMusicSelectViewModelTest : BehaviorSpec({ // ───────────────────────────────────────── Given("플레이리스트가 없는 상태일 때") { - Dispatchers.setMain(testDispatcher) + val viewModel = makeViewModel() When("플레이리스트 생성에 성공하면") { @@ -250,7 +229,7 @@ class ZenMusicSelectViewModelTest : BehaviorSpec({ } Given("플레이리스트 생성이 실패하는 환경일 때") { - Dispatchers.setMain(testDispatcher) + val playlistRepo = FakePlaylistRepository(throwOnCreate = RuntimeException("DB 오류")) val viewModel = makeViewModel(playlistRepo = playlistRepo) @@ -267,7 +246,7 @@ class ZenMusicSelectViewModelTest : BehaviorSpec({ } Given("플레이리스트가 하나 있을 때") { - Dispatchers.setMain(testDispatcher) + val playlistRepo = FakePlaylistRepository() val viewModel = makeViewModel(playlistRepo = playlistRepo) @@ -291,7 +270,7 @@ class ZenMusicSelectViewModelTest : BehaviorSpec({ } Given("플레이리스트 삭제가 실패하는 환경일 때") { - Dispatchers.setMain(testDispatcher) + val viewModel = makeViewModel( playlistRepo = FakePlaylistRepository(throwOnDelete = RuntimeException("삭제 실패")), ) @@ -309,7 +288,7 @@ class ZenMusicSelectViewModelTest : BehaviorSpec({ } Given("플레이리스트 이름 변경이 실패하는 환경일 때") { - Dispatchers.setMain(testDispatcher) + val viewModel = makeViewModel( playlistRepo = FakePlaylistRepository(throwOnRename = RuntimeException("이름 변경 실패")), ) @@ -327,14 +306,14 @@ class ZenMusicSelectViewModelTest : BehaviorSpec({ } Given("플레이리스트에 트랙을 추가할 수 있는 상태일 때") { - Dispatchers.setMain(testDispatcher) + val playlistRepo = FakePlaylistRepository() playlistRepo.createPlaylist("Jazz") val viewModel = makeViewModel(playlistRepo = playlistRepo) When("트랙 3개를 모두 성공적으로 추가하면") { Then("AddTrackSuccess 이벤트가 발행된다") { - val tracks = listOf(makeTrack(1L), makeTrack(2L), makeTrack(3L)) + val tracks = listOf(testTrack(id = 1L), testTrack(id = 2L), testTrack(id = 3L)) viewModel.uiEvent.test { viewModel.onAction(ZenMusicSelectAction.AddTracksToPlaylist(playlistId = 1L, tracks = tracks)) testScheduler.advanceUntilIdle() @@ -348,7 +327,7 @@ class ZenMusicSelectViewModelTest : BehaviorSpec({ } Given("트랙 추가가 두 번째부터 실패하는 환경일 때") { - Dispatchers.setMain(testDispatcher) + val playlistRepo = FakePlaylistRepository( throwOnAddTrack = RuntimeException("추가 실패"), addTrackFailAfter = 1, @@ -358,7 +337,7 @@ class ZenMusicSelectViewModelTest : BehaviorSpec({ When("트랙 3개를 추가하면") { Then("AddTrackPartialFailed 이벤트가 발행된다") { - val tracks = listOf(makeTrack(1L), makeTrack(2L), makeTrack(3L)) + val tracks = listOf(testTrack(id = 1L), testTrack(id = 2L), testTrack(id = 3L)) viewModel.uiEvent.test { viewModel.onAction(ZenMusicSelectAction.AddTracksToPlaylist(playlistId = 1L, tracks = tracks)) testScheduler.advanceUntilIdle() @@ -376,7 +355,7 @@ class ZenMusicSelectViewModelTest : BehaviorSpec({ // ───────────────────────────────────────── Given("트랙 목록이 비어있는 상태일 때") { - Dispatchers.setMain(testDispatcher) + val binder = FakeZenPlayerBinder() val viewModel = makeViewModel(tracks = emptyList(), binder = binder) @@ -397,8 +376,8 @@ class ZenMusicSelectViewModelTest : BehaviorSpec({ } Given("트랙이 3개 있는 상태일 때") { - Dispatchers.setMain(testDispatcher) - val tracks = listOf(makeTrack(1L), makeTrack(2L), makeTrack(3L)) + + val tracks = listOf(testTrack(id = 1L), testTrack(id = 2L), testTrack(id = 3L)) val binder = FakeZenPlayerBinder() val viewModel = makeViewModel(tracks = tracks, binder = binder) @@ -419,7 +398,7 @@ class ZenMusicSelectViewModelTest : BehaviorSpec({ } Given("플레이리스트가 있는 상태일 때") { - Dispatchers.setMain(testDispatcher) + val viewModel = makeViewModel() When("빈 이름으로 CreatePlaylist 액션을 전달하면") { diff --git a/docs/testing.md b/docs/testing.md index d43917ca..2dbc61ca 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -15,6 +15,7 @@ - Compose UI 렌더링 세부사항 - 단순 getter/setter - 라이브러리 코드 동작 +- `private` 필드·메서드 (공개 인터페이스를 통한 동작으로 간접 검증) --- @@ -55,14 +56,17 @@ PlaylistSliceTest.kt Kotest **BehaviorSpec**을 사용한다. `Given` / `When` / `Then` 은 **대문자**로 시작한다. ```kotlin -class TimerEngineTest : BehaviorSpec({ +class FooTest : BehaviorSpec({ Given("RUNNING 상태의 타이머가 있을 때") { + val foo = Foo() + foo.start() + When("pause를 호출하면") { + foo.pause() + Then("PAUSED 상태로 전환된다") { - runTest { - // ... - } + foo.state shouldBe State.PAUSED } } } @@ -81,6 +85,15 @@ xGiven("...") { ... } - `When`: 실행하는 동작을 서술한다. - `Then`: 기대 결과를 서술한다. +### 코드 배치 규칙 + +- `Given` — 사전 데이터·상태 준비 (Fake, 초기값 등) +- `When` — 객체 생성 + 액션 호출 (+ ViewModel 테스트는 `testScheduler.advanceUntilIdle()`) +- `Then` — 상태 검증만 (비동기 호출 없음) +- `Channel`/`SharedFlow` 검증은 `Then` 안에서 Turbine `test { }` 사용 + +> **주의:** `coroutineTestScope = true` 환경에서 `advanceUntilIdle()`, `advanceTimeBy()`, `backgroundScope` 등 `TestScope` 확장은 **`Then` 블록에서만** 사용 가능하다. `When` 블록에서는 `TestScope` 수신자가 없으므로 호출하면 컴파일 에러가 발생한다. + ### 검증 대상 행동(behavior)을 검증한다. 내부 구현 세부사항(private 필드, 메서드 호출 순서)은 검증하지 않는다. @@ -101,6 +114,31 @@ fun `_loopCount가 1이 된다`() { } ``` +### 코루틴 테스트 + +코루틴을 사용하는 테스트 클래스는 `coroutineTestScope = true`를 BehaviorSpec 최상단에 선언한다. +이렇게 하면 `Given`/`When`/`Then` 블록에서 `suspend` 함수를 직접 호출할 수 있어 `runTest` 래핑이 필요 없다. + +`scope: CoroutineScope`를 생성자로 받는 클래스는 `When` 블록 안에서 `CoroutineScope(UnconfinedTestDispatcher())`를 직접 생성해 전달한다. `UnconfinedTestDispatcher`를 사용하면 코루틴이 즉시 실행되므로 별도의 `advanceUntilIdle()` 없이 `.value`로 결과를 읽을 수 있다. + +`TimerEngine`/`PomodoroManager`처럼 내부에서 장시간 실행되는 actor/collector 코루틴을 띄우는 타입은, 테스트 종료 후 반드시 `dispose()`와 `scope.cancel()`을 호출해 리소스를 해제해야 한다. 해제하지 않으면 코루틴이 테스트 이후에도 살아있어 `testScheduler`를 공유하는 다른 테스트에 영향을 줄 수 있다. + +```kotlin +When("...") { + val scope = CoroutineScope(UnconfinedTestDispatcher(testScheduler)) + val engine = TimerEngine(..., scope = scope) + engine.start(1.minutes) + + Then("...") { + engine.timerState.value shouldBe TimerState.RUNNING + engine.dispose() // actor 채널 닫기 + scope.cancel() // 남은 코루틴 정리 + } +} +``` + +시간 제어(`advanceTimeBy`, `advanceUntilIdle`)가 필요한 경우에는 `UnconfinedTestDispatcher(testScheduler)`처럼 `TestCoroutineScheduler`를 명시적으로 전달한다. 하나의 `testScheduler`를 여러 테스트에서 공유하면 시간 흐름이 누적되어 의도치 않은 동작이 생길 수 있으므로 주의한다. + ### 커버리지 기준 모든 코드를 커버하는 것이 목표가 아니다. **핵심 흐름과 경계 조건**이 커버되는지를 기준으로 판단한다. @@ -140,7 +178,10 @@ class FakePlaylistRepository( ### Mockk 사용 기준 -다음 경우에만 선택적으로 사용한다: +Mockk 사용은 최대한 피한다. 근본적인 대안은 **인프라 의존성을 인터페이스 뒤로 숨기는 것**이다. +`Context`나 `ExoPlayer`를 직접 주입받는 대신 그 기능을 추상화한 인터페이스를 만들고 Fake로 구현하면 Mockk 없이도 테스트할 수 있다. + +Fake로 대체할 수 없는 경우에 한해 최후 수단으로 사용한다: - Android 시스템 클래스 (`Context`, `ContentResolver`) - 외부 라이브러리 구체 클래스 (`ExoPlayer`, `MediaSession`) @@ -213,57 +254,38 @@ viewModel.uiState.test { ### 도메인 로직 테스트 -코루틴을 사용하는 테스트 클래스는 `coroutineTestScope = true`를 BehaviorSpec 최상단에 선언한다. -이렇게 하면 `Given`/`When`/`Then` 블록에서 `suspend` 함수를 직접 호출할 수 있어 `runTest` 래핑이 필요 없다. - -`scope: CoroutineScope`를 생성자로 받는 클래스는 `coroutineScope`를 전달한다. +`scope: CoroutineScope`를 주입받는 도메인 클래스는 `When` 블록에서 `CoroutineScope(UnconfinedTestDispatcher())`를 생성해 전달한다. `UnconfinedTestDispatcher`는 코루틴을 즉시 실행하므로 `advanceUntilIdle()` 없이 결과를 즉시 읽을 수 있다. ```kotlin -// test/.../features/timer/domain/PomodoroManagerTest.kt -class PomodoroManagerTest : BehaviorSpec({ +class FooManagerTest : BehaviorSpec({ coroutineTestScope = true - fun makeManager(scope: CoroutineScope) = PomodoroManager( - timerEngine = TimerEngine( - repository = FakeTimerSnapshotRepository(), - timeSource = FakeTimeSource(), - scope = scope, - ), - repository = FakePomodoroSnapshotRepository(), + fun makeManager(scope: CoroutineScope) = FooManager( + repository = FakeFooRepository(), scope = scope, ) - Given("IDLE 상태의 PomodoroManager가 있을 때") { + Given("IDLE 상태일 때") { When("start()를 호출하면") { - Then("timerState가 RUNNING으로 전환된다") { - val manager = makeManager(scope = coroutineScope) - manager.start() - advanceUntilIdle() + val manager = makeManager(scope = CoroutineScope(UnconfinedTestDispatcher())) + manager.start() - manager.pomodoroState.value.timerState shouldBe TimerState.RUNNING + Then("RUNNING 상태로 전환된다") { + manager.state.value shouldBe State.RUNNING } } } -}) -``` - -`scope`를 생성자로 받지 않는 Manager/도메인 클래스는 그냥 인스턴스를 생성하고 코루틴 함수를 직접 호출한다. - -```kotlin -class PlayQueueManagerTest : BehaviorSpec({ - coroutineTestScope = true - - Given("3곡이 담긴 플레이리스트가 설정된 상태") { - val tracks = makeTracks(3) + Given("스냅샷(session=BREAK)이 저장되어 있을 때") { + val repo = FakeFooRepository().apply { snapshot = Snapshot(session = BREAK) } - When("첫 번째 곡부터 재생을 시작하면") { - val manager = PlayQueueManager(FakeAudioController()) - manager.setPlayQueue(tracks, startIndex = 0) + When("restore()를 호출하면") { + val manager = makeManager(scope = CoroutineScope(UnconfinedTestDispatcher())) + manager.restore() - Then("첫 번째 곡이 준비된다") { - manager.playQueue.value?.currentIndex shouldBe 0 + Then("session이 BREAK로 복원된다") { + manager.state.value.session shouldBe BREAK } } } @@ -273,7 +295,6 @@ class PlayQueueManagerTest : BehaviorSpec({ ### ViewModel 테스트 (FakeRepository + coroutineTestScope) `viewModelScope`는 `Dispatchers.Main`을 사용하므로 `setMain`이 필요하다. -`coroutineTestScope = true`를 Spec에 설정하면 `Given`/`When`/`Then` 블록에서 `suspend` 함수를 직접 호출할 수 있어 `runTest` 래핑이 필요 없다. `Dispatchers.setMain`에는 반드시 `StandardTestDispatcher(testScheduler)`를 넘긴다. `coroutineContext[CoroutineDispatcher]`를 직접 넘기면 Kotest 내부 디스패처가 등록되어 무한 루프 위험이 있다. ([참고](https://github.com/kotest/kotest/issues/3932)) @@ -308,11 +329,7 @@ class PlaylistListViewModelTest : BehaviorSpec({ }) ``` -**코드 배치 규칙:** -- `Given` — `setMain` + ViewModel/Repo 생성 + 사전 상태 준비 + `testScheduler.advanceUntilIdle()` -- `When` — 검증 대상 액션 호출 + `testScheduler.advanceUntilIdle()` -- `Then` — 상태 검증만 (비동기 호출 없음) -- `Channel`/`SharedFlow` 검증은 `Then` 안에서 Turbine `test { }` 사용 +**ViewModel 특이사항:** `Given`에서 `setMain` + ViewModel 생성, `When`에서 `testScheduler.advanceUntilIdle()` 사용. `testScheduler.advanceUntilIdle()`은 `TestScope` 확장이 아니므로 `Given`/`When` 블록에서도 호출 가능하다. ### E2E 테스트 — 주요 사용자 흐름만 (androidTest)