[译]在 Android 中测试 Kotlin 协程

原文:Unit Testing with Kotlin Coroutines: The Android Way

在我们的应用测试策略中,单元测试是基本测试。通过单元测试,我们可以验证我们编写的单个逻辑是否正确。我们可以在每次构建项目后运行这些单元测试,确保任何新的改动不会对影响到现有的代码。因此,单元测试可帮助我们在发版之前快速捕获并修复问题。

如果你是 Android 开发的新手,或者已经有多年的经验,你现在应该对 Kotlin 非常熟悉了。Android 在 API30 弃用了 AsyncTask 之后,最好的选择是使用 RxJava 或 Kotlin 的异步编程协程。本文是关于 Kotlin 的协程和测试它们的不同方法的。我们还将继续探索新的支持库和最佳实践。

我们将学习什么?

  1. 使用 runBlocking() ​和 runBlockingTest()​ 测试挂起函数;
  2. 使用 TestCoroutineDispatcher ​和 MainCoroutineRule ​测试运行在 Dispatchers.Main ​上面的协程;
  3. 注入 Dispatcher​(调度器)以测试除了 Dispatchers.Main ​之外的协程调度器,比如 Dispatchers.IO​;
  4. 使用 InstantTaskExecutorRule ​测试 LiveData;
  5. 使用 LiveDataTestUtil ​监听 LiveData

配置我们的项目

为了演示该项目,我们将使用以下三个依赖库:

//For using viewModelScope
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.3.0"
//For runBlockingTest, CoroutineDispatcher etc.
testImplementation “org.jetbrains.kotlinx:kotlinx-coroutines-test:1.4.2”
//For  InstantTaskExecutorRule
testImplementation “androidx.arch.core:core-testing:2.1.0”

使用 runBlocking() ​和 runBlockingTest()​ 测试挂起函数

挂起函数(suspending function)指的是可以挂起并在后续恢复的函数。这些函数能够执行耗时操作,直至执行完毕也不会阻塞。挂起函数只能在另一个挂起函数或者协程中调用。

runBlocking()

如果我们的应用程序有任何挂起函数,则必须从另一个挂起函数调用它或从协程启动它。我们可以通过 runBlocking() ​函数来解决这个问题。它会启动一个新的协程,并阻塞当前的线程直至代码运行完毕。让我们来看一个例子:

class MainViewModel : ViewModel() {











    private var isSessionExpired = false






    suspend fun checkSessionExpiry(): Boolean {

        withContext(Dispatchers.IO) {

            delay(5_000) // to simulate a heavy weight operations

            isSessionExpired = true

        }





        return isSessionExpired

    }

}

class MainViewModelTest {





    @Test


    fun testCheckSessionExpiry() = runBlocking {
        val mainViewModel = MainViewModel()






        val totalExecutionTime = measureTimeMillis {

            val isSessionExpired = mainViewModel.checkSessionExpiry()

            assertTrue(isSessionExpired)

        }







        print("Total Execution Time: $totalExecutionTime")

    }





}

在上面的示例中,MainViewModel​ 中有个 checkSessionExpiry()​ 函数,该函数会校验用户的 session 是否过期并返回结果。为了模拟 IO 任务,我们使用了 delay​ 函数。在 MainViewModelTest​ 类中,我们有一个校验返回值是否为 true​ 的测试函数。如果你运行的话 它将通过。但你会注意有个 5 秒的延迟,这是因为 runBlocking()​ 不能跳过 delay​ 操作。

runBlockingTest()

runBlockingTest() ​是 kotlinx.coroutines.runBlock 包的一部分。类似于函数,但它将立即越过延迟并进入 launch ​和 async ​代码块。你可以使用它来编写在存在延迟调用的情况下执行的测试,而不会花费额外的时间。如果我们使用 runBlockingTest() ​来运行前面的测试示例的话就不会产生延迟:

class MainViewModel : ViewModel() {











    private var isSessionExpired = false






    suspend fun checkSessionExpiry(): Boolean {

        withContext(Dispatchers.IO) {

            delay(5_000) // to simulate a heavy weight operations

            isSessionExpired = true

        }





        return isSessionExpired

    }

}

class MainViewModelTest {

    @ExperimentalCoroutinesApi

    @Test


