티스토리 뷰
UnconfinedTestDispatcher
fun main() {
CoroutineScope(StandardTestDispatcher()).launch {
print("A")
delay(1)
print("B")
}
CoroutineScope(UnconfinedTestDispatcher()).launch {
print("C")
delay(1)
print("D")
}
}
C
테스트 디스패처는 StandardTestDispatcher 외에 UnconfinedTestDispatcher도 있다.
StandardTestDispatcher는 스케줄러를 사용하기 전까지 어떤 연산도 수행하지 않는다.
반면에 UnconfinedTestDispatcher는 코루틴을 즉시 시작한다. 중단이 일어나기 전까지의 모든 연산을 수행한다.
따라서 위 코드에서 C가 출력된다.
@Test
fun testName() = runTest(UnconfinedTestDispatcher()) {
// ...
}
runTest의 인자로 UnconfinedTestDispatcher를 전달하여 사용한다.
이 방식은 runTest 함수가 생기기 전에 사용된 runBlockingTest 함수와 동일하다.
목(mock) 사용하기
@Test
fun `should load data concurrently`() = runTest {
// given
val userRepo = mockk<UserDataRepository>()
coEvery { userRepo.getName() } coAnswers {
delay(600)
aName
}
coEvery { userRepo.getFriends() } coAnswers {
delay(700)
someFriends
}
coEvery { userRepo.getProfile() } coAnswers {
delay(800)
aProfile
}
val useCase = FetchUserUseCase(userRepo)
// when
useCase.fetchUserData()
// then
assertEquals(800, currentTime)
}
fake 객체에서 delay를 사용하면, 테스트 함수에서는 delay가 사용되었다는 게 명확하게 드러나지 않는다.
이를 해결하기 위해 Mock을 사용하면 테스트 함수에서 바로 delay를 호출할 수 있다.
디스패처를 바꾸는 함수 테스트하기
suspend fun readSave(name: String): GameState =
withContext(Dispatchers.IO) {
reader.readCsvBlocking(name, GameState::class.java)
}
위 코드처럼, withContext를 통해 디스패처를 바꾸는 함수를 테스트하는 것도 간단하다.
runBlocking이나 runTest로 감싸 함수가 어떤 값을 반환하는지 테스트할 수 있다.
@Test
fun `should change dispatcher`() = runBlocking {
// given
val csvReader = mockk<CsvReader>()
val startThreadName = "MyName"
var usedThreadName: String? = null
every {
csvReader.readCsvBlocking(
aFileName,
GameState::class.java,
)
} coAnswers {
usedThreadName = Thread.currentThread().name
aGameState
}
val saveReader = SaveReader(csvReader)
// when
withContext(newSingleThreadContext(startThreadName)) {
saveReader.readSave(aFileName)
}
// then
assertNotNull(usedThreadName)
val expectedPrefix = "DefaultDispatcher-worker-"
assert(usedThreadName!!.startsWith(expectedPrefix))
}
함수가 실제로 디스패처를 바꾸는지를 확인하려면 어떻게 할까?
디스패처를 바꾸는 함수를 모킹하고, 함수 내부에서 사용한 스레드의 이름을 가져오면 된다.
suspend fun fetchUserData() = withContext(Dispatchers.IO) {
val name = async { userRepo.getName() }
val friends = async { userRepo.getFriends() }
val profile = async { userRepo.getProfile() }
User(
name = name.await(),
friends = friends.await(),
profile = profile.await(),
)
}
디스패처를 바꾸는 함수에서 시간 의존성을 가지고, 이를 테스트해야 하는 경우도 있다.
StandardTestDispatcher를 사용하더라도, 테스트할 함수 내에서 디스패처를 변경하면 가상 시간의 작동이 멈춘다.
가상 시간의 작동이 멈추면, 다른 모든 테스트도 실제 시간만큼 기다리게 된다. currentTime은 0이 된다.
class FetchUserUseCase(
private val userRepo: UserDataRepository,
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
) {
suspend fun fetchUserData() = withContext(ioDispatcher) {
val name = async { userRepo.getName() }
val friends = async { userRepo.getFriends() }
val profile = async { userRepo.getProfile() }
User(
name = name.await(),
friends = friends.await(),
profile = profile.await(),
)
}
}
이를 해결하기 위한 가장 쉬운 방법은 디스패처를 생성자에서 주입받을 수 있도록 분리하는 것이다.
val testDispatcher = this
.coroutinecontext[ContinuationInterceptor]
as CoroutineDispatcher
val useCase = FetchUserUseCase(
userRepo = userRepo,
ioDispatcher = testDispatcher,
)
이제 단위 테스트에서 Dispatchers.IO 대신 runTest의 StandardTestDispatcher를 사용할 수 있다.
runTest 블록 내부에서 디스패처를 얻기 위해서는, 컨텍스트의 Continuationinterceptor 키를 사용한다.
함수 실행 중에 일어나는 일 테스트하기
suspend fun sendUserData() {
val userData = database.getUserData()
progressBarVisible.value = true
userRepository.sendUserData(userData)
progressBarVisible.value = false
}
프로그레스 바를 먼저 보여 주고, 특정 작업이 끝나면 프로그레스 바를 숨기는 함수가 있다.
함수가 끝난 후의 결과만 확인한다면, 중간에 프로그레스 바의 상태를 변경했는지 확인할 수 없다.
@Test
fun `should show progress bar when sending data`() = runTest {
// given
val database = FakeDatabase()
val vm = UserViewModel(database)
// when
launch {
vm.sendUserData()
}
// then
assertEquals(false, vm.progressBarVisible.value)
// when
advanceTimeBy(1000)
// then
assertEquals(false, vm.progressBarVisible.value)
// when
runCurrent()
// then
assertEquals(true, vm.progressBarVisible.value)
// when
advanceUntilIdle()
// then
assertEquals(false, vm.progressBarVisible.value)
}
이런 경우 테스트할 함수를 새로운 코루틴에서 시작하고, 코루틴 밖에서 가상 시간을 조절하면 된다.
자식 코루틴은 부모가 자식을 기다리기 시작할 때가 되어야 시간이 흐른다. 그 이전에는 가상 시간을 조절할 수 있다.
runCurrent()는 현 시점에 예약된 코루틴을 모두 실행시키기 때문에, 특정 값이 변경되었는지 정확하게 확인할 수 있다.
(FakeDatabase에서 sendUserData가 1000ms 걸린다는 것을 명시했다고 가정한다)
@Test
fun `should show progress bar when sending data`() =
runTest {
val database = FakeDatabase()
val vm = UserViewModel(database)
launch {
vm.showUserData()
}
// then
assertEquals(false, vm.progressBarVisible.value)
delay(1000)
assertEquals(true, vm.progressBarVisible.value)
delay(1000)
assertEquals(false, vm.progressBarVisible.value)
}
delay()를 사용할 수도 있다. (하지만 advanceTimeBy()나 runCurrent() 같은 명시적인 함수가 delay()보다 가독성이 좋다)
위 코드는 두 개의 독립적인 프로세스를 가지고 있는 것과 비슷하다.
A 프로세스가 작업을 하고, B 프로세스는 A 프로세스가 정확히 작동하는지 확인하는 것이다.
새로운 코루틴을 시작하는 함수 테스트하기
@Scheduled(fixedRate = 5000)
fun sendNotifications() {
notificationsScope.launch {
val notifications = notificationsRepository
.notificationsToSend()
for (notification in notifications) {
launch {
notificationsService.send(notification)
notificationsRepository
.markAsSent(notification.id)
}
}
}
}
함수 내부에서 새로운 코루틴을 시작할 수도 있다. 이 함수에서 실제로 동시에 알림이 전송되는지 테스트해 보자.
@Test
fun testSendNotifications() {
// given
val notifications = List(100) { Notification(it) }
val repo = FakeNotificationsRepository(
delayMillis = 200,
notifications = notifications,
)
val service = FakeNotificationsService(
delayMillis = 300,
)
val testScope = TestScope()
val sender = NotificationsSender(
notificationsRepository = repo,
notificationsService = service,
notificationsScope = testScope,
)
// when
sender.sendNotifications()
testScope.advanceUntilIdle()
assertEquals(
notification.toSet(),
service.notificationsSent.toSet(),
)
assertEquals(
notifications.map { it.id }.toSet(),
repo.notificationsMarkedAsSent.toSet(),
)
assertEquals(700, testScope.currentTime)
}
동일하게 단위 테스트의 스코프를 생성자로 주입하여 테스트한다. (StandardTestDispatcher를 사용하는 TestScope)
fake 레포지토리와 fake 서비스를 통해 약간의 지연을 추가했다.
알림 100개를 보내는 과정이 병렬적으로 수행되었기 때문에, 총 700ms의 가상 시간이 소요되는 것을 확인할 수 있다.
(레포지토리 notificationsToSend() 200 + 서비스 send() 300 + 레포지토리 markAsSend 200 = 700)
메인 디스패처 교체하기
단위 테스트에는 메인 함수가 존재하지 않는다.
테스트에서 메인 함수를 사용하려고 하면, "메인 디스패처를 가진 모듈이 없다"는 예외를 던진다.
매번 메인 스레드를 주입하는 대신, Dispatchers의 setMain() 확장 함수를 사용한다.
테스트 시작 전 실행되는 setup() 함수에서 메인 함수를 설정하면, 코루틴이 항상 메인 디스패처에서 실행된다는 것이 보장된다.
테스트가 끝난 후 실행되는 tearDown() 함수에서는 Dispatchers.resetMain()을 호출해 메인 함수의 상태를 초기화시켜야 한다.
코루틴을 시작하는 안드로이드 함수 테스트하기
class MainViewModel(
private val userRepo: UserRepository,
private val newsRepo: NewsRepository,
) : BaseVievModel() {
private val _userName: MutableLiveData<String> = MutableLiveData()
val userName: LiveData<String> = _userName
private val _news: MutableLiveData<List<News>> = MutableLiveData()
val news: LiveData<List<News>> = _news
private val _progressVisible: MutableLiveData<Boolean> = MutableLiveData()
val progressVisible: LiveData<Boolean> = _progressVisible
fun onCreate() {
viewModelScope.launch {
val user = userRepo.getUser()
_userName.value = user.name
}
viewModelScope.launch {
_progressVisible.value = true
val news = newsRepo.getNews()
.sortedByDescending { it.date }
_news.value = news
_progressVisible.value = false
}
}
}
위 코드에서 ViewModel의 onCreate() 함수는 viewModelScope에서 새로운 코루틴을 실행하고 있다.
이전 코드에서는 생성자를 통해 테스트 스코프를 주입했지만, 이보다 조금 더 간단한 방법을 사용할 수도 있다.
스코프를 따로 주입하지 않아도 테스트 디스패처에서 새로운 코루틴을 실행시킬 수 있다.
private val testDispatcher = StandardTestDispatcher()
@Before
fun setUp() {
testDispatcher = StandardTestDispatcher()
Dispatchers.setMain(testDispatcher)
}
@After
fun tearDown() {
Dispatchers.resetMain()
}
안드로이드는 기본 디스패처로 Dispatchers.Main을 사용하고 있다.
Dispatchers.setMain() 함수를 사용하면 메인 디스패처를 잠시 StandardTestDispatcher로 교체할 수 있다.
이제 함수 내부에서 어떤 스코프의 코루틴을 시작하든, 그다지 중요하지 않다.
모든 코루틴은 StandardTestDispatcher에서 실행된다.
class MainViewModelTests {
private lateinit var scheduler: TestCoroutineScheduler
private lateinit var viewModel: MainViewModel
@BeforeEach
fun setUp() {
scheduler = TestCoroutineScheduler()
Dispatchers.setMain(StandardTestDispatcher(scheduler))
viewModel = MainVievModel(
userRepo = FakeUserRepository(aName),
newsRepo = FakeNewsRepository(someNews),
)
}
@AfterEach
fun tearDown() {
Dispatchers.resetMain()
viewModel.onCleared()
}
@Test
fun `should show user name and sorted news`() {
// when
viewModel.onCreate()
scheduler.advanceUntilIdle()
// then
assertEquals(aName, viewModel.userName.value)
val someNewsSorted =
listOf(News(date1), News(date2), News(date3))
assertEquals(someNewsSorted, viewModel.news.value)
}
@Test
fun `should show progress bar when loading news`() {
// given
assertEquals(null, viewModel.progressVisible.value)
// when
viewModel.onCreate()
// then
assertEquals(false, viewModel.progressVisible.value)
// when
scheduler.advanceTimeBy(200)
// then
assertEquals(true, viewModel.progressVisible.value)
// when
scheduler.runCurrent()
// then
assertEquals(false, viewModel.progressVisible.value)
}
@Test
fun `user and news are called concurrently`() {
// when
viewModel.onCreate()
scheduler.advanceUtilIdle()
// then
assertEquals(300, testDispatcher.currentTime)
}
class FakeUserRepository(
private val name: String
) : UserRepository {
override suspend fun getUser(): UserOata {
delay(300)
return UserOata(name)
}
}
class FakeNewsRepository(
private val news: List<News>
) : NewsRepository {
override suspend fun getNews(): List<News> {
delay(200)
return news
}
}
}
setup()에서 메인 디스패처를 설정했고, tearDown()에서 메인 디스패처를 원래대로 돌려놓았다.
이렇게 하면 onCreate() 함수 내부에서 시작되는 코루틴은 testDispatcher에서 실행되므로 시간을 조작할 수 있다.
advanceTimeBy() 함수를 사용해 특정 시간을 흐르게 할 수도 있고, advanceUntilIdle()을 사용해 모든 코루틴을 즉시 실행시킬 수도 있다.
룰이 있는 테스트 디스패처 설정하기
JUnit4에서는 룰 클래스를 사용할 수 있다.
룰은 테스트 클래스가 살아있을 동안 반드시 실행되어야 할 로직을 포함하는 클래스다.
테스트가 시작되기 전과 끝난 뒤에 실행해야 할 것들을 정의할 수 있기 때문에, 테스트 디스패처를 설정하고 해제하는 데 사용하기 좋다.
class MainCoroutineRule : TestWatcher() {
lateinit var scheduler: TestCoroutineScheduler
private set
lateinit var dispatcher: TestDispatcher
private set
override fun starting(description: Description) {
scheduler = TestCoroutineScheduler()
dispatcher = StandardTestDispatcher(scheduler)
Dispatchers.setMain(dispatcher)
}
override fun finished(description: Description) {
Dispatchers.resetMain()
}
}
룰을 구현하기 위해 TestWatcher를 상속받는다.
TestWatcher는 starting()과 finished()와 같은 수명 주기 메서드를 제공한다.
class MainViewModelTests {
@get:Rule
var mainCoroutineRule = MainCoroutineRule()
// ...
@Test
fun `should show user name and sorted news`() {
// when
viewModel.onCreate()
mainCoroutineRule.scheduler.advanceUntilIdle()
// then
assertEquals(aName, viewModel.userName.value)
val someNewsSorted =
listOf(News(date1), News(date2), News(date3))
assertEquals(someNewsSorted, viewModel.news.value)
}
@Test
fun `should show progress bar when loading news`() {
// given
assertEquals(null, viewModel.progressVisible.value)
// when
viewModel.onCreate()
// then
assertEquals(false, viewModel.progressVisible.value)
// when
mainCoroutineRule.scheduler.advanceTimeBy(200)
// then
assertEquals(true, viewModel.progressVisible.value)
}
@Test
fun `user and news are called concurrently`() {
// when
viewModel.onCreate()
mainCoroutineRule.scheduler.advanceUntilIdle()
// then
assertEquals(300, mainCoroutineRule.currentTime)
}
}
위 방식은 안드로이드에서 코루틴을 테스트할 때 자주 사용된다.
룰을 사용했기 때문에 각각의 테스트가 시작되기 전에 TestDispatcher가 메인 디스패처로 설정된다.
각 테스트가 끝난 뒤에 메인 디스패처는 원래 상태로 초기화된다.
@ExperimentalCoroutinesApi
class MainCoroutineExtension :
BeforeEachCallback, AfterEachCallback {
lateinit var scheduler: TestCoroutineScheduler
private set
lateinit var dispatcher: TestDispatcher
private set
override fun beforeEach(context: ExtensionContext?) {
scheduler = TestCoroutineScheduler()
dispatcher = StandardTestDispatcher(scheduler)
Dispatchers.setMain(dispatcher)
}
override fun afterEach(context: ExtensionContext?) {
Dispatchers.resetMain()
}
}
JUnit5에서는 룰 대신 Extension 클래스를 정의할 수 있다.
@JvmField
@RegisterExtension
var mainCoroutineExtension = MainCoroutineExtension()
MainCoroutineExtension을 사용하는 방법은 룰과 거의 동일하다.
@get:Rule 어노테이션 대신 @JvmField와 @RegisterExtension을 사용해야 한다는 점이 다르다.
출처
'app > kotlin' 카테고리의 다른 글
2.10 코틀린 코루틴 라이브러리 - 코틀린 코루틴 테스트하기 (1) (0) | 2025.01.19 |
---|---|
2.9 코틀린 코루틴 라이브러리 - 공유 상태로 인한 문제 (0) | 2025.01.18 |
2.8 코틀린 코루틴 라이브러리 - 코루틴 스코프 만들기 (0) | 2025.01.17 |
2.7 코틀린 코루틴 라이브러리 - 디스패처 (2) | 2025.01.16 |
2.6 코틀린 코루틴 라이브러리 - 코루틴 스코프 함수 (0) | 2025.01.14 |