본문으로 바로가기
반응형

안드로이드 2019 Dev Summit에서 발표된 Testing Coroutines 자료를 정리합니다.


Coroutines-first

안드로이드 UI Component를 개발할때 코루틴을 염두해 두고 개발합니다.
  • New APIs : 만약 새로운 API가 추가된다면 코루틴에 맞는지 살펴보고, API 대해 코루틴을 잘 지원할수 있는지 검토합니다.
  • Jetpack Library: 라이브러리를 빌드할 때도 코루틴을 사용하며, 라이브러리중 일부에서는 이미 그렇게 하고 있습니다.
  • Documentation: 안드로이드에서 코루틴을 사용하는 방법에 대한 develop.android.com에 문서화 작업을 진행하고 있습니다.

이미 4개의 라이브러리에서 안정적으로 즉시 사용 가능한 코루틴을 지원합니다

또한 나머지 기능들 역시 안정적인 버전 릴리즈를 대기하고 있습니다.


Room Test

corouitne test를 설명하기 위해 room을 가지고 간단한 테스트 환경을 만듭니다.

Todo.kt


Room에 문자열과 완료 표시를 저장할 수 있도록 Entity class를 정의 합니다. (아직 coroutine이 쓰이지는 않았습니다.)

TodoDatabase.kt

Dao에 suspend function을 추가합니다. 

addItem()suspend 한정자를 붙입니다. 그러면...

1. Main-safe

 - suspend가 있지만 없는것과 같이 동일하게 동작합니다.

2. Room은 해당 쿼리를 background thread에서 사용하며 이는 내부적으로 custom dispatcher 를 사용합니다.

 - 아마도 Room에서 NewSingleThreadContext()로 만들어 쓰지 않았을까 하는 개인적인 추측을 해 봅니다.

3. Cancellable

 - Coroutine suspend function으로 구성되어 취소도 가능해 집니다. 오오~~


items()의 selection 구문 역시 suspend function으로 만들어 위와 동일한 효과를 볼 수 있습니다.

LiveData에서 사용하듯이 Flow를 쓸수도 있지만 간단한 구현을 위해 suspend 함수로만 표현합니다.

(오호..이런데다가 flow를 쓰는군요.)


이제 dao를 호출해 봅니다.


Repository.kt

그리고 insertTodo()라는 함수를 두가지 타입으로 만들어 봅니다.

//방법 1
suspend fun insertTodo(text: String) {
    val todo = Todo(text = text)
    dao.addItem(todo)
    return dao.items()
}

//방법 2
fun insertTodo(text: String) = scope.async { //Deferred
    val todo = Todo(text = text)
    dao.addItem(todo)
    return dao.items()
}

두 구문의 차이점은 아래와 같습니다.


  • 둘다 suspend형태를 취합니다.
  • 하지만 scope에 종속되는 suspend function은 cancel 요청시 전부 같이 취소 되지만 위와 같이 자신의 scope을 만들어서 사용하는 deferred는 cancel을 따로 호출해줘야 합니다. (따로 관리하면서 신경써야 하는거죠)
  • 둘다 안정한 코드입니다.
  • Thread 사용 역시 크기 다르지 않습니다만!!! Thread가 동작하는 형태는 좀 다릅니다.
Deferred를 사용할경우 callback 형태로 값을 받아가야 합니다. (실제로 deferred.await()로 값을 반환 받습니다.)
따라서 관리하기 힘들뿐만 아니라 기본적으로 suspend function을 쓰는걸 권장합니다.

실제 정확한게 찝어서 얘기를 하지는 않지만, 함수의 반환타입이 suspend로 기존 couroutine에 종속 되느냐, 아니면 funtion 내부에서 새로운 scope으로 만들어서 반환하는냐에 대한 비교입니다.
당연히 suspend function으로 만들어서 사용하는게 권장되는 방법입니다.
이 이유에 대한 자세한 내용은 하기 링크 내부 Async-style functions 파트에 자세히 나와 있습니다.


ViewModel.kt

실제로 ViewModle에서 호출하는걸 보면 coroutine을 launch 시키고 그내부에서 insertTodo를 요청합니다.

insert 작업은 background에서 일어나지만 코드에서는 콜백 없이 바로 사용합니다. Awesome!!!!