    fun testCheckSessionExpiry() = runBlockingTest {
        val mainViewModel = MainViewModel()






        val totalExecutionTime = measureTimeMillis {

            val isSessionExpired = mainViewModel.checkSessionExpiry()

            assertTrue(isSessionExpired)

        }







        print("Total Execution Time: $totalExecutionTime")

    }





}

注意:

  1. 此测试被标记为 ExperimentalCoroutinesApi​,因此将来可能会更改;
  2. 永远不要在你的应用中使用 runBlocking()​,它仅适用于单元测试。

使用 TestCoroutineDispatcher​ 和 MainCoroutineRule​ 测试运行在 Dispatchers.Main​ 上面的协程

TestCoroutineDispatcher

我们已经使用 runBlocking() ​和 runBlockingTest() ​成功编写了一个单元测试,但你是否注意到我们所有的测试都是在 Dispatcher.IO ​线程中启动的?如果我们使用 Dispatcher.Main ​替代 Dispatcher.IO ​会怎么样呢?来看一个例子:

class MainViewModel : ViewModel() {











    private var userData: Any? = null



    fun getUserData(): Any? = userData







    suspend fun saveSessionData() {



        viewModelScope.launch {

            userData = "some_user_data"



        }





    }



}



class MainViewModelTest {


    @ExperimentalCoroutinesApi

    @Test


    fun testsSaveSessionData() = runBlockingTest {
        val mainViewModel = MainViewModel()






        mainViewModel.saveSessionData()


        val userData = mainViewModel.getUserData()
        assertEquals("some_user_data", userData)
    }

}

注意:

  1. viewModelScope ​位于 androidx.lifecycle 包中,这个作用域与 ViewModel ​绑定,会在 ViewModel 被清除时调用,即 ViewModel.onCleared() ​被调用时;
  2. 默认情况下,viewModelScope ​总是运行在主线程上,但你可以通过配置 Dispatcher 来让它运行在其他线程上。

如果我们尝试在 Dispatcher.Main ​线程中运行单元测试,就会失败并报出如下错误

Exception in thread "main @coroutine#1" java.lang.IllegalStateException: Module with the Main dispatcher had failed to initialize. For tests Dispatchers.setMain from kotlinx-coroutines-test module can be used

这是正常的,因为我们不能在单元测试中使用主循环器(main looper​)。让我们试试将 Dispatcher ​替换为 TestCoroutineDispatcher​。

class MainViewModel : ViewModel() {











    private var userData: Any? = null



    fun getUserData(): Any? = userData







    suspend fun saveSessionData() {



        viewModelScope.launch {

            userData = "some_user_data"



        }





    }



}






@ExperimentalCoroutinesApi


class MainViewModelTest {





    @ExperimentalCoroutinesApi


    private val testDispatcher = TestCoroutineDispatcher()




    @Before
    fun setup() {
        // 1
        Dispatchers.setMain(testDispatcher)
    }



    @After
    fun tearDown() {
        // 2
        Dispatchers.resetMain()
        // 3
        testDispatcher.cleanupTestCoroutines()
    }


    @ExperimentalCoroutinesApi
    @Test
    fun testsSaveSessionData() = runBlockingTest {
        val mainViewModel = MainViewModel()

        mainViewModel.saveSessionData()

        val userData = mainViewModel.getUserData()
        assertEquals("some_user_data", userData)
    }

}

代码分析:

  1. 所有在底层使用了 Dispatchers.Main ​的地方都会被替换成我们给定的 testDispatcher​;
  2. Dispatchers.Main ​的状态重置为原始的主调度器(main dispatcher);
  3. 清除 TestCoroutineDispatcher ​以确保没有其他操作在运行。

MainCoroutineRule

到目前为止,我们已经知道如何测试 Dispatcher.IO ​和 Dispatcher.Main ​中的协程。默认情况下,协程起作用于 Dispatcher.Main​,若想测试 Dispatcher.IO​,我们必须使用 TestCoroutineDispatcher ​将主调度器替换掉。这个可以有效运行,却不能很好地扩展。每当我们增加测试类时,都必须一次又一次地写相同的样板代码。为了克服这个问题,我们可以创建自定义的 MainCoroutineRule,然后通过 @Rule ​注解添加到我们的测试类中。

