[코루틴 테스트] Dispatcher

SSY·2024년 4월 3일
0

Coroutine

목록 보기
7/7
post-thumbnail

☘️ 시작하며

프로덕션 앱 기능 개발과 테스트 코드 개발은 차이가 있습니다. 그 핵심엔 Dispatcher가 있습니다.

참고 : 공식 홈페이지 : Android에서 Kotlin 코루틴 테스트

🐝 Dispatcher

아래의 환경을 가정해보겠습니다.

대규모 앱 프로젝트 내, 5만개의 테스트 코드가 존재하며 1개의 테스트 코드당 5초의 딜레이가 걸린다.

위 상황에서프로젝트의 빌드 및 테스트 코드 실행 시, 25만초가 걸리게 됩니다. 그리고 이는 테스트 코드 검사까지 최대 70시간이 걸릴 수 있음을 의미하며 회사 차원에서 아주 큰 비효율을 초래합니다.

따라서 테스트 코드를 작성하며 시간 의존 코드를 작성할 땐, 일반 시간이 아닌 '가상 시간'에 의존하게 작성해야 합니다.

[가상시간이란?]
테스트 코드에서 10초가 흘렀다고 가정할 때, 실제 10초가 흐르지 않았음에도 불구, 런타임 테스트 환경에서 10초의 경과를 나타내주는 것을 의미합니다. 예를 들어, advanaceTimeBy(10_000L)와 같은 메서드 호출시, 10초가 흐르지 않았음에도 불구,(몇 ms만에 끝남) 로그를 찍어보면 10초가 경과했다고 나옵니다. 이를 '가상시간 10초가 흘렀다' 라고 표현 합니다.

이런 이유로 테스트 코드를 작성할 때엔 '시간 의존성'을 염두해두고 작성해야 하며, 이때 알아야할 것이 바로 TestDispatcher입니다.

여기서 Dispacher란, CoroutineContext의 하위 개념으로, 비동기 작업(=코루틴)을 어떤 스레드 위에서 실행시킬지를 나타내는 CoroutineContext의 하위 Element입니다.

우린 프로덕션 개발시엔 IO, Main, Default를 많이 씁니다. 하지만 위에서 말했다시피, 테스트 환경에선 시간 의존성을 줄이는 것이 매우 중요하기에 새로운 TestDispatchers를 사용합니다. 이 디스패처는 테스트 코드 내에서 시간 의존적인 코드를 작성할 때, '실제 시간'이 아닌, '가상 시간'이 흐르게 함으로써 테스트 코드의 실행을 매우 빨리 끝낼 수 있도록 도와주게 됩니다. 이러한 디스패처로는 StandardTestDispatcherUnconfinedTestDispatcher가 있습니다.

🦁 StandardTestDispatcher

공식 홈페이지의 설명입니다.

이해에 어려움이 있을 수도 있어 풀어 설명해보고자 합니다.

코루틴은 여러 작업을 1개의 Thread 위에서 수많은 Object Switching을 통해 실행시키는 동시성 라이브러이 입니다. 이를 Android관점에서 말해보면, 1개의 Main스레드 위에 수많은 코루틴들이 Object Switching작업을 통해 동시성 작업이 진행된다는 의미입니다.

참고 : [코틀린 코루틴] 동시성과 병렬성이란?

이러한 과정들은 CoroutineScheduler에 의해 일어나며, 1개의 Thread 위에서 여러 코루틴 작업의 효율적인 배치를 선택하며 이뤄집니다.

추가 : [코틀린 코루틴] 코루틴의 중단과 재개 CPS

StandardTestDispatcher또한 이와 같습니다. 다만, 아래와 같은 차이가 있습니다.

  • 단위 테스팅의 실행 스레드는 MainThread가 아닌, TestThread이다.
  • 코루틴에게 스레드 자원을 할당해주는 Scheduler가 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 메서드를 사용하면 됩니다.

🐥 UnconfinedTestDispatcher

시작하기 전, Dispatchers.Unconfined를 간단히 짚고 넘어가고자 합니다. 이는 다른 Dispatcher와는 다른 특이한 CoroutineContext로써, 마지막으로 실행되었던 스레드를 기반으로 다음 코루틴의 실행 스레드를 자동 결정하는 디스패처입니다.

참조 : Unconfined동작 방법

일전에 StandardTestDispatcherCoroutineTestScheduler를 사용하여 테스트 코루틴에게 TestThread의 자원할당이 진행된다고 말씀드렸습니다.

하지만 UnconfinedTestDispatcher는 이와 다르게 CoroutineTestScheduler를 사용한 TestThread의 할당 작업이 없습니다. 즉, Test Thread 사용 가능 여부를 고려하지 않은 채, 하위 코루틴을 Eagerly하게 실행시킨다는 의미힙니다.

이런 이유로 장점이 존재합니다. 만약 테스트 코드가 매우 간단하고 세부적인 시간 의존적인 테스트 환경이 필요하지 않을 경우, UnconfinedTestDispatcher를 고려할 수 있으며, 테스트 코드가 매우 간단해지며 깔끔해진다는 장점도 있습니다.

참고 : 공식 홈페이지 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는 스케줄링 작업에 따른 딜레이 없이 순수 기능 테스트에만 집중하고 싶을 때 사용하면 좋습니다.

🐹 UnconfinedTestDispatcher 사용 사례

Now In Android에서 사용중입니다.

해당 커밋 로그는 2022년 6월에 바뀐 로그입니다. Google 팀도 처음엔 StandardTestDispatcher를 사용한걸 알 수 있습니다. 이는 테스트 코드의 시간 의존적인 확장성을 고려해 사용했던 것으로 추정합니다.

하지만, 앱이 커지고, 시간 의존성인 단위 테스팅 환경이 불필요하다고 느꼈는지, 그리고 좀 더 깔끔한 단위 테스팅 코드로 개발하는게 이점이 더 크다고 판단했는지, UnconfinedTestDispatcher로 교체한 모습을 보이고 있습니다.

ps. 그리고 해당 모듈은 UI Layer의 ViewModel테스트와 Domain Layer의 UseCase 테스트시 사용하는걸 볼 수 있습니다.

profile
불가능보다 가능함에 몰입할 수 있는 개발자가 되기 위해 노력합니다.

0개의 댓글