API 설계시 주의 할 점

  • Prefer to suspend funtion
    • 기본적인 API로 suspend function을 제공하도록 합니다. deferred나 builder나 또는 복잡한 interface를 반환하지 않도록 합니다.
  • Have the owner launch
    • launch는 owner가 하도록 합니다. 따라서 owner가 전체의 coroutine을 관리하도록 하면 cancel하기 편해 지겠죠?
  • Trust main-safety
    • 내부를 파보고..이거 동작하는거 맞아? 라고 의심하지 마세요..잘 돌아요~~ 믿쑵니까~~~

Nature of a Good unit test

  • fast
    • 빠르게 진행된다. ( 실패하거나 통과할때 까지 기다리지 않아야함.)
  • Reliable
    • 언제나 동일한 결과를 제공해야 한다.
  • Isolated
    • 각 unit test간에는 서로 영향을 주지 않고 독립적 이어야 한다.

Coroutine을 테스트 할때는 두가지 상황에 대해 생각해야 합니다. (크게 두가지의 형태로 나눠볼수 있습니다.)
  1. Test code를 실행시 내부적으로 launch나 async를 하고 있다. (비동기 작업을 함수 내부에서 하는경우죠)
  2. Test code가 그저 suspend function을 호출하는 형태이다.

그리고 coroutine test를 위해 아래 라이브러리를 사용합니다.
kotlinx-coroutines-test
(아직은 experimental 이지만..곧 안정화 버전이 나오겠죠?)

Testing suspend functions

먼저 위에서 한번 언급된 insertTodo 함수를 test 하도록 하겠습니다.
다시 한번 코드를 보자면..
class TodoRepository(private val dao: TodoDao) {
    suspend fun insertTodo(text: String) {
         val todo = Todo(text = text)
        dao.addItem(todo)
       return dao.items()
    }
}

suspend function을 test하기 위해서 라이브러리에서 제공하는 runBlockingTest를 사용합니다.


위 함수를 테스트 하려면 아래 형태처럼 Test code를 작성하면 됩니다.

runBlockingTest 블럭 내부에서 insertTodo를 수행시키면 바로 값을 반환합니다.

(내부에 있는 delay는 skip해 버립니다.)

참 쉽죠잉?


Tests that trigger new coroutines

이제 테스트 대상 함수가 내부적으로 coroutine을 생성하는 경우에 대하여 테스트해 봅니다.


위에서 이미 사용했던 runBlockingTest를 이용하여 addItem을 호출한다면 어떨까요?

네 실패합니다.

addItem에서 사용하는 viewModleScopeDispathers.Main을 사용합니다.

하지만 runBlockingTest는 Test thread를 사용하기 때문에 코드가 synchronouse 하게 흘러가지 못합니다.

이런식으로 서로 다른 thread에서 동작하니 실패하게 됩니다.

따라서 이를 테스트 하기위해 Test thread를 기다리게 만드는 방법을 사용할 수 있습니다.

뭔가를 이용해서 check 함수를 기다리게 만듭니다.

그리고 함수 내부 결과가 끝날때 까지 기다립니다.

하지만 이런경우 테스트의 속도가 느려지며, 하물며 모든 unit test에 이런 식으로 동작한다면 우리가 원했던 빠른 테스트라는 요건을 만족하기는 힘듭니다.


TestCoroutineDispatcher

Dispatchers.Main은 안드로이드에서 Main thread의 looper를 이용합니다.
어떻게 하든 Dispatchers.Main은 Unit test에서 사용할 수 없으니, 이를 TestCoroutineDispatcher로 바꿔서 사용하도록 합니다.

먼저 아래와 같이 setup() 과 tearDown을 생성합니다.

그리고 매번 이 코드를 넣을 수 없으니 JUnit에서 제공하는 rule을 이용하여 아래와 같이 선언합니다.

아래와 같이 rule로 생성된 TestCoroutineDispatcher를 이용하여 runBlockingTest를 사용하면 Dispatchers.MainTestCoroutineDispatcher로 치환됩니다.

그럼 원하는대로 아래 처럼 동작하겠죠?

하지만..

Dispatchers.Main이 아닌 다른 Dispatcher를 사용하는 경우 여전히 문제가 발생합니다.

예를들어 viewModelScope에서 launch를 할때 Dispatchers.Default를 주면 아래와 같은 상황이 또 발생 합니다.


사실 이런 상황이 발생하는 이유는 dispatcher를 code에 hardcoding하기 때문입니다.

따라서 Test를 하기 위해서는 아래와 같이 injection 할수 있는 코드의 형태를 권장합니다.

그렇다면 아래와 같이 dispatcher를 inject하여 정상적인 test를 할 수 있겠죠?

반응형