@ExperimentalCoroutinesApi
class MainCoroutineRule : TestWatcher(), TestCoroutineScope by TestCoroutineScope() {

    override fun starting(description: Description) {
        super.starting(description)
       Dispatchers.setMain(this.coroutineContext[ContinuationInterceptor] as CoroutineDispatcher)
    }

    override fun finished(description: Description) {
        super.finished(description)
        Dispatchers.resetMain()
    }





}

MainCoroutineRule ​会在单元测试时将主协程调度器设置为 TestCoroutineScope​。TestCoroutineScope ​会控制协程的运行。我们只需像 @Rule ​一样使用它即可:

class MainViewModel : ViewModel() {











    private var userData: Any? = null



    fun getUserData(): Any? = userData







    suspend fun saveSessionData() {



        viewModelScope.launch(Dispatchers.IO) {

            userData = "some_user_data"



        }





    }



}






@ExperimentalCoroutinesApi


class MainViewModelTest {





    @ExperimentalCoroutinesApi


    @get:Rule

    var mainCoroutineRule = MainCoroutineRule()






    @ExperimentalCoroutinesApi



    @Test
    fun testsSaveSessionData() = runBlockingTest {
        val mainViewModel = MainViewModel()


        mainViewModel.saveSessionData()




        val userData = mainViewModel.getUserData()
        assertEquals("some_user_data", userData)
    }


}

注入 Dispatcher​(调度器)以测试除了 Dispatchers.Main​ 之外的协程调度器,比如 Dispatchers.IO

前面我们已经探索了如何在 Dispatchers.Main​ 和 Dispatchers.IO ​上测试协程。不过,如果我们要动态地改变单元测试的调度器的话会出现什么情况呢?来看看下面的例子:

class MainViewModel : ViewModel() {











    private var userData: Any? = null



    fun getUserData(): Any? = userData







    suspend fun saveSessionData() {



        viewModelScope.launch(Dispatchers.IO) {

            userData = "some_user_data"



        }





    }



}






@ExperimentalCoroutinesApi


class MainViewModelTest {





    @ExperimentalCoroutinesApi


    @get:Rule

    var mainCoroutineRule = MainCoroutineRule()






    @Test
    fun testsSaveSessionData() = runBlockingTest {
        val mainViewModel = MainViewModel()






        mainViewModel.saveSessionData()


        val userData = mainViewModel.getUserData()
        assertEquals("some_user_data", userData)
    }





}

在这里,我们通过能够接管主调度器MainDispatcher)的 MainCoroutineRule ​将 TestCorotutineDispatcher ​设为了主调度器,然而我们还是无法控制通过 viewModelScope.launch(Dispatchers.IO) ​启动的协程的运行。与此同时,如果我们运行测试的话就会显示断言错误(assertion error),因为我们的测试是运行在了与 ViewModel ​的线程不不同的线程中。现在,我们要如何解决这个问题呢?

引自谷歌:

为了准确的测试,Dispatchers ​应当注入到 ViewModels ​中。将 Dispatcher ​传入到 ViewModel ​的构造方法中能够确保你使用同一调度器测试和编写代码。

我们修改后的 ViewModel ​和测试类的代码会是这样的:

class MainViewModel (
    private val dispatcher: CoroutineDispatcher


) : ViewModel() {







    private var userData: Any? = null
    fun getUserData(): Any? = userData



    suspend fun saveSessionData() {


        viewModelScope.launch(dispatcher) {


            userData = "some_user_data"
        }


    }



}






@ExperimentalCoroutinesApi


class MainViewModelTest {






    private val testDispatcher = TestCoroutineDispatcher()







    @ExperimentalCoroutinesApi



    @get:Rule


    var mainCoroutineRule = MainCoroutineRule()








    @Test

    fun testsSaveSessionData() = runBlockingTest {

        val mainViewModel = MainViewModel(testDispatcher)



        mainViewModel.saveSessionData()





        val userData = mainViewModel.getUserData()
        assertEquals("some_user_data", userData)

    }



}

运行测试,现在应该没问题了。

使用 InstantTaskExecutorRule​ 测试 LiveData;

至此,我们已经学习了测试协程所需的全部知识。作为额外奖励,我想谈谈 LiveData ​的测试。让我们从以下的示例开始吧:

