프로덕션 앱 기능 개발과 테스트 코드 개발은 차이가 있습니다. 그 핵심엔 Dispatcher
가 있습니다.
참고 : 공식 홈페이지 : Android에서 Kotlin 코루틴 테스트
아래의 환경을 가정해보겠습니다.
대규모 앱 프로젝트 내, 5만개의 테스트 코드가 존재하며 1개의 테스트 코드당 5초의 딜레이가 걸린다.
위 상황에서프로젝트의 빌드 및 테스트 코드 실행 시, 25만초가 걸리게 됩니다. 그리고 이는 테스트 코드 검사까지 최대 70시간이 걸릴 수 있음을 의미하며 회사 차원에서 아주 큰 비효율을 초래합니다.
따라서 테스트 코드를 작성하며 시간 의존 코드를 작성할 땐, 일반 시간이 아닌 '가상 시간'에 의존하게 작성해야 합니다.
[가상시간이란?]
테스트 코드에서 10초가 흘렀다고 가정할 때, 실제 10초가 흐르지 않았음에도 불구, 런타임 테스트 환경에서 10초의 경과를 나타내주는 것을 의미합니다. 예를 들어,advanaceTimeBy(10_000L)
와 같은 메서드 호출시, 10초가 흐르지 않았음에도 불구,(몇 ms만에 끝남) 로그를 찍어보면 10초가 경과했다고 나옵니다. 이를 '가상시간 10초가 흘렀다' 라고 표현 합니다.
이런 이유로 테스트 코드를 작성할 때엔 '시간 의존성'을 염두해두고 작성해야 하며, 이때 알아야할 것이 바로 TestDispatcher
입니다.
여기서 Dispacher
란, CoroutineContext
의 하위 개념으로, 비동기 작업(=코루틴)을 어떤 스레드 위에서 실행시킬지를 나타내는 CoroutineContext
의 하위 Element입니다.
우린 프로덕션 개발시엔 IO
, Main
, Default
를 많이 씁니다. 하지만 위에서 말했다시피, 테스트 환경에선 시간 의존성을 줄이는 것이 매우 중요하기에 새로운 TestDispatchers
를 사용합니다. 이 디스패처는 테스트 코드 내에서 시간 의존적인 코드를 작성할 때, '실제 시간'이 아닌, '가상 시간'이 흐르게 함으로써 테스트 코드의 실행을 매우 빨리 끝낼 수 있도록 도와주게 됩니다. 이러한 디스패처로는 StandardTestDispatcher
와 UnconfinedTestDispatcher
가 있습니다.
공식 홈페이지의 설명입니다.
이해에 어려움이 있을 수도 있어 풀어 설명해보고자 합니다.
코루틴은 여러 작업을 1개의 Thread
위에서 수많은 Object Switching
을 통해 실행시키는 동시성 라이브러이 입니다. 이를 Android관점에서 말해보면, 1개의 Main스레드 위에 수많은 코루틴들이 Object Switching
작업을 통해 동시성 작업이 진행된다는 의미입니다.
이러한 과정들은 CoroutineScheduler
에 의해 일어나며, 1개의 Thread
위에서 여러 코루틴 작업의 효율적인 배치를 선택하며 이뤄집니다.
StandardTestDispatcher
또한 이와 같습니다. 다만, 아래와 같은 차이가 있습니다.
MainThread
가 아닌, TestThread
이다.CoroutineScheduler
가 아닌, CoroutineTestScheduler
이다.참고 : 공식 홈페이지 Main 디스패처 설정
위처럼 TestThread
에서 단위 테스트 코드를 실행했을때의 이점이 무엇일까요? 바로 '시간 의존적인 코드를 세밀하게 테스트할 수 있다'는 점입니다.
우린 단위 테스트 코드를 작성할 때, viewModel로부터 데이터를 가져오는 테스트를 진행합니다. 이때, 추가적인 코루틴 빌더를 사용해가며 하위에 추가적인 테스트를 진행합니다.
@Test
fun whenAppFirstLoading_thenNewsListAreLoading() = runTest {
val job = launch {
viewModel.newsUiState.collect()
}
// ...
}
이때 위, viewModel.newsUiState.collect()
가 몇초의 응답이 걸릴지는 모릅니다. 따라서 테스트 코드상에서 몇초동안 기다릴 것인지를 지정할 수가 있습니다.
@Test
fun whenAppFirstLoading_thenNewsListAreLoading() = runTest {
val job = launch {
viewModel.newsUiState.collect()
}
advancedUntilIdle() // 밀려있는 코루틴 실행이 모두 끝날때까지 기다린다.
advanceTimeBy(10_000L) // 10초의 가상시간을 흐르게하며 그때까지만 기다린다.
}
StandardTestDispatcher
를 사용하면 위처럼 시간 의존적인 코드의 상세한 컨트롤을 통해 시간을 좀 더 세밀하게 제어할 수 있다는 장점이 존재합니다.
따라서 동시성 테스트 코드를 작성시, 시간을 좀 더 세밀하게 제어하고자 한다면 StandardTestDispatcher
를 사용하면 됩니다.
[참고]
runTest
를 테스트 전용 코루틴 빌더로 사용하고 있다면 아무 작업도 하지 않아도 됩니다.
여기서 퀴즈를 하나 내볼까 합니다. 아래의 코드는 실패합니다. 왜 그럴까요?
@Test
fun standardTest() = runTest {
val userRepo = UserRepository()
launch { userRepo.register("Alice") }
launch { userRepo.register("Bob") }
assertEquals(listOf("Alice", "Bob"), userRepo.getAllUsers()) // ❌ Fails
}
참고 : 공식 홈페이지 StandardTestDispathcer
실패 이유는, 비동기 작업(=userRepo.register
) 완료 전에, assertEquals()의 실행 때문입니다. 테스트 코드가 가상시간동안 기다리게 하고싶다면advanceXXX
메서드를 사용하면 됩니다.
시작하기 전, Dispatchers.Unconfined
를 간단히 짚고 넘어가고자 합니다. 이는 다른 Dispatcher와는 다른 특이한 CoroutineContext
로써, 마지막으로 실행되었던 스레드를 기반으로 다음 코루틴의 실행 스레드를 자동 결정하는 디스패처입니다.
참조 : Unconfined동작 방법
일전에 StandardTestDispatcher
는 CoroutineTestScheduler
를 사용하여 테스트 코루틴에게 TestThread
의 자원할당이 진행된다고 말씀드렸습니다.
하지만 UnconfinedTestDispatcher
는 이와 다르게 CoroutineTestScheduler
를 사용한 TestThread
의 할당 작업이 없습니다. 즉, Test Thread
사용 가능 여부를 고려하지 않은 채, 하위 코루틴을 Eagerly하게 실행시킨다는 의미힙니다.
이런 이유로 장점이 존재합니다. 만약 테스트 코드가 매우 간단하고 세부적인 시간 의존적인 테스트 환경이 필요하지 않을 경우, UnconfinedTestDispatcher
를 고려할 수 있으며, 테스트 코드가 매우 간단해지며 깔끔해진다는 장점도 있습니다.
마찬가지로 퀴즈 하나 낼까 합니다. 아래의 경우 테스트가 성공합니다. 왜 그럴까요?
@Test
fun unconfinedTest() = runTest(UnconfinedTestDispatcher()) {
val userRepo = UserRepository()
launch { userRepo.register("Alice") }
launch { userRepo.register("Bob") }
assertEquals(listOf("Alice", "Bob"), userRepo.getAllUsers()) // ✅ Passes
}
성공 이유는 하위 코루틴 스케줄링 작업 없이, Test 스레드 생성 작업 없이, 하위 코루틴이 생성되었으며, 마지막 코루틴의 스레드가 다음 코루틴의 실행 스레드를 그대로 물려주었기 때문입니다. 따라서 결국 1개의 스레드만 사용하여 작업이 진행되었다고 볼 수 있습니다.
따라서 UnconfinedTestDispatcher
는 스케줄링 작업에 따른 딜레이 없이 순수 기능 테스트에만 집중하고 싶을 때 사용하면 좋습니다.
Now In Android에서 사용중입니다.
해당 커밋 로그는 2022년 6월에 바뀐 로그입니다. Google 팀도 처음엔 StandardTestDispatcher
를 사용한걸 알 수 있습니다. 이는 테스트 코드의 시간 의존적인 확장성을 고려해 사용했던 것으로 추정합니다.
하지만, 앱이 커지고, 시간 의존성인 단위 테스팅 환경이 불필요하다고 느꼈는지, 그리고 좀 더 깔끔한 단위 테스팅 코드로 개발하는게 이점이 더 크다고 판단했는지, UnconfinedTestDispatcher
로 교체한 모습을 보이고 있습니다.
ps. 그리고 해당 모듈은 UI Layer의 ViewModel테스트와 Domain Layer의 UseCase 테스트시 사용하는걸 볼 수 있습니다.