From 794d1e455316725cf9101ece11137f4a0e1fed7e Mon Sep 17 00:00:00 2001 From: gagip Date: Fri, 27 Mar 2026 12:30:06 +0900 Subject: [PATCH 01/12] =?UTF-8?q?docs:=20testing.md=20=EB=82=B4=EC=9A=A9?= =?UTF-8?q?=20=EB=B6=88=EC=9D=BC=EC=B9=98=20=EB=B0=8F=20=EC=A4=91=EB=B3=B5?= =?UTF-8?q?=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - BehaviorSpec 예시에서 runTest 제거, coroutineTestScope 패턴으로 통일 - Given/When/Then 코드 배치 규칙을 테스트 작성 규칙 섹션으로 통합 - 도메인/ViewModel 계층별 중복 배치 규칙 제거, 특이사항만 잔류 - 코루틴 테스트 항목 신설 (coroutineTestScope, backgroundScope 규칙) - private 검증 금지 항목을 테스트 기준점에 추가 - Mockk 사용 기준에 인터페이스 추상화 대안 명시 - 예시 코드를 실전 클래스 대신 가상 예제(FooTest)로 교체 --- docs/testing.md | 91 ++++++++++++++++++++++++------------------------- 1 file changed, 44 insertions(+), 47 deletions(-) diff --git a/docs/testing.md b/docs/testing.md index d43917ca..4cb78883 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,13 @@ xGiven("...") { ... } - `When`: 실행하는 동작을 서술한다. - `Then`: 기대 결과를 서술한다. +### 코드 배치 규칙 + +- `Given` — 사전 데이터·상태 준비 (Fake, 초기값 등) +- `When` — 객체 생성 + 액션 호출 + `advanceUntilIdle()` +- `Then` — 상태 검증만 (비동기 호출 없음) +- `Channel`/`SharedFlow` 검증은 `Then` 안에서 Turbine `test { }` 사용 + ### 검증 대상 행동(behavior)을 검증한다. 내부 구현 세부사항(private 필드, 메서드 호출 순서)은 검증하지 않는다. @@ -101,6 +112,13 @@ fun `_loopCount가 1이 된다`() { } ``` +### 코루틴 테스트 + +코루틴을 사용하는 테스트 클래스는 `coroutineTestScope = true`를 BehaviorSpec 최상단에 선언한다. +이렇게 하면 `Given`/`When`/`Then` 블록에서 `suspend` 함수를 직접 호출할 수 있어 `runTest` 래핑이 필요 없다. + +`scope: CoroutineScope`를 생성자로 받는 클래스는 `backgroundScope`를 전달한다. + ### 커버리지 기준 모든 코드를 커버하는 것이 목표가 아니다. **핵심 흐름과 경계 조건**이 커버되는지를 기준으로 판단한다. @@ -140,7 +158,10 @@ class FakePlaylistRepository( ### Mockk 사용 기준 -다음 경우에만 선택적으로 사용한다: +Mockk 사용은 최대한 피한다. 근본적인 대안은 **인프라 의존성을 인터페이스 뒤로 숨기는 것**이다. +`Context`나 `ExoPlayer`를 직접 주입받는 대신 그 기능을 추상화한 인터페이스를 만들고 Fake로 구현하면 Mockk 없이도 테스트할 수 있다. + +Fake로 대체할 수 없는 경우에 한해 최후 수단으로 사용한다: - Android 시스템 클래스 (`Context`, `ContentResolver`) - 외부 라이브러리 구체 클래스 (`ExoPlayer`, `MediaSession`) @@ -213,57 +234,38 @@ viewModel.uiState.test { ### 도메인 로직 테스트 -코루틴을 사용하는 테스트 클래스는 `coroutineTestScope = true`를 BehaviorSpec 최상단에 선언한다. -이렇게 하면 `Given`/`When`/`Then` 블록에서 `suspend` 함수를 직접 호출할 수 있어 `runTest` 래핑이 필요 없다. - -`scope: CoroutineScope`를 생성자로 받는 클래스는 `coroutineScope`를 전달한다. - ```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 = backgroundScope) + manager.start() + advanceUntilIdle() - manager.pomodoroState.value.timerState shouldBe TimerState.RUNNING + Then("RUNNING 상태로 전환된다") { + manager.state.value shouldBe State.RUNNING } } } -}) -``` - -`scope`를 생성자로 받지 않는 Manager/도메인 클래스는 그냥 인스턴스를 생성하고 코루틴 함수를 직접 호출한다. -```kotlin -class PlayQueueManagerTest : BehaviorSpec({ - - coroutineTestScope = true + Given("스냅샷(session=BREAK)이 저장되어 있을 때") { + val repo = FakeFooRepository().apply { snapshot = Snapshot(session = BREAK) } - Given("3곡이 담긴 플레이리스트가 설정된 상태") { - val tracks = makeTracks(3) + When("restore()를 호출하면") { + val manager = makeManager(scope = backgroundScope) // repo 주입 + manager.restore() + advanceUntilIdle() - When("첫 번째 곡부터 재생을 시작하면") { - val manager = PlayQueueManager(FakeAudioController()) - manager.setPlayQueue(tracks, startIndex = 0) - - Then("첫 번째 곡이 준비된다") { - manager.playQueue.value?.currentIndex shouldBe 0 + Then("session이 BREAK로 복원된다") { + manager.state.value.session shouldBe BREAK } } } @@ -273,7 +275,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 +309,7 @@ class PlaylistListViewModelTest : BehaviorSpec({ }) ``` -**코드 배치 규칙:** -- `Given` — `setMain` + ViewModel/Repo 생성 + 사전 상태 준비 + `testScheduler.advanceUntilIdle()` -- `When` — 검증 대상 액션 호출 + `testScheduler.advanceUntilIdle()` -- `Then` — 상태 검증만 (비동기 호출 없음) -- `Channel`/`SharedFlow` 검증은 `Then` 안에서 Turbine `test { }` 사용 +**ViewModel 특이사항:** `Given`에서 `setMain` + ViewModel 생성, `When`/`Given`의 `advanceUntilIdle()` 대신 `testScheduler.advanceUntilIdle()` 사용 ### E2E 테스트 — 주요 사용자 흐름만 (androidTest) From 60251d2103e82907144832efc71ab254d551a3bc Mon Sep 17 00:00:00 2001 From: gagip Date: Fri, 27 Mar 2026 13:07:33 +0900 Subject: [PATCH 02/12] =?UTF-8?q?refactor:=20coroutineTestScope=20?= =?UTF-8?q?=ED=8C=A8=ED=84=B4=EC=9C=BC=EB=A1=9C=20=ED=86=B5=EC=9D=BC=20(Po?= =?UTF-8?q?modoroManager,=20TimerEngine,=20FallbackAwareMusicRepository)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - runTest {} 래핑 제거, coroutineTestScope = true 선언으로 통일 - scope 주입 클래스는 CoroutineScope(UnconfinedTestDispatcher(testScheduler)) 사용 - 코루틴 시간 조작은 testScheduler.advanceTimeBy() 사용 - TimerTestUtils에 TestScope 없이 사용 가능한 elapse() 오버로드 추가 - Given/When/Then 배치 규칙 준수 (Given: 준비, When: 객체+액션, Then: 검증) - docs/testing.md 코루틴 테스트 섹션을 실제 Kotest 동작에 맞게 수정 --- .../data/FallbackAwareMusicRepositoryTest.kt | 114 +++--- .../timer/domain/PomodoroManagerTest.kt | 353 ++++++++---------- .../features/timer/domain/TimerEngineTest.kt | 315 +++++++--------- .../features/timer/domain/TimerTestUtils.kt | 16 + docs/testing.md | 16 +- 5 files changed, 377 insertions(+), 437 deletions(-) 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..861211d6 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 @@ -4,10 +4,10 @@ import app.cash.turbine.test import com.happyseal.zenplayer.core.model.Track 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, @@ -22,100 +22,90 @@ class FallbackAwareMusicRepositoryTest : val sampleTracks = listOf(makeTrack(101), makeTrack(102)) Given("기기 음원에 정상 접근 가능한 상태") { + val repo = FallbackAwareMusicRepository( + primary = FakeMusicRepository(tracks = deviceTracks), + fallback = FakeMusicRepository(tracks = sampleTracks), + ) + When("음악 목록을 불러오면") { + val result = repo.getAllTracks() + 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() - } + result.getOrNull() shouldBe deviceTracks + 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 + 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 + 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/timer/domain/PomodoroManagerTest.kt b/app/src/test/java/com/happyseal/zenplayer/features/timer/domain/PomodoroManagerTest.kt index fd7cf8a9..5cf0a2b0 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,15 @@ 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.test.TestCoroutineScheduler +import kotlinx.coroutines.test.UnconfinedTestDispatcher @OptIn(ExperimentalCoroutinesApi::class) class PomodoroManagerTest : BehaviorSpec({ + coroutineTestScope = true + + val testScheduler = TestCoroutineScheduler() fun makeManager( pomodoroRepo: FakePomodoroSnapshotRepository, @@ -34,268 +37,228 @@ class PomodoroManagerTest : Given("IDLE 상태의 PomodoroManager가 있을 때") { When("start()를 호출하면") { - Then("timerState가 RUNNING으로 전환된다") { - runTest { - val manager = makeManager( - FakePomodoroSnapshotRepository(), - FakeTimerSnapshotRepository(), - FakeTimeSource(), - backgroundScope, - ) - - manager.start() - advanceUntilIdle() + val manager = makeManager( + FakePomodoroSnapshotRepository(), + FakeTimerSnapshotRepository(), + FakeTimeSource(), + CoroutineScope(UnconfinedTestDispatcher(testScheduler)), + ) + manager.start() - manager.pomodoroState.value.timerState shouldBe TimerState.RUNNING - } + Then("timerState가 RUNNING으로 전환된다") { + manager.pomodoroState.value.timerState shouldBe TimerState.RUNNING } } } Given("RUNNING 상태의 PomodoroManager가 있을 때") { When("pause()를 호출하면") { - Then("timerState가 PAUSED로 전환된다") { - runTest { - val manager = makeManager( - FakePomodoroSnapshotRepository(), - FakeTimerSnapshotRepository(), - FakeTimeSource(), - backgroundScope, - ) - manager.start() + val manager = makeManager( + FakePomodoroSnapshotRepository(), + FakeTimerSnapshotRepository(), + FakeTimeSource(), + CoroutineScope(UnconfinedTestDispatcher(testScheduler)), + ) + manager.start() + manager.pause() - manager.pause() - advanceUntilIdle() - - manager.pomodoroState.value.timerState shouldBe TimerState.PAUSED - } + Then("timerState가 PAUSED로 전환된다") { + manager.pomodoroState.value.timerState shouldBe TimerState.PAUSED } } When("reset()을 호출하면") { - Then("timerState가 IDLE, session이 FOCUS, loopCount가 0으로 초기화된다") { - runTest { - val manager = makeManager( - FakePomodoroSnapshotRepository(), - FakeTimerSnapshotRepository(), - FakeTimeSource(), - backgroundScope, - ) - manager.start() - - manager.reset() - advanceUntilIdle() + val manager = makeManager( + FakePomodoroSnapshotRepository(), + FakeTimerSnapshotRepository(), + FakeTimeSource(), + CoroutineScope(UnconfinedTestDispatcher(testScheduler)), + ) + manager.start() + manager.reset() - manager.pomodoroState.value.timerState shouldBe TimerState.IDLE - manager.pomodoroState.value.session shouldBe PomodoroSession.FOCUS - manager.pomodoroState.value.loopCount shouldBe 0 - } + Then("timerState가 IDLE, session이 FOCUS, loopCount가 0으로 초기화된다") { + manager.pomodoroState.value.timerState shouldBe TimerState.IDLE + manager.pomodoroState.value.session shouldBe PomodoroSession.FOCUS + manager.pomodoroState.value.loopCount shouldBe 0 } } } Given("PAUSED 상태의 PomodoroManager가 있을 때") { When("resume()을 호출하면") { - Then("timerState가 RUNNING으로 전환된다") { - runTest { - val manager = makeManager( - FakePomodoroSnapshotRepository(), - FakeTimerSnapshotRepository(), - FakeTimeSource(), - backgroundScope, - ) - manager.start() - manager.pause() - - manager.resume() - advanceUntilIdle() + val manager = makeManager( + FakePomodoroSnapshotRepository(), + FakeTimerSnapshotRepository(), + FakeTimeSource(), + CoroutineScope(UnconfinedTestDispatcher(testScheduler)), + ) + manager.start() + manager.pause() + manager.resume() - manager.pomodoroState.value.timerState shouldBe TimerState.RUNNING - } + Then("timerState가 RUNNING으로 전환된다") { + manager.pomodoroState.value.timerState shouldBe TimerState.RUNNING } } } Given("loopsPerRound=4, 1번째 FOCUS 세션이 RUNNING 중일 때") { + val fakeTimeSource1 = FakeTimeSource() + val manager1 = makeManager( + FakePomodoroSnapshotRepository(), + FakeTimerSnapshotRepository(), + fakeTimeSource1, + CoroutineScope(UnconfinedTestDispatcher(testScheduler)), + ) + manager1.start() + When("타이머가 완료되면") { 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 - } + elapse(fakeTimeSource1, PomodoroConfig.Default.focusDuration, testScheduler) + manager1.pomodoroState.value.session shouldBe PomodoroSession.BREAK + manager1.pomodoroState.value.loopCount shouldBe 0 } } } Given("loopsPerRound=4, 1번째 BREAK 세션이 RUNNING 중일 때") { + val fakeTimeSource2 = FakeTimeSource() + val manager2 = makeManager( + FakePomodoroSnapshotRepository(), + FakeTimerSnapshotRepository(), + fakeTimeSource2, + CoroutineScope(UnconfinedTestDispatcher(testScheduler)), + ) + manager2.start() + manager2.skip() // FOCUS → BREAK (loopCount 0 유지) + When("타이머가 완료되면") { 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 - } + elapse(fakeTimeSource2, PomodoroConfig.Default.breakDuration, testScheduler) + manager2.pomodoroState.value.session shouldBe PomodoroSession.FOCUS + manager2.pomodoroState.value.loopCount shouldBe 1 } } } Given("loopsPerRound=2, 마지막(2번째) FOCUS 세션이 RUNNING 중일 때") { + val fakeTimeSource3 = FakeTimeSource() + val configRepo = FakePomodoroConfigRepository( + PomodoroConfig( + focusDuration = PomodoroConfig.Default.focusDuration, + breakDuration = PomodoroConfig.Default.breakDuration, + loopsPerRound = 2, + ), + ) + val manager3 = makeManager( + FakePomodoroSnapshotRepository(), + FakeTimerSnapshotRepository(), + fakeTimeSource3, + CoroutineScope(UnconfinedTestDispatcher(testScheduler)), + configRepo, + ) + manager3.start() + manager3.skip() // FOCUS(loopCount=0) → BREAK(loopCount=0 유지) + manager3.skip() // BREAK → FOCUS (loopCount=1) + When("타이머가 완료되면") { 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 - } + // 2번째 FOCUS 완료: loopCount(1)+1 == loopsPerRound(2) → IDLE 정지 + elapse(fakeTimeSource3, PomodoroConfig.Default.focusDuration, testScheduler) + manager3.pomodoroState.value.timerState shouldBe TimerState.IDLE + manager3.pomodoroState.value.session shouldBe PomodoroSession.FOCUS + manager3.pomodoroState.value.loopCount shouldBe 0 } } } Given("loopsPerRound=4, BREAK 세션이 RUNNING 중일 때") { + val fakeTimeSource4 = FakeTimeSource() + val manager4 = makeManager( + FakePomodoroSnapshotRepository(), + FakeTimerSnapshotRepository(), + fakeTimeSource4, + CoroutineScope(UnconfinedTestDispatcher(testScheduler)), + ) + // FOCUS → skip → BREAK 상태로 이동 (skip은 자동으로 다음 세션 start) + manager4.start() + manager4.skip() // BREAK로 이동, 자동 start + When("타이머가 완료되면") { 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 - } + elapse(fakeTimeSource4, PomodoroConfig.Default.breakDuration, testScheduler) + manager4.pomodoroState.value.session shouldBe PomodoroSession.FOCUS + manager4.pomodoroState.value.loopCount shouldBe 1 } } } Given("FOCUS 세션이 RUNNING 중일 때") { When("skip()을 호출하면") { - Then("session이 BREAK로 전환되고 timerState가 RUNNING이 된다") { - runTest { - val manager = makeManager( - FakePomodoroSnapshotRepository(), - FakeTimerSnapshotRepository(), - FakeTimeSource(), - backgroundScope, - ) - manager.start() + val manager = makeManager( + FakePomodoroSnapshotRepository(), + FakeTimerSnapshotRepository(), + FakeTimeSource(), + CoroutineScope(UnconfinedTestDispatcher(testScheduler)), + ) + manager.start() + manager.skip() - manager.skip() - advanceUntilIdle() - - manager.pomodoroState.value.session shouldBe PomodoroSession.BREAK - manager.pomodoroState.value.timerState shouldBe TimerState.RUNNING - } + Then("session이 BREAK로 전환되고 timerState가 RUNNING이 된다") { + manager.pomodoroState.value.session shouldBe PomodoroSession.BREAK + manager.pomodoroState.value.timerState shouldBe TimerState.RUNNING } } } Given("IDLE 상태이고 스냅샷(session=BREAK, loopCount=2)이 저장되어 있을 때") { - When("restore()를 호출하면") { - Then("session과 loopCount가 스냅샷 값으로 복원된다") { - runTest { - val pomodoroRepo = FakePomodoroSnapshotRepository().apply { - snapshot = PomodoroSnapshot( - session = PomodoroSession.BREAK, - loopCount = 2, - ) - } - val manager = makeManager( - pomodoroRepo, - FakeTimerSnapshotRepository(), - FakeTimeSource(), - backgroundScope, - ) + val pomodoroRepo = FakePomodoroSnapshotRepository().apply { + snapshot = PomodoroSnapshot( + session = PomodoroSession.BREAK, + loopCount = 2, + ) + } - manager.restore() - advanceUntilIdle() + When("restore()를 호출하면") { + val manager = makeManager( + pomodoroRepo, + FakeTimerSnapshotRepository(), + FakeTimeSource(), + CoroutineScope(UnconfinedTestDispatcher(testScheduler)), + ) + manager.restore() - manager.pomodoroState.value.session shouldBe PomodoroSession.BREAK - manager.pomodoroState.value.loopCount shouldBe 2 - } + Then("session과 loopCount가 스냅샷 값으로 복원된다") { + manager.pomodoroState.value.session shouldBe PomodoroSession.BREAK + manager.pomodoroState.value.loopCount shouldBe 2 } } } Given("RUNNING 상태이고 스냅샷이 저장되어 있을 때") { - When("restore()를 호출하면") { - Then("상태가 변하지 않고 무시된다") { - runTest { - val pomodoroRepo = FakePomodoroSnapshotRepository().apply { - snapshot = PomodoroSnapshot( - session = PomodoroSession.BREAK, - loopCount = 2, - ) - } - val manager = makeManager( - pomodoroRepo, - FakeTimerSnapshotRepository(), - FakeTimeSource(), - backgroundScope, - ) - manager.start() - advanceUntilIdle() + val pomodoroRepo = FakePomodoroSnapshotRepository().apply { + snapshot = PomodoroSnapshot( + session = PomodoroSession.BREAK, + loopCount = 2, + ) + } - manager.restore() - advanceUntilIdle() + When("restore()를 호출하면") { + val manager = makeManager( + pomodoroRepo, + FakeTimerSnapshotRepository(), + FakeTimeSource(), + CoroutineScope(UnconfinedTestDispatcher(testScheduler)), + ) + manager.start() + manager.restore() - // 스냅샷 값이 아닌 현재 상태(FOCUS, RUNNING) 유지 - manager.pomodoroState.value.session shouldBe PomodoroSession.FOCUS - manager.pomodoroState.value.timerState shouldBe TimerState.RUNNING - manager.pomodoroState.value.loopCount shouldBe 0 - } + Then("상태가 변하지 않고 무시된다") { + // 스냅샷 값이 아닌 현재 상태(FOCUS, RUNNING) 유지 + manager.pomodoroState.value.session shouldBe PomodoroSession.FOCUS + manager.pomodoroState.value.timerState shouldBe TimerState.RUNNING + manager.pomodoroState.value.loopCount shouldBe 0 } } } 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..c9eb87de 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,8 @@ 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.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 +15,9 @@ import kotlin.time.Duration.Companion.seconds @OptIn(ExperimentalCoroutinesApi::class) class TimerEngineTest : BehaviorSpec({ + coroutineTestScope = true + + val testScheduler = TestCoroutineScheduler() fun makeEngine( repository: FakeTimerSnapshotRepository, @@ -28,278 +31,244 @@ class TimerEngineTest : Given("IDLE 상태의 타이머가 있을 때") { When("start를 호출하면") { - Then("RUNNING 상태로 전환된다") { - runTest { - val engine = makeEngine(FakeTimerSnapshotRepository(), FakeTimeSource(), backgroundScope) - engine.start(1.minutes) + val engine = makeEngine(FakeTimerSnapshotRepository(), FakeTimeSource(), CoroutineScope(UnconfinedTestDispatcher(testScheduler))) + engine.start(1.minutes) - engine.timerState.value shouldBe TimerState.RUNNING - } + Then("RUNNING 상태로 전환된다") { + engine.timerState.value shouldBe TimerState.RUNNING } } } Given("RUNNING 상태의 타이머가 있을 때") { When("pause를 호출하면") { - Then("PAUSED 상태로 전환된다") { - runTest { - val engine = makeEngine(FakeTimerSnapshotRepository(), FakeTimeSource(), backgroundScope) - engine.start(1.minutes) - engine.pause() + val engine = makeEngine(FakeTimerSnapshotRepository(), FakeTimeSource(), CoroutineScope(UnconfinedTestDispatcher(testScheduler))) + engine.start(1.minutes) + engine.pause() - engine.timerState.value shouldBe TimerState.PAUSED - } + Then("PAUSED 상태로 전환된다") { + engine.timerState.value shouldBe TimerState.PAUSED } } When("reset을 호출하면") { - Then("IDLE 상태로 전환된다") { - runTest { - val engine = makeEngine(FakeTimerSnapshotRepository(), FakeTimeSource(), backgroundScope) - engine.start(1.minutes) - engine.reset() + val engine = makeEngine(FakeTimerSnapshotRepository(), FakeTimeSource(), CoroutineScope(UnconfinedTestDispatcher(testScheduler))) + 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 } } When("start를 다시 호출하면") { - Then("무시된다") { - runTest { - val engine = makeEngine(FakeTimerSnapshotRepository(), FakeTimeSource(), backgroundScope) - engine.start(1.minutes) - engine.start(30.seconds) // 무시 + val engine = makeEngine(FakeTimerSnapshotRepository(), FakeTimeSource(), CoroutineScope(UnconfinedTestDispatcher(testScheduler))) + 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 } } } Given("PAUSED 상태의 타이머가 있을 때") { When("resume을 호출하면") { - Then("RUNNING 상태로 전환된다") { - runTest { - val engine = makeEngine(FakeTimerSnapshotRepository(), FakeTimeSource(), backgroundScope) - engine.start(1.minutes) - engine.pause() - engine.resume() + val engine = makeEngine(FakeTimerSnapshotRepository(), FakeTimeSource(), CoroutineScope(UnconfinedTestDispatcher(testScheduler))) + engine.start(1.minutes) + engine.pause() + engine.resume() - engine.timerState.value shouldBe TimerState.RUNNING - } + Then("RUNNING 상태로 전환된다") { + engine.timerState.value shouldBe TimerState.RUNNING } } } Given("500ms 타이머가 RUNNING 중일 때") { - When("600ms가 경과하면") { - Then("COMPLETED 상태로 전환된다") { - runTest { - val fakeTimeSource = FakeTimeSource() - val engine = makeEngine(FakeTimerSnapshotRepository(), fakeTimeSource, backgroundScope) + val fakeTimeSource1 = FakeTimeSource() + val engine1 = makeEngine(FakeTimerSnapshotRepository(), fakeTimeSource1, CoroutineScope(UnconfinedTestDispatcher(testScheduler))) + 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 } } } 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 engine = makeEngine(FakeTimerSnapshotRepository(), fakeTimeSource, CoroutineScope(UnconfinedTestDispatcher(testScheduler))) + engine.start(10.seconds) + fakeTimeSource.advanceBy(2_000) + testScheduler.advanceTimeBy(2_100) + engine.pause() + val remainingAfterPause = engine.remaining.value - val remainingAfterPause = engine.remaining.value + fakeTimeSource.advanceBy(3_000) + testScheduler.advanceTimeBy(3_100) - fakeTimeSource.advanceBy(3_000) - advanceTimeBy(3_100) - - engine.remaining.value shouldBe remainingAfterPause - } + Then("일시정지 중 경과한 시간은 remaining에 포함되지 않는다") { + engine.remaining.value shouldBe remainingAfterPause } } When("3초가 경과하면") { - Then("remaining이 7초로 감소한다") { - runTest { - val fakeTimeSource = FakeTimeSource() - val engine = makeEngine(FakeTimerSnapshotRepository(), fakeTimeSource, backgroundScope) + val fakeTimeSource = FakeTimeSource() + val engine = makeEngine(FakeTimerSnapshotRepository(), fakeTimeSource, CoroutineScope(UnconfinedTestDispatcher(testScheduler))) + 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 } } } Given("0 duration으로 start를 호출하면") { When("start(0ms)를 호출하면") { + val engine = makeEngine(FakeTimerSnapshotRepository(), FakeTimeSource(), CoroutineScope(UnconfinedTestDispatcher(testScheduler))) + Then("IllegalArgumentException이 전파된다") { - runTest { - val engine = makeEngine(FakeTimerSnapshotRepository(), FakeTimeSource(), backgroundScope) - shouldThrow { - engine.start(0.milliseconds) - } + shouldThrow { + engine.start(0.milliseconds) } } } } Given("remaining이 0인 PAUSED 상태의 타이머가 있을 때") { + val repository = FakeTimerSnapshotRepository().apply { + restoredSnapshot = RestoredTimerSnapshot( + duration = 1.minutes, + elapsedMillis = 60_000L, + wasRunning = false, + isCompleted = false, + ) + } + When("resume을 호출하면") { + val engine = makeEngine(repository, FakeTimeSource(), CoroutineScope(UnconfinedTestDispatcher(testScheduler))) + 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() } } } } Given("RUNNING 상태에서 restore를 호출하면") { + val repository = FakeTimerSnapshotRepository().apply { + restoredSnapshot = RestoredTimerSnapshot( + duration = 1.minutes, + elapsedMillis = 0L, + wasRunning = true, + isCompleted = false, + ) + } + When("restore를 호출하면") { + val engine = makeEngine(repository, FakeTimeSource(), CoroutineScope(UnconfinedTestDispatcher(testScheduler))) + 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() } } } } 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 engine = makeEngine(repository, FakeTimeSource(), CoroutineScope(UnconfinedTestDispatcher(testScheduler))) + engine.restore() - engine.timerState.value shouldBe TimerState.PAUSED - } + Then("PAUSED 상태로 복원된다") { + engine.timerState.value shouldBe TimerState.PAUSED } } } 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 engine = makeEngine(repository, FakeTimeSource(), CoroutineScope(UnconfinedTestDispatcher(testScheduler))) + engine.restore() - engine.timerState.value shouldBe TimerState.RUNNING - } + Then("RUNNING 상태로 복원된다") { + engine.timerState.value shouldBe TimerState.RUNNING } } } 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 engine = makeEngine(repository, FakeTimeSource(), CoroutineScope(UnconfinedTestDispatcher(testScheduler))) + engine.restore() - engine.timerState.value shouldBe TimerState.COMPLETED - } + Then("COMPLETED 상태로 복원된다") { + engine.timerState.value shouldBe TimerState.COMPLETED } } } 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 engine = makeEngine(repository, FakeTimeSource(), CoroutineScope(UnconfinedTestDispatcher(testScheduler))) + engine.restore() - engine.remaining.value shouldBe 7.minutes - } + Then("remaining이 7분으로 설정된다") { + engine.remaining.value shouldBe 7.minutes } } } 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/docs/testing.md b/docs/testing.md index 4cb78883..40f222ad 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -88,10 +88,12 @@ xGiven("...") { ... } ### 코드 배치 규칙 - `Given` — 사전 데이터·상태 준비 (Fake, 초기값 등) -- `When` — 객체 생성 + 액션 호출 + `advanceUntilIdle()` +- `When` — 객체 생성 + 액션 호출 (+ ViewModel 테스트는 `testScheduler.advanceUntilIdle()`) - `Then` — 상태 검증만 (비동기 호출 없음) - `Channel`/`SharedFlow` 검증은 `Then` 안에서 Turbine `test { }` 사용 +> **주의:** `coroutineTestScope = true` 환경에서 `advanceUntilIdle()`, `advanceTimeBy()`, `backgroundScope` 등 `TestScope` 확장은 **`Then` 블록에서만** 사용 가능하다. `When` 블록에서는 `TestScope` 수신자가 없으므로 호출하면 컴파일 에러가 발생한다. + ### 검증 대상 행동(behavior)을 검증한다. 내부 구현 세부사항(private 필드, 메서드 호출 순서)은 검증하지 않는다. @@ -117,7 +119,7 @@ fun `_loopCount가 1이 된다`() { 코루틴을 사용하는 테스트 클래스는 `coroutineTestScope = true`를 BehaviorSpec 최상단에 선언한다. 이렇게 하면 `Given`/`When`/`Then` 블록에서 `suspend` 함수를 직접 호출할 수 있어 `runTest` 래핑이 필요 없다. -`scope: CoroutineScope`를 생성자로 받는 클래스는 `backgroundScope`를 전달한다. +`scope: CoroutineScope`를 생성자로 받는 클래스는 `When` 블록 안에서 `CoroutineScope(UnconfinedTestDispatcher())`를 직접 생성해 전달한다. `UnconfinedTestDispatcher`를 사용하면 코루틴이 즉시 실행되므로 별도의 `advanceUntilIdle()` 없이 `.value`로 결과를 읽을 수 있다. ### 커버리지 기준 @@ -234,6 +236,8 @@ viewModel.uiState.test { ### 도메인 로직 테스트 +`scope: CoroutineScope`를 주입받는 도메인 클래스는 `When` 블록에서 `CoroutineScope(UnconfinedTestDispatcher())`를 생성해 전달한다. `UnconfinedTestDispatcher`는 코루틴을 즉시 실행하므로 `advanceUntilIdle()` 없이 결과를 즉시 읽을 수 있다. + ```kotlin class FooManagerTest : BehaviorSpec({ @@ -246,9 +250,8 @@ class FooManagerTest : BehaviorSpec({ Given("IDLE 상태일 때") { When("start()를 호출하면") { - val manager = makeManager(scope = backgroundScope) + val manager = makeManager(scope = CoroutineScope(UnconfinedTestDispatcher())) manager.start() - advanceUntilIdle() Then("RUNNING 상태로 전환된다") { manager.state.value shouldBe State.RUNNING @@ -260,9 +263,8 @@ class FooManagerTest : BehaviorSpec({ val repo = FakeFooRepository().apply { snapshot = Snapshot(session = BREAK) } When("restore()를 호출하면") { - val manager = makeManager(scope = backgroundScope) // repo 주입 + val manager = makeManager(scope = CoroutineScope(UnconfinedTestDispatcher())) manager.restore() - advanceUntilIdle() Then("session이 BREAK로 복원된다") { manager.state.value.session shouldBe BREAK @@ -309,7 +311,7 @@ class PlaylistListViewModelTest : BehaviorSpec({ }) ``` -**ViewModel 특이사항:** `Given`에서 `setMain` + ViewModel 생성, `When`/`Given`의 `advanceUntilIdle()` 대신 `testScheduler.advanceUntilIdle()` 사용 +**ViewModel 특이사항:** `Given`에서 `setMain` + ViewModel 생성, `When`에서 `testScheduler.advanceUntilIdle()` 사용. `testScheduler.advanceUntilIdle()`은 `TestScope` 확장이 아니므로 `Given`/`When` 블록에서도 호출 가능하다. ### E2E 테스트 — 주요 사용자 흐름만 (androidTest) From 0d27478f5f9e080497ca21ba17bfad8956ca1108 Mon Sep 17 00:00:00 2001 From: gagip Date: Fri, 27 Mar 2026 13:09:47 +0900 Subject: [PATCH 03/12] =?UTF-8?q?test:=20PlaylistDetailViewModel=20?= =?UTF-8?q?=ED=8A=B8=EB=9E=99=20=EC=A0=9C=EA=B1=B0=20=EC=84=B1=EA=B3=B5=20?= =?UTF-8?q?=ED=9B=84=20=EB=AA=A9=EB=A1=9D=20=EA=B0=B1=EC=8B=A0=20=EC=BC=80?= =?UTF-8?q?=EC=9D=B4=EC=8A=A4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ui/PlaylistDetailViewModelTest.kt | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) 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..35e73fe7 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 @@ -97,6 +97,28 @@ class PlaylistDetailViewModelTest : BehaviorSpec({ } } + Given("플레이리스트에 트랙 2개가 있을 때") { + Dispatchers.setMain(testDispatcher) + val repo = FakePlaylistRepository() + val playlistId = repo.createPlaylist("My List").getOrThrow() + repo.addTrackToPlaylist(playlistId, makeTrack(1)) // rowId=1 + repo.addTrackToPlaylist(playlistId, makeTrack(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 오류")) From 7b7caa91b1bc8372fe21674b2ded56a3fcfccf64 Mon Sep 17 00:00:00 2001 From: gagip Date: Fri, 27 Mar 2026 13:13:19 +0900 Subject: [PATCH 04/12] =?UTF-8?q?test:=20PlayQueueManager=20=EC=85=94?= =?UTF-8?q?=ED=94=8C=20=ED=85=8C=EC=8A=A4=ED=8A=B8=EB=A5=BC=20=ED=96=89?= =?UTF-8?q?=EB=8F=99=20=EA=B8=B0=EB=B0=98=20=EA=B2=80=EC=A6=9D=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EA=B5=90=EC=B2=B4=20=EB=B0=8F=20=EC=85=94=ED=94=8C?= =?UTF-8?q?=20OFF=20=EB=B3=B5=EA=B7=80=20=EC=BC=80=EC=9D=B4=EC=8A=A4=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - seed 기반 인덱스 하드코딩 → 5곡 모두 한 번씩 방문하는 행동 기반 검증으로 교체 - 셔플 ON 상태에서 OFF로 토글 시 currentIndex가 원래 인덱스를 유지하는 케이스 추가 --- .../player/data/PlayQueueManagerTest.kt | 50 +++++++++---------- 1 file changed, 24 insertions(+), 26 deletions(-) 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..c051630d 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 @@ -113,38 +113,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() + 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 } } } From d360c0af5e7b8bd6658674e5322cc701473e85e6 Mon Sep 17 00:00:00 2001 From: gagip Date: Fri, 27 Mar 2026 13:16:52 +0900 Subject: [PATCH 05/12] =?UTF-8?q?refactor:=20ZenMusicSelectViewModel=20Ret?= =?UTF-8?q?ry=20while=20=EB=A3=A8=ED=94=84=EB=A5=BC=20Turbine=20awaitItem?= =?UTF-8?q?=20=EB=AA=85=EC=8B=9C=20=ED=8C=A8=ED=84=B4=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EA=B5=90=EC=B2=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - StateFlow conflation으로 Loading이 합쳐져 Success 상태 하나만 방출됨을 확인 - while 루프 + 재시도 카운터 → awaitItem() 단일 호출로 단순화 - 불필요해진 fail import 제거 --- .../musicselect/ZenMusicSelectViewModelTest.kt | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) 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..0a6589b3 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 @@ -10,7 +10,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 @@ -162,18 +161,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}") - } - finalState = awaitItem() - } + // StateFlow conflation 으로 인해 Loading이 합쳐져 Success 상태 하나만 방출될 수 있음 + val finalState = awaitItem() finalState.musicListUiState.tracks shouldHaveSize 1 cancelAndIgnoreRemainingEvents() } From 64476c84b40e0d9fd2604e402ea5e479a2b9bbe7 Mon Sep 17 00:00:00 2001 From: gagip Date: Fri, 27 Mar 2026 13:23:56 +0900 Subject: [PATCH 06/12] =?UTF-8?q?refactor:=20makeTrack=20=EA=B3=B5?= =?UTF-8?q?=ED=86=B5=20=ED=94=BD=EC=8A=A4=EC=B2=98=20=ED=8C=8C=EC=9D=BC(Tr?= =?UTF-8?q?ackFixtures.kt)=EB=A1=9C=20=EC=B6=94=EC=B6=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - core/model/TrackFixtures.kt 신규 생성 (testTrack 함수) - PlayQueueManagerTest, PlaylistDetailViewModelTest, ZenMusicSelectViewModelTest, MiniPlayerViewModelTest, FallbackAwareMusicRepositoryTest 5개 파일 로컬 makeTrack 제거 후 testTrack으로 치환 - 호출 시 named argument 사용 강제 --- .../zenplayer/core/model/TrackFixtures.kt | 17 ++++++++++ .../data/FallbackAwareMusicRepositoryTest.kt | 15 ++------- .../player/data/PlayQueueManagerTest.kt | 16 ++------- .../ui/PlaylistDetailViewModelTest.kt | 23 ++++--------- .../zenplayer/zen/MiniPlayerViewModelTest.kt | 11 ++----- .../ZenMusicSelectViewModelTest.kt | 33 ++++++------------- 6 files changed, 41 insertions(+), 74 deletions(-) create mode 100644 app/src/test/java/com/happyseal/zenplayer/core/model/TrackFixtures.kt 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 861211d6..c461fb37 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,7 +1,7 @@ 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 @@ -9,17 +9,8 @@ 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( 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 c051630d..fd1ebd83 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(), 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 35e73fe7..677c2675 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 @@ -31,21 +31,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))를 호출하면") { @@ -78,8 +69,8 @@ class PlaylistDetailViewModelTest : BehaviorSpec({ 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)) @@ -101,8 +92,8 @@ class PlaylistDetailViewModelTest : BehaviorSpec({ Dispatchers.setMain(testDispatcher) val repo = FakePlaylistRepository() val playlistId = repo.createPlaylist("My List").getOrThrow() - repo.addTrackToPlaylist(playlistId, makeTrack(1)) // rowId=1 - repo.addTrackToPlaylist(playlistId, makeTrack(2)) // rowId=2 + 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() 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..6f4fd83c 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 @@ -31,14 +31,7 @@ class MiniPlayerViewModelTest : return MiniPlayerViewModel(connection) } - val fakeTrack = Track( - id = 1L, - fileUri = "content://media/1", - title = "Test Track", - artist = "Artist", - album = "Album", - durationMs = 180_000L, - ) + val fakeTrack = testTrack(id = 1L, title = "Test Track") Given("playQueue가 변경될 때") { Dispatchers.setMain(testDispatcher) 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 0a6589b3..d64b85cb 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 @@ -32,20 +33,6 @@ class ZenMusicSelectViewModelTest : BehaviorSpec({ 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,7 +58,7 @@ 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에 반영된다") { @@ -138,7 +125,7 @@ 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) @@ -173,9 +160,9 @@ 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) @@ -197,7 +184,7 @@ 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) @@ -323,7 +310,7 @@ class ZenMusicSelectViewModelTest : BehaviorSpec({ 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() @@ -347,7 +334,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() @@ -387,7 +374,7 @@ 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) From c6bac8b29e526d8cd14a83b886f2baa3f0d316e4 Mon Sep 17 00:00:00 2001 From: gagip Date: Fri, 27 Mar 2026 13:26:26 +0900 Subject: [PATCH 07/12] =?UTF-8?q?test:=20ZenViewModel=20skip=20=EC=95=A1?= =?UTF-8?q?=EC=85=98=20=EC=8B=9C=EB=82=98=EB=A6=AC=EC=98=A4=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - FOCUS RUNNING 상태에서 PomodoroSkip 시 세션이 BREAK로 전환되고 RUNNING 유지 검증 - FakeZenPlayerBinder.skipPomodoro()에 실제 세션 전환 로직 추가 --- .../zenplayer/zen/FakeZenPlayerBinder.kt | 7 +++++- .../zenplayer/zen/ZenViewModelTest.kt | 25 +++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) 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/ZenViewModelTest.kt b/app/src/test/java/com/happyseal/zenplayer/zen/ZenViewModelTest.kt index acc5095a..327711f4 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 @@ -208,6 +209,30 @@ class ZenViewModelTest : } } + Given("FOCUS 세션이 RUNNING 상태일 때") { + Dispatchers.setMain(testDispatcher) + 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 { From 3f54cea3c06e9c6043652f61451c3fa84d14aac8 Mon Sep 17 00:00:00 2001 From: gagip Date: Fri, 27 Mar 2026 14:07:03 +0900 Subject: [PATCH 08/12] =?UTF-8?q?test:=20PomodoroManager=20RUNNING=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=EC=97=90=EC=84=9C=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=20=EC=8B=9C=20=EC=83=81=ED=83=9C=20=EC=9C=A0?= =?UTF-8?q?=EC=A7=80=20=EC=BC=80=EC=9D=B4=EC=8A=A4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - focusDuration 설정 변경 시 현재 세션의 timerState(RUNNING)가 유지되는지 검증 --- .../timer/domain/PomodoroManagerTest.kt | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) 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 5cf0a2b0..9ba4b468 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 @@ -10,6 +10,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.TestCoroutineScheduler import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlin.time.Duration.Companion.minutes @OptIn(ExperimentalCoroutinesApi::class) class PomodoroManagerTest : @@ -236,6 +237,33 @@ class PomodoroManagerTest : } } + Given("타이머가 RUNNING 중일 때") { + val configRepo = FakePomodoroConfigRepository() + val fakeTimeSource = FakeTimeSource() + val manager = makeManager( + FakePomodoroSnapshotRepository(), + FakeTimerSnapshotRepository(), + fakeTimeSource, + CoroutineScope(UnconfinedTestDispatcher(testScheduler)), + 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 + } + } + } + Given("RUNNING 상태이고 스냅샷이 저장되어 있을 때") { val pomodoroRepo = FakePomodoroSnapshotRepository().apply { snapshot = PomodoroSnapshot( From 5974b185faa57f9ef3bd5341deb1dcefa422e31e Mon Sep 17 00:00:00 2001 From: gagip Date: Fri, 27 Mar 2026 14:21:41 +0900 Subject: [PATCH 09/12] =?UTF-8?q?refactor:=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=20=EA=B5=AC=EC=A1=B0=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Dispatchers.setMain()을 각 Given에서 beforeTest로 통합 (4개 파일) - PlayQueueManager 셔플 OFF 토글 테스트에 고정 seed(42) 적용으로 결정론적 검증 보장 - PomodoroManagerTest elapse() 행동 코드를 Then → When 블록으로 이동 (4곳) - FallbackAwareMusicRepositoryTest Then 블록을 반환값/isFallback 검증으로 분리 - MiniPlayerViewModelTest fakeTrack을 사용하는 Given 블록 내부로 스코프 이동 - PlayQueueManagerTest makeManager() 생성을 Then → When 블록으로 이동 --- .../data/FallbackAwareMusicRepositoryTest.kt | 11 ++++++- .../player/data/PlayQueueManagerTest.kt | 8 ++--- .../ui/PlaylistDetailViewModelTest.kt | 13 ++++---- .../timer/domain/PomodoroManagerTest.kt | 14 +++++--- .../zenplayer/zen/MiniPlayerViewModelTest.kt | 13 ++++---- .../zenplayer/zen/ZenViewModelTest.kt | 21 ++++++------ .../ZenMusicSelectViewModelTest.kt | 33 ++++++++++--------- 7 files changed, 64 insertions(+), 49 deletions(-) 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 c461fb37..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 @@ -21,8 +21,11 @@ class FallbackAwareMusicRepositoryTest : When("음악 목록을 불러오면") { val result = repo.getAllTracks() - Then("기기 음원 목록이 반환되고 isFallback이 false다") { + Then("기기 음원 목록이 반환된다") { result.getOrNull() shouldBe deviceTracks + } + + Then("isFallback이 false다") { repo.isFallback.test { awaitItem() shouldBe false cancelAndIgnoreRemainingEvents() @@ -42,6 +45,9 @@ class FallbackAwareMusicRepositoryTest : Then("내장 기본 음원 목록으로 대체된다") { result.getOrNull() shouldBe sampleTracks + } + + Then("isFallback이 true다") { repo.isFallback.test { awaitItem() shouldBe true cancelAndIgnoreRemainingEvents() @@ -61,6 +67,9 @@ class FallbackAwareMusicRepositoryTest : Then("내장 기본 음원 목록으로 대체된다") { result.getOrNull() shouldBe sampleTracks + } + + Then("isFallback이 true다") { repo.isFallback.test { awaitItem() shouldBe true cancelAndIgnoreRemainingEvents() 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 fd1ebd83..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 @@ -119,7 +119,7 @@ class PlayQueueManagerTest : } Given("셔플 ON 상태에서 재생 중일 때") { - val manager = makeManager() + val manager = makeManager(random = Random(seed = 42L)) val tracks = makeTracks(5) manager.setPlayQueue(tracks, startIndex = 0) manager.toggleShuffle() @@ -177,8 +177,8 @@ class PlayQueueManagerTest : Given("재생 큐가 없는 초기 상태") { When("playNext()를 호출하면") { + val manager = makeManager() Then("IllegalStateException이 발생한다") { - val manager = makeManager() shouldThrow { manager.playNext() } @@ -186,8 +186,8 @@ class PlayQueueManagerTest : } When("playPrevious()를 호출하면") { + val manager = makeManager() Then("IllegalStateException이 발생한다") { - val manager = makeManager() shouldThrow { manager.playPrevious() } @@ -195,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 677c2675..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 @@ -24,6 +24,7 @@ class PlaylistDetailViewModelTest : BehaviorSpec({ val testScheduler = TestCoroutineScheduler() val testDispatcher = StandardTestDispatcher(testScheduler) + beforeTest { Dispatchers.setMain(testDispatcher) } afterTest { Dispatchers.resetMain() } fun makeViewModel( @@ -32,7 +33,7 @@ class PlaylistDetailViewModelTest : BehaviorSpec({ ): PlaylistDetailViewModel = PlaylistDetailViewModel(repo, FakeZenPlayerServiceConnection(binder)) Given("플레이리스트 \"Chill Beats\"와 트랙 2개가 있을 때") { - Dispatchers.setMain(testDispatcher) + val repo = FakePlaylistRepository() val playlistId = repo.createPlaylist("Chill Beats").getOrThrow() repo.addTrackToPlaylist(playlistId, testTrack(id = 1)) @@ -52,7 +53,7 @@ class PlaylistDetailViewModelTest : BehaviorSpec({ } Given("존재하지 않는 playlistId가 있을 때") { - Dispatchers.setMain(testDispatcher) + val viewModel = makeViewModel() When("onAction(SelectPlaylist(999L))를 호출하면") { @@ -66,7 +67,7 @@ class PlaylistDetailViewModelTest : BehaviorSpec({ } Given("플레이리스트 \"Work Out\"과 트랙 2개가 있을 때") { - Dispatchers.setMain(testDispatcher) + val repo = FakePlaylistRepository() val playlistId = repo.createPlaylist("Work Out").getOrThrow() repo.addTrackToPlaylist(playlistId, testTrack(id = 1)) @@ -89,7 +90,7 @@ class PlaylistDetailViewModelTest : BehaviorSpec({ } Given("플레이리스트에 트랙 2개가 있을 때") { - Dispatchers.setMain(testDispatcher) + val repo = FakePlaylistRepository() val playlistId = repo.createPlaylist("My List").getOrThrow() repo.addTrackToPlaylist(playlistId, testTrack(id = 1)) // rowId=1 @@ -111,7 +112,7 @@ class PlaylistDetailViewModelTest : BehaviorSpec({ } Given("removeTrackFromPlaylist가 실패하는 Repository가 있을 때") { - Dispatchers.setMain(testDispatcher) + val repo = FakePlaylistRepository(throwOnRemoveTrack = RuntimeException("DB 오류")) val viewModel = makeViewModel(repo) @@ -130,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 9ba4b468..e23235e5 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 @@ -115,8 +115,9 @@ class PomodoroManagerTest : manager1.start() When("타이머가 완료되면") { + elapse(fakeTimeSource1, PomodoroConfig.Default.focusDuration, testScheduler) + Then("session이 BREAK로 전환되고 loopCount는 0 유지된다") { - elapse(fakeTimeSource1, PomodoroConfig.Default.focusDuration, testScheduler) manager1.pomodoroState.value.session shouldBe PomodoroSession.BREAK manager1.pomodoroState.value.loopCount shouldBe 0 } @@ -135,8 +136,9 @@ class PomodoroManagerTest : manager2.skip() // FOCUS → BREAK (loopCount 0 유지) When("타이머가 완료되면") { + elapse(fakeTimeSource2, PomodoroConfig.Default.breakDuration, testScheduler) + Then("session이 FOCUS로 전환되고 loopCount가 1이 된다") { - elapse(fakeTimeSource2, PomodoroConfig.Default.breakDuration, testScheduler) manager2.pomodoroState.value.session shouldBe PomodoroSession.FOCUS manager2.pomodoroState.value.loopCount shouldBe 1 } @@ -164,9 +166,10 @@ class PomodoroManagerTest : 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으로 리셋된다") { - // 2번째 FOCUS 완료: loopCount(1)+1 == loopsPerRound(2) → IDLE 정지 - elapse(fakeTimeSource3, PomodoroConfig.Default.focusDuration, testScheduler) manager3.pomodoroState.value.timerState shouldBe TimerState.IDLE manager3.pomodoroState.value.session shouldBe PomodoroSession.FOCUS manager3.pomodoroState.value.loopCount shouldBe 0 @@ -187,8 +190,9 @@ class PomodoroManagerTest : manager4.skip() // BREAK로 이동, 자동 start When("타이머가 완료되면") { + elapse(fakeTimeSource4, PomodoroConfig.Default.breakDuration, testScheduler) + Then("session이 FOCUS로 전환되고 loopCount가 1이 된다") { - elapse(fakeTimeSource4, PomodoroConfig.Default.breakDuration, testScheduler) manager4.pomodoroState.value.session shouldBe PomodoroSession.FOCUS manager4.pomodoroState.value.loopCount shouldBe 1 } 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 6f4fd83c..beb2fb88 100644 --- a/app/src/test/java/com/happyseal/zenplayer/zen/MiniPlayerViewModelTest.kt +++ b/app/src/test/java/com/happyseal/zenplayer/zen/MiniPlayerViewModelTest.kt @@ -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,10 +32,8 @@ class MiniPlayerViewModelTest : return MiniPlayerViewModel(connection) } - val fakeTrack = testTrack(id = 1L, title = "Test Track") - Given("playQueue가 변경될 때") { - Dispatchers.setMain(testDispatcher) + val fakeTrack = testTrack(id = 1L, title = "Test Track") When("현재 트랙이 있으면") { val binder = FakeZenPlayerBinder() @@ -89,7 +88,7 @@ class MiniPlayerViewModelTest : } Given("PLAYING 상태일 때") { - Dispatchers.setMain(testDispatcher) + val binder = FakeZenPlayerBinder().apply { playbackState.value = PlaybackState.PLAYING } @@ -106,7 +105,7 @@ class MiniPlayerViewModelTest : } Given("PAUSED 상태일 때") { - Dispatchers.setMain(testDispatcher) + val binder = FakeZenPlayerBinder().apply { playbackState.value = PlaybackState.PAUSED } @@ -123,7 +122,7 @@ class MiniPlayerViewModelTest : } Given("ERROR 상태일 때") { - Dispatchers.setMain(testDispatcher) + val binder = FakeZenPlayerBinder().apply { playbackState.value = PlaybackState.ERROR } @@ -140,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 327711f4..1497dc6d 100644 --- a/app/src/test/java/com/happyseal/zenplayer/zen/ZenViewModelTest.kt +++ b/app/src/test/java/com/happyseal/zenplayer/zen/ZenViewModelTest.kt @@ -26,6 +26,7 @@ class ZenViewModelTest : val testScheduler = TestCoroutineScheduler() val testDispatcher = StandardTestDispatcher(testScheduler) + beforeTest { Dispatchers.setMain(testDispatcher) } afterTest { Dispatchers.resetMain() } fun makeViewModel( @@ -43,7 +44,7 @@ class ZenViewModelTest : } Given("타이머가 멈춰있는(IDLE) 상태") { - Dispatchers.setMain(testDispatcher) + val binder = FakeZenPlayerBinder() val viewModel = makeViewModel(binder) @@ -62,7 +63,7 @@ class ZenViewModelTest : } Given("타이머가 진행 중인(RUNNING) 상태") { - Dispatchers.setMain(testDispatcher) + val binder = FakeZenPlayerBinder().apply { pomodoroState.value = PomodoroState.Idle.copy(timerState = TimerState.RUNNING) } @@ -84,7 +85,7 @@ class ZenViewModelTest : } Given("타이머가 일시정지된(PAUSED) 상태") { - Dispatchers.setMain(testDispatcher) + val binder = FakeZenPlayerBinder().apply { pomodoroState.value = PomodoroState.Idle.copy(timerState = TimerState.PAUSED) } @@ -106,7 +107,7 @@ class ZenViewModelTest : } Given("집중 세션이 완료된(COMPLETED) 상태") { - Dispatchers.setMain(testDispatcher) + val binder = FakeZenPlayerBinder().apply { pomodoroState.value = PomodoroState.Idle.copy(timerState = TimerState.COMPLETED) } @@ -128,7 +129,7 @@ class ZenViewModelTest : } Given("기본 집중 시간 설정 상태") { - Dispatchers.setMain(testDispatcher) + val configRepo = FakePomodoroConfigRepository() val viewModel = makeViewModel(configRepo = configRepo) @@ -147,7 +148,7 @@ class ZenViewModelTest : } Given("반복 없음(NONE) 모드 상태") { - Dispatchers.setMain(testDispatcher) + val binder = FakeZenPlayerBinder() val viewModel = makeViewModel(binder) @@ -166,7 +167,7 @@ class ZenViewModelTest : } Given("오디오가 PLAYING 상태일 때") { - Dispatchers.setMain(testDispatcher) + val binder = FakeZenPlayerBinder().apply { playbackState.value = PlaybackState.PLAYING } @@ -188,7 +189,7 @@ class ZenViewModelTest : } Given("오디오가 PAUSED 상태일 때") { - Dispatchers.setMain(testDispatcher) + val binder = FakeZenPlayerBinder().apply { playbackState.value = PlaybackState.PAUSED } @@ -210,7 +211,7 @@ class ZenViewModelTest : } Given("FOCUS 세션이 RUNNING 상태일 때") { - Dispatchers.setMain(testDispatcher) + val binder = FakeZenPlayerBinder().apply { pomodoroState.value = PomodoroState.Idle.copy(timerState = TimerState.RUNNING) } @@ -234,7 +235,7 @@ class ZenViewModelTest : } 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 d64b85cb..a10bcbbd 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 @@ -31,6 +31,7 @@ class ZenMusicSelectViewModelTest : BehaviorSpec({ val testScheduler = TestCoroutineScheduler() val testDispatcher = StandardTestDispatcher(testScheduler) + beforeTest { Dispatchers.setMain(testDispatcher) } afterTest { Dispatchers.resetMain() } fun makeViewModel( @@ -57,7 +58,7 @@ class ZenMusicSelectViewModelTest : BehaviorSpec({ // ───────────────────────────────────────── Given("권한이 허용되지 않은 초기 상태일 때") { - Dispatchers.setMain(testDispatcher) + val tracks = listOf(testTrack(id = 1L), testTrack(id = 2L)) When("권한 허용 액션을 전달하면") { @@ -88,7 +89,7 @@ class ZenMusicSelectViewModelTest : BehaviorSpec({ } Given("권한이 허용되지 않은 초기 상태에서 권한 거부가 발생하면") { - Dispatchers.setMain(testDispatcher) + val viewModel = makeViewModel() When("권한 거부 액션을 전달하면") { @@ -105,7 +106,7 @@ class ZenMusicSelectViewModelTest : BehaviorSpec({ } Given("트랙 로드가 실패하는 환경일 때") { - Dispatchers.setMain(testDispatcher) + val error = RuntimeException("로드 실패") val viewModel = makeViewModel(throwOnGetAll = error) @@ -123,7 +124,7 @@ class ZenMusicSelectViewModelTest : BehaviorSpec({ } Given("트랙 로드가 한 번 실패한 상태일 때") { - Dispatchers.setMain(testDispatcher) + var shouldFail = true val tracks = listOf(testTrack(id = 1L)) val repo = object : com.happyseal.zenplayer.features.music.domain.MusicRepository { @@ -158,7 +159,7 @@ class ZenMusicSelectViewModelTest : BehaviorSpec({ } Given("두 폴더에 걸쳐 트랙이 있을 때") { - Dispatchers.setMain(testDispatcher) + val tracks = listOf( testTrack(id = 1L, folder = "Jazz"), testTrack(id = 2L, folder = "Jazz"), @@ -183,7 +184,7 @@ class ZenMusicSelectViewModelTest : BehaviorSpec({ } Given("트랙 목록이 로드된 상태이고 binder가 연결된 상태일 때") { - Dispatchers.setMain(testDispatcher) + val tracks = listOf(testTrack(id = 1L), testTrack(id = 2L), testTrack(id = 3L)) val binder = FakeZenPlayerBinder() val viewModel = makeViewModel(tracks = tracks, binder = binder) @@ -209,7 +210,7 @@ class ZenMusicSelectViewModelTest : BehaviorSpec({ // ───────────────────────────────────────── Given("플레이리스트가 없는 상태일 때") { - Dispatchers.setMain(testDispatcher) + val viewModel = makeViewModel() When("플레이리스트 생성에 성공하면") { @@ -226,7 +227,7 @@ class ZenMusicSelectViewModelTest : BehaviorSpec({ } Given("플레이리스트 생성이 실패하는 환경일 때") { - Dispatchers.setMain(testDispatcher) + val playlistRepo = FakePlaylistRepository(throwOnCreate = RuntimeException("DB 오류")) val viewModel = makeViewModel(playlistRepo = playlistRepo) @@ -243,7 +244,7 @@ class ZenMusicSelectViewModelTest : BehaviorSpec({ } Given("플레이리스트가 하나 있을 때") { - Dispatchers.setMain(testDispatcher) + val playlistRepo = FakePlaylistRepository() val viewModel = makeViewModel(playlistRepo = playlistRepo) @@ -267,7 +268,7 @@ class ZenMusicSelectViewModelTest : BehaviorSpec({ } Given("플레이리스트 삭제가 실패하는 환경일 때") { - Dispatchers.setMain(testDispatcher) + val viewModel = makeViewModel( playlistRepo = FakePlaylistRepository(throwOnDelete = RuntimeException("삭제 실패")), ) @@ -285,7 +286,7 @@ class ZenMusicSelectViewModelTest : BehaviorSpec({ } Given("플레이리스트 이름 변경이 실패하는 환경일 때") { - Dispatchers.setMain(testDispatcher) + val viewModel = makeViewModel( playlistRepo = FakePlaylistRepository(throwOnRename = RuntimeException("이름 변경 실패")), ) @@ -303,7 +304,7 @@ class ZenMusicSelectViewModelTest : BehaviorSpec({ } Given("플레이리스트에 트랙을 추가할 수 있는 상태일 때") { - Dispatchers.setMain(testDispatcher) + val playlistRepo = FakePlaylistRepository() playlistRepo.createPlaylist("Jazz") val viewModel = makeViewModel(playlistRepo = playlistRepo) @@ -324,7 +325,7 @@ class ZenMusicSelectViewModelTest : BehaviorSpec({ } Given("트랙 추가가 두 번째부터 실패하는 환경일 때") { - Dispatchers.setMain(testDispatcher) + val playlistRepo = FakePlaylistRepository( throwOnAddTrack = RuntimeException("추가 실패"), addTrackFailAfter = 1, @@ -352,7 +353,7 @@ class ZenMusicSelectViewModelTest : BehaviorSpec({ // ───────────────────────────────────────── Given("트랙 목록이 비어있는 상태일 때") { - Dispatchers.setMain(testDispatcher) + val binder = FakeZenPlayerBinder() val viewModel = makeViewModel(tracks = emptyList(), binder = binder) @@ -373,7 +374,7 @@ class ZenMusicSelectViewModelTest : BehaviorSpec({ } Given("트랙이 3개 있는 상태일 때") { - Dispatchers.setMain(testDispatcher) + val tracks = listOf(testTrack(id = 1L), testTrack(id = 2L), testTrack(id = 3L)) val binder = FakeZenPlayerBinder() val viewModel = makeViewModel(tracks = tracks, binder = binder) @@ -395,7 +396,7 @@ class ZenMusicSelectViewModelTest : BehaviorSpec({ } Given("플레이리스트가 있는 상태일 때") { - Dispatchers.setMain(testDispatcher) + val viewModel = makeViewModel() When("빈 이름으로 CreatePlaylist 액션을 전달하면") { From 740c856b948de9e9200b4d45c561ab15b2d09c14 Mon Sep 17 00:00:00 2001 From: gagip Date: Fri, 27 Mar 2026 16:08:15 +0900 Subject: [PATCH 10/12] =?UTF-8?q?test:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=BD=94=EB=A3=A8=ED=8B=B4=20=EB=A6=AC=EC=86=8C=EC=8A=A4=20?= =?UTF-8?q?=EB=88=84=EC=88=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PomodoroManagerTest: scope를 변수로 추출하고 Then 블록 마지막에 dispose()/cancel() 추가 - ZenMusicSelectViewModelTest: Retry 후 Loading 상태 방출 시 건너뛰도록 flaky 테스트 수정 --- .../timer/domain/PomodoroManagerTest.kt | 61 +++++++++++++++---- .../ZenMusicSelectViewModelTest.kt | 6 +- 2 files changed, 53 insertions(+), 14 deletions(-) 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 e23235e5..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,6 +8,7 @@ import io.kotest.core.spec.style.BehaviorSpec import io.kotest.matchers.shouldBe import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancel import kotlinx.coroutines.test.TestCoroutineScheduler import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlin.time.Duration.Companion.minutes @@ -38,42 +39,49 @@ class PomodoroManagerTest : Given("IDLE 상태의 PomodoroManager가 있을 때") { When("start()를 호출하면") { + val scope = CoroutineScope(UnconfinedTestDispatcher(testScheduler)) val manager = makeManager( FakePomodoroSnapshotRepository(), FakeTimerSnapshotRepository(), FakeTimeSource(), - CoroutineScope(UnconfinedTestDispatcher(testScheduler)), + scope, ) manager.start() Then("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(), - CoroutineScope(UnconfinedTestDispatcher(testScheduler)), + scope, ) manager.start() manager.pause() Then("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(), - CoroutineScope(UnconfinedTestDispatcher(testScheduler)), + scope, ) manager.start() manager.reset() @@ -82,17 +90,20 @@ class PomodoroManagerTest : 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(), - CoroutineScope(UnconfinedTestDispatcher(testScheduler)), + scope, ) manager.start() manager.pause() @@ -100,17 +111,20 @@ class PomodoroManagerTest : Then("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, - CoroutineScope(UnconfinedTestDispatcher(testScheduler)), + scope1, ) manager1.start() @@ -120,17 +134,20 @@ class PomodoroManagerTest : Then("session이 BREAK로 전환되고 loopCount는 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, - CoroutineScope(UnconfinedTestDispatcher(testScheduler)), + scope2, ) manager2.start() manager2.skip() // FOCUS → BREAK (loopCount 0 유지) @@ -141,6 +158,8 @@ class PomodoroManagerTest : Then("session이 FOCUS로 전환되고 loopCount가 1이 된다") { manager2.pomodoroState.value.session shouldBe PomodoroSession.FOCUS manager2.pomodoroState.value.loopCount shouldBe 1 + manager2.dispose() + scope2.cancel() } } } @@ -154,11 +173,12 @@ class PomodoroManagerTest : loopsPerRound = 2, ), ) + val scope3 = CoroutineScope(UnconfinedTestDispatcher(testScheduler)) val manager3 = makeManager( FakePomodoroSnapshotRepository(), FakeTimerSnapshotRepository(), fakeTimeSource3, - CoroutineScope(UnconfinedTestDispatcher(testScheduler)), + scope3, configRepo, ) manager3.start() @@ -173,17 +193,20 @@ class PomodoroManagerTest : 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, - CoroutineScope(UnconfinedTestDispatcher(testScheduler)), + scope4, ) // FOCUS → skip → BREAK 상태로 이동 (skip은 자동으로 다음 세션 start) manager4.start() @@ -195,17 +218,20 @@ class PomodoroManagerTest : Then("session이 FOCUS로 전환되고 loopCount가 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(), - CoroutineScope(UnconfinedTestDispatcher(testScheduler)), + scope, ) manager.start() manager.skip() @@ -213,6 +239,8 @@ class PomodoroManagerTest : Then("session이 BREAK로 전환되고 timerState가 RUNNING이 된다") { manager.pomodoroState.value.session shouldBe PomodoroSession.BREAK manager.pomodoroState.value.timerState shouldBe TimerState.RUNNING + manager.dispose() + scope.cancel() } } } @@ -226,17 +254,20 @@ class PomodoroManagerTest : } When("restore()를 호출하면") { + val scope = CoroutineScope(UnconfinedTestDispatcher(testScheduler)) val manager = makeManager( pomodoroRepo, FakeTimerSnapshotRepository(), FakeTimeSource(), - CoroutineScope(UnconfinedTestDispatcher(testScheduler)), + scope, ) manager.restore() Then("session과 loopCount가 스냅샷 값으로 복원된다") { manager.pomodoroState.value.session shouldBe PomodoroSession.BREAK manager.pomodoroState.value.loopCount shouldBe 2 + manager.dispose() + scope.cancel() } } } @@ -244,11 +275,12 @@ class PomodoroManagerTest : Given("타이머가 RUNNING 중일 때") { val configRepo = FakePomodoroConfigRepository() val fakeTimeSource = FakeTimeSource() + val scope = CoroutineScope(UnconfinedTestDispatcher(testScheduler)) val manager = makeManager( FakePomodoroSnapshotRepository(), FakeTimerSnapshotRepository(), fakeTimeSource, - CoroutineScope(UnconfinedTestDispatcher(testScheduler)), + scope, configRepo, ) manager.start() @@ -264,6 +296,8 @@ class PomodoroManagerTest : Then("현재 진행 중인 세션의 timerState는 RUNNING을 유지한다") { manager.pomodoroState.value.timerState shouldBe TimerState.RUNNING + manager.dispose() + scope.cancel() } } } @@ -277,11 +311,12 @@ class PomodoroManagerTest : } When("restore()를 호출하면") { + val scope = CoroutineScope(UnconfinedTestDispatcher(testScheduler)) val manager = makeManager( pomodoroRepo, FakeTimerSnapshotRepository(), FakeTimeSource(), - CoroutineScope(UnconfinedTestDispatcher(testScheduler)), + scope, ) manager.start() manager.restore() @@ -291,6 +326,8 @@ class PomodoroManagerTest : 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/zen/musicselect/ZenMusicSelectViewModelTest.kt b/app/src/test/java/com/happyseal/zenplayer/zen/musicselect/ZenMusicSelectViewModelTest.kt index a10bcbbd..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 @@ -149,8 +149,10 @@ class ZenMusicSelectViewModelTest : BehaviorSpec({ shouldFail = false viewModel.onAction(ZenMusicSelectAction.RetryLoadTracks) testScheduler.advanceUntilIdle() - // StateFlow conflation 으로 인해 Loading이 합쳐져 Success 상태 하나만 방출될 수 있음 - val finalState = awaitItem() + var finalState = awaitItem() + if (finalState.musicListUiState.trackLoadState is TrackLoadState.Loading) { + finalState = awaitItem() + } finalState.musicListUiState.tracks shouldHaveSize 1 cancelAndIgnoreRemainingEvents() } From 81b5c45bed960aa12ff47e066422afa5be3f6d8c Mon Sep 17 00:00:00 2001 From: gagip Date: Fri, 27 Mar 2026 16:44:15 +0900 Subject: [PATCH 11/12] =?UTF-8?q?test:=20TimerEngineTest=20=EC=BD=94?= =?UTF-8?q?=EB=A3=A8=ED=8B=B4=20=EB=A6=AC=EC=86=8C=EC=8A=A4=20=EB=88=84?= =?UTF-8?q?=EC=88=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - scope를 변수로 추출하고 Then 블록 마지막에 dispose()/cancel() 추가 --- .../features/timer/domain/TimerEngineTest.kt | 76 +++++++++++++++---- 1 file changed, 61 insertions(+), 15 deletions(-) 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 c9eb87de..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,6 +6,7 @@ import io.kotest.core.spec.style.BehaviorSpec import io.kotest.matchers.shouldBe import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancel import kotlinx.coroutines.test.TestCoroutineScheduler import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlin.time.Duration.Companion.milliseconds @@ -31,28 +32,35 @@ class TimerEngineTest : Given("IDLE 상태의 타이머가 있을 때") { When("start를 호출하면") { - val engine = makeEngine(FakeTimerSnapshotRepository(), FakeTimeSource(), CoroutineScope(UnconfinedTestDispatcher(testScheduler))) + val scope = CoroutineScope(UnconfinedTestDispatcher(testScheduler)) + val engine = makeEngine(FakeTimerSnapshotRepository(), FakeTimeSource(), scope) engine.start(1.minutes) Then("RUNNING 상태로 전환된다") { engine.timerState.value shouldBe TimerState.RUNNING + engine.dispose() + scope.cancel() } } } Given("RUNNING 상태의 타이머가 있을 때") { When("pause를 호출하면") { - val engine = makeEngine(FakeTimerSnapshotRepository(), FakeTimeSource(), CoroutineScope(UnconfinedTestDispatcher(testScheduler))) + val scope = CoroutineScope(UnconfinedTestDispatcher(testScheduler)) + val engine = makeEngine(FakeTimerSnapshotRepository(), FakeTimeSource(), scope) engine.start(1.minutes) engine.pause() Then("PAUSED 상태로 전환된다") { engine.timerState.value shouldBe TimerState.PAUSED + engine.dispose() + scope.cancel() } } When("reset을 호출하면") { - val engine = makeEngine(FakeTimerSnapshotRepository(), FakeTimeSource(), CoroutineScope(UnconfinedTestDispatcher(testScheduler))) + val scope = CoroutineScope(UnconfinedTestDispatcher(testScheduler)) + val engine = makeEngine(FakeTimerSnapshotRepository(), FakeTimeSource(), scope) engine.start(1.minutes) engine.reset() @@ -62,37 +70,46 @@ class TimerEngineTest : Then("remaining이 0이 된다") { engine.remaining.value shouldBe 0.milliseconds + engine.dispose() + scope.cancel() } } When("start를 다시 호출하면") { - val engine = makeEngine(FakeTimerSnapshotRepository(), FakeTimeSource(), CoroutineScope(UnconfinedTestDispatcher(testScheduler))) + val scope = CoroutineScope(UnconfinedTestDispatcher(testScheduler)) + val engine = makeEngine(FakeTimerSnapshotRepository(), FakeTimeSource(), scope) engine.start(1.minutes) engine.start(30.seconds) // 무시 Then("무시된다") { engine.timerState.value shouldBe TimerState.RUNNING engine.remaining.value shouldBe 1.minutes + engine.dispose() + scope.cancel() } } } Given("PAUSED 상태의 타이머가 있을 때") { When("resume을 호출하면") { - val engine = makeEngine(FakeTimerSnapshotRepository(), FakeTimeSource(), CoroutineScope(UnconfinedTestDispatcher(testScheduler))) + val scope = CoroutineScope(UnconfinedTestDispatcher(testScheduler)) + val engine = makeEngine(FakeTimerSnapshotRepository(), FakeTimeSource(), scope) engine.start(1.minutes) engine.pause() engine.resume() Then("RUNNING 상태로 전환된다") { engine.timerState.value shouldBe TimerState.RUNNING + engine.dispose() + scope.cancel() } } } Given("500ms 타이머가 RUNNING 중일 때") { val fakeTimeSource1 = FakeTimeSource() - val engine1 = makeEngine(FakeTimerSnapshotRepository(), fakeTimeSource1, CoroutineScope(UnconfinedTestDispatcher(testScheduler))) + val scope1 = CoroutineScope(UnconfinedTestDispatcher(testScheduler)) + val engine1 = makeEngine(FakeTimerSnapshotRepository(), fakeTimeSource1, scope1) engine1.start(500.milliseconds) When("600ms가 경과하면") { @@ -101,6 +118,8 @@ class TimerEngineTest : Then("COMPLETED 상태로 전환된다") { engine1.timerState.value shouldBe TimerState.COMPLETED + engine1.dispose() + scope1.cancel() } } } @@ -108,7 +127,8 @@ class TimerEngineTest : Given("10초 타이머가 RUNNING 중일 때") { When("2초 경과 후 pause하고, 추가로 3초가 경과하면") { val fakeTimeSource = FakeTimeSource() - val engine = makeEngine(FakeTimerSnapshotRepository(), fakeTimeSource, CoroutineScope(UnconfinedTestDispatcher(testScheduler))) + val scope = CoroutineScope(UnconfinedTestDispatcher(testScheduler)) + val engine = makeEngine(FakeTimerSnapshotRepository(), fakeTimeSource, scope) engine.start(10.seconds) fakeTimeSource.advanceBy(2_000) testScheduler.advanceTimeBy(2_100) @@ -120,12 +140,15 @@ class TimerEngineTest : Then("일시정지 중 경과한 시간은 remaining에 포함되지 않는다") { engine.remaining.value shouldBe remainingAfterPause + engine.dispose() + scope.cancel() } } When("3초가 경과하면") { val fakeTimeSource = FakeTimeSource() - val engine = makeEngine(FakeTimerSnapshotRepository(), fakeTimeSource, CoroutineScope(UnconfinedTestDispatcher(testScheduler))) + val scope = CoroutineScope(UnconfinedTestDispatcher(testScheduler)) + val engine = makeEngine(FakeTimerSnapshotRepository(), fakeTimeSource, scope) engine.start(10.seconds) fakeTimeSource.advanceBy(3_000) @@ -133,18 +156,23 @@ class TimerEngineTest : Then("remaining이 7초로 감소한다") { engine.remaining.value.inWholeSeconds shouldBe 7L + engine.dispose() + scope.cancel() } } } Given("0 duration으로 start를 호출하면") { When("start(0ms)를 호출하면") { - val engine = makeEngine(FakeTimerSnapshotRepository(), FakeTimeSource(), CoroutineScope(UnconfinedTestDispatcher(testScheduler))) + val scope = CoroutineScope(UnconfinedTestDispatcher(testScheduler)) + val engine = makeEngine(FakeTimerSnapshotRepository(), FakeTimeSource(), scope) Then("IllegalArgumentException이 전파된다") { shouldThrow { engine.start(0.milliseconds) } + engine.dispose() + scope.cancel() } } } @@ -160,13 +188,16 @@ class TimerEngineTest : } When("resume을 호출하면") { - val engine = makeEngine(repository, FakeTimeSource(), CoroutineScope(UnconfinedTestDispatcher(testScheduler))) + val scope = CoroutineScope(UnconfinedTestDispatcher(testScheduler)) + val engine = makeEngine(repository, FakeTimeSource(), scope) engine.restore() // remaining = 0, PAUSED Then("IllegalArgumentException이 전파된다") { shouldThrow { engine.resume() } + engine.dispose() + scope.cancel() } } } @@ -182,13 +213,16 @@ class TimerEngineTest : } When("restore를 호출하면") { - val engine = makeEngine(repository, FakeTimeSource(), CoroutineScope(UnconfinedTestDispatcher(testScheduler))) + val scope = CoroutineScope(UnconfinedTestDispatcher(testScheduler)) + val engine = makeEngine(repository, FakeTimeSource(), scope) engine.start(1.minutes) // RUNNING 상태 Then("IllegalStateException이 전파된다") { shouldThrow { engine.restore() } + engine.dispose() + scope.cancel() } } } @@ -204,11 +238,14 @@ class TimerEngineTest : } When("restore를 호출하면") { - val engine = makeEngine(repository, FakeTimeSource(), CoroutineScope(UnconfinedTestDispatcher(testScheduler))) + val scope = CoroutineScope(UnconfinedTestDispatcher(testScheduler)) + val engine = makeEngine(repository, FakeTimeSource(), scope) engine.restore() Then("PAUSED 상태로 복원된다") { engine.timerState.value shouldBe TimerState.PAUSED + engine.dispose() + scope.cancel() } } } @@ -224,11 +261,14 @@ class TimerEngineTest : } When("restore를 호출하면") { - val engine = makeEngine(repository, FakeTimeSource(), CoroutineScope(UnconfinedTestDispatcher(testScheduler))) + val scope = CoroutineScope(UnconfinedTestDispatcher(testScheduler)) + val engine = makeEngine(repository, FakeTimeSource(), scope) engine.restore() Then("RUNNING 상태로 복원된다") { engine.timerState.value shouldBe TimerState.RUNNING + engine.dispose() + scope.cancel() } } } @@ -244,11 +284,14 @@ class TimerEngineTest : } When("restore를 호출하면") { - val engine = makeEngine(repository, FakeTimeSource(), CoroutineScope(UnconfinedTestDispatcher(testScheduler))) + val scope = CoroutineScope(UnconfinedTestDispatcher(testScheduler)) + val engine = makeEngine(repository, FakeTimeSource(), scope) engine.restore() Then("COMPLETED 상태로 복원된다") { engine.timerState.value shouldBe TimerState.COMPLETED + engine.dispose() + scope.cancel() } } } @@ -264,11 +307,14 @@ class TimerEngineTest : } When("restore를 호출하면") { - val engine = makeEngine(repository, FakeTimeSource(), CoroutineScope(UnconfinedTestDispatcher(testScheduler))) + val scope = CoroutineScope(UnconfinedTestDispatcher(testScheduler)) + val engine = makeEngine(repository, FakeTimeSource(), scope) engine.restore() Then("remaining이 7분으로 설정된다") { engine.remaining.value shouldBe 7.minutes + engine.dispose() + scope.cancel() } } } From 5ea5244413033761fcadf0fe05b093fb4323afdb Mon Sep 17 00:00:00 2001 From: gagip Date: Fri, 27 Mar 2026 16:45:22 +0900 Subject: [PATCH 12/12] =?UTF-8?q?docs:=20=EC=BD=94=EB=A3=A8=ED=8B=B4=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EB=A6=AC=EC=86=8C=EC=8A=A4=20?= =?UTF-8?q?=EC=A0=95=EB=A6=AC=20=EA=B0=80=EC=9D=B4=EB=93=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - dispose()/scope.cancel() 호출 필요성 및 예시 코드 추가 - TestCoroutineScheduler 공유 시 주의사항 추가 --- docs/testing.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/docs/testing.md b/docs/testing.md index 40f222ad..2dbc61ca 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -121,6 +121,24 @@ fun `_loopCount가 1이 된다`() { `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`를 여러 테스트에서 공유하면 시간 흐름이 누적되어 의도치 않은 동작이 생길 수 있으므로 주의한다. + ### 커버리지 기준 모든 코드를 커버하는 것이 목표가 아니다. **핵심 흐름과 경계 조건**이 커버되는지를 기준으로 판단한다.