class MainViewModel(

    private val dispatcher: CoroutineDispatcher


) : ViewModel() {







    private var _userData: MutableLiveData<Any> = MutableLiveData<Any>()

    val userData: LiveData<Any> = _userData




    suspend fun saveSessionData() {


        viewModelScope.launch(dispatcher) {


            _userData.value = "some_user_data"

        }


    }



}






@ExperimentalCoroutinesApi


class MainViewModelTest {






    private val testDispatcher = TestCoroutineDispatcher()







    @ExperimentalCoroutinesApi



    @get:Rule


    var mainCoroutineRule = MainCoroutineRule()








    @Test

    fun testsSaveSessionData() = runBlockingTest {

        val mainViewModel = MainViewModel(testDispatcher)



        mainViewModel.saveSessionData()





        val userData = mainViewModel.userData.value
        assertEquals("some_user_data", userData)

    }



}

看似是个完美的测试,可一旦运行就会报如下错误了:

Exception in thread "main @coroutine#2" java.lang.RuntimeException: Method getMainLooper in android.os.Looper not mocked. See http://g.co/androidstudio/not-mocked for details. at android.os.Looper.getMainLooper(Looper.java)

这是由于 LiveData ​是属于 Android 生命周期的一部分,它需要访问主循环器(main looper)才能运行下去。好在我们一行代码就可以解决这个问题:只需将 androidx.arch.core.executor.testing 包中 InstantTaskExecutorRule ​作为 @Rule ​添加到代码中。现在重新运行,应该就会通过。

class MainViewModel(

    private val dispatcher: CoroutineDispatcher


) : ViewModel() {







    private var _userData: MutableLiveData<Any> = MutableLiveData<Any>()

    val userData: LiveData<Any> = _userData




    suspend fun saveSessionData() {


        viewModelScope.launch(dispatcher) {


            _userData.value = "some_user_data"

        }


    }



}






@ExperimentalCoroutinesApi


class MainViewModelTest {






    private val testDispatcher = TestCoroutineDispatcher()







    @ExperimentalCoroutinesApi



    @get:Rule


    var mainCoroutineRule = MainCoroutineRule()








    @get:Rule
    var instantExecutorRule = InstantTaskExecutorRule()




    @Test
    fun testsSaveSessionData() = runBlockingTest {
        val mainViewModel = MainViewModel(testDispatcher)


        mainViewModel.saveSessionData()


        val userData = mainViewModel.userData.value
        assertEquals("some_user_data", userData)
    }

}

InstantTaskExecutorRule ​是一个 JUnit ​测试规则,它将架构组件使用的后台执行程序与同步执行每个任务的执行程序交换。

使用 LiveDataTestUtil​ 监听 LiveData

如果你想监听 LiveData ​的变化,可以使用扩展函数 LiveDataTestUtil​,它可以帮助你毫不费力地达到目的:

@VisibleForTesting(otherwise = VisibleForTesting.NONE)
fun <T> LiveData<T>.getOrAwaitValue(
    time: Long = 2,
    timeUnit: TimeUnit = TimeUnit.SECONDS,
    afterObserve: () -> Unit = {}
): T {
    var data: T? = null
    val latch = CountDownLatch(1)
    val observer = object : Observer<T> {
        override fun onChanged(o: T?) {
            data = o
            latch.countDown()
            this@getOrAwaitValue.removeObserver(this)
        }
    }
    this.observeForever(observer)




    try {
        afterObserve.invoke()


        // Don't wait indefinitely if the LiveData is not set.
        if (!latch.await(time, timeUnit)) {
            throw TimeoutException("LiveData value was never set.")
        }


    } finally {
        this.removeObserver(observer)
    }





    @Suppress("UNCHECKED_CAST")
    return data as T
}

© 版权声明
THE END
喜欢就支持一下吧
点赞0

Warning: mysqli_query(): (HY000/3): Error writing file '/tmp/MYPQhAoS' (Errcode: 28 - No space left on device) in /www/wwwroot/583.cn/wp-includes/class-wpdb.php on line 2345
admin的头像-五八三
评论 抢沙发
头像
欢迎您留下宝贵的见解!
提交
头像

昵称

图形验证码
取消
昵称代码图片