Skip to content

Commit 58e9b3c

Browse files
committed
Test: UserRepositoryImpl에 대한 단위 테스트 추가 (캐싱, 동시성, 캐시 초기화 검증)
1 parent 35bf092 commit 58e9b3c

2 files changed

Lines changed: 129 additions & 0 deletions

File tree

data/build.gradle.kts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,4 +20,7 @@ dependencies {
2020
implementation(libs.bundles.retrofit)
2121
implementation(libs.play.services.location)
2222
implementation(libs.kotlinx.coroutines.play)
23+
24+
testImplementation(libs.androidx.junit)
25+
testImplementation(libs.kotlin.coroutines.test)
2326
}
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
package com.threegap.bitnagil.data.user.repositoryImpl
2+
3+
import com.threegap.bitnagil.data.user.datasource.UserLocalDataSource
4+
import com.threegap.bitnagil.data.user.datasource.UserRemoteDataSource
5+
import com.threegap.bitnagil.data.user.model.response.UserProfileResponse
6+
import com.threegap.bitnagil.domain.user.model.UserProfile
7+
import com.threegap.bitnagil.domain.user.repository.UserRepository
8+
import kotlinx.coroutines.async
9+
import kotlinx.coroutines.awaitAll
10+
import kotlinx.coroutines.delay
11+
import kotlinx.coroutines.flow.MutableStateFlow
12+
import kotlinx.coroutines.flow.StateFlow
13+
import kotlinx.coroutines.flow.asStateFlow
14+
import kotlinx.coroutines.flow.first
15+
import kotlinx.coroutines.flow.update
16+
import kotlinx.coroutines.test.runTest
17+
import org.junit.Assert.assertEquals
18+
import org.junit.Before
19+
import org.junit.Test
20+
import java.util.concurrent.atomic.AtomicInteger
21+
22+
class UserRepositoryImplTest {
23+
24+
private lateinit var localDataSource: FakeUserLocalDataSource
25+
private lateinit var remoteDataSource: FakeUserRemoteDataSource
26+
private lateinit var userRepository: UserRepository
27+
28+
@Before
29+
fun setup() {
30+
localDataSource = FakeUserLocalDataSource()
31+
remoteDataSource = FakeUserRemoteDataSource()
32+
userRepository = UserRepositoryImpl(localDataSource, remoteDataSource)
33+
}
34+
35+
@Test
36+
fun `캐시가 비어있을 때 observeUserProfile을 구독하면 Remote에서 데이터를 가져와 캐시를 업데이트해야 한다`() =
37+
runTest {
38+
// given
39+
val expectedProfile = UserProfile(nickname = "TestUser")
40+
remoteDataSource.profileResponse = UserProfileResponse(nickname = "TestUser")
41+
42+
// when
43+
// 구독(first)이 시작되는 순간 Fetch가 발생함
44+
val result = userRepository.observeUserProfile().first()
45+
46+
// then
47+
assertEquals(expectedProfile, result.getOrNull())
48+
assertEquals(1, remoteDataSource.fetchCount.get())
49+
assertEquals(expectedProfile, localDataSource.userProfile.value)
50+
}
51+
52+
@Test
53+
fun `캐시가 이미 존재할 때 observeUserProfile을 구독하면 Remote를 호출하지 않고 캐시를 반환해야 한다`() =
54+
runTest {
55+
// given
56+
val cachedProfile = UserProfile(nickname = "CachedUser")
57+
localDataSource.saveUserProfile(cachedProfile)
58+
59+
// when
60+
val result = userRepository.observeUserProfile().first()
61+
62+
// then
63+
assertEquals(cachedProfile, result.getOrNull())
64+
assertEquals(0, remoteDataSource.fetchCount.get())
65+
}
66+
67+
@Test
68+
fun `여러 코루틴이 동시에 observeUserProfile을 구독해도 Remote API는 1회만 호출되어야 한다 (Race Condition 방지)`() =
69+
runTest {
70+
// given
71+
remoteDataSource.profileResponse = UserProfileResponse(nickname = "RaceUser")
72+
remoteDataSource.delayMillis = 100L // 네트워크 지연 시뮬레이션
73+
74+
// when
75+
// 10개의 코루틴이 동시에 구독 시작
76+
val jobs = List(10) {
77+
async { userRepository.observeUserProfile().first() }
78+
}
79+
jobs.awaitAll()
80+
81+
// then
82+
assertEquals(1, remoteDataSource.fetchCount.get())
83+
assertEquals("RaceUser", localDataSource.userProfile.value?.nickname)
84+
}
85+
86+
@Test
87+
fun `clearCache를 호출하면 로컬 캐시가 초기화되어야 한다`() =
88+
runTest {
89+
// given
90+
localDataSource.saveUserProfile(UserProfile(nickname = "ToDelete"))
91+
92+
// when
93+
userRepository.clearCache()
94+
95+
// then
96+
assertEquals(null, localDataSource.userProfile.value)
97+
}
98+
99+
// --- Fake Objects ---
100+
101+
private class FakeUserLocalDataSource : UserLocalDataSource {
102+
private val _userProfile = MutableStateFlow<UserProfile?>(null)
103+
override val userProfile: StateFlow<UserProfile?> = _userProfile.asStateFlow()
104+
105+
override suspend fun saveUserProfile(userProfile: UserProfile) {
106+
_userProfile.update { userProfile }
107+
}
108+
109+
override fun clearCache() {
110+
_userProfile.update { null }
111+
}
112+
}
113+
114+
private class FakeUserRemoteDataSource : UserRemoteDataSource {
115+
var profileResponse: UserProfileResponse? = null
116+
val fetchCount = AtomicInteger(0)
117+
var delayMillis = 0L
118+
119+
override suspend fun fetchUserProfile(): Result<UserProfileResponse> {
120+
if (delayMillis > 0) delay(delayMillis)
121+
fetchCount.incrementAndGet()
122+
return profileResponse?.let { Result.success(it) }
123+
?: Result.failure(Exception("No profile set in fake"))
124+
}
125+
}
126+
}

0 commit comments

Comments
 (0)