深潜Kotlin协程(十五):测试 Kotlin 协程

Posted RikkaTheWorld

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了深潜Kotlin协程(十五):测试 Kotlin 协程相关的知识,希望对你有一定的参考价值。

系列电子书:传送门


在大多数情况下,测试挂起函数与测试正常函数没有什么不同。看看下面的 FetchUserUseCase.fetchUserData。通过伪造Fake(或模拟Mock)和简单的断言,我们可以很容易地测试它是否按预期显示数据:

class FetchUserUseCase(
    private val repo: UserDataRepository,
) 
    suspend fun fetchUserData(): User = coroutineScope 
        val name = async  repo.getName() 
        val friends = async  repo.getFriends() 
        val profile = async  repo.getProfile() 
        User(
            name = name.await(),
            friends = friends.await(),
            profile = profile.await()
        )
    


class FetchUserDataTest 
    @Test
    fun `should construct user`() = runBlocking 
        // given
        val repo = FakeUserDataRepository()
        val useCase = FetchUserUseCase(repo)
        
        // when
        val result = useCase.fetchUserData()
        
        // then
        val expectedUser = User(
            name = "Ben",
            friends = listOf(Friend("some-friend-id-1")),
            profile = Profile("Example description")
        )
        
        assertEquals(expectedUser, result)
    

    class FakeUserDataRepository : UserDataRepository 
        override suspend fun getName(): String = "Ben"
        
        override suspend fun getFriends(): List<Friend> =
            listOf(Friend("some-friend-id-1"))
        
        override suspend fun getProfile(): Profile =
            Profile("Example description")
    

上面的测试函数不应该作为参考标准。对于单元测试的表达有多种多样的方式。我在这里使用了 伪造fake 而不是 模拟mock,这样就不会引入任何三方库(我个人也更喜欢这种方式)。我还尝试让所有的测试都尽量简化,以使它们更容易阅读。

类似的,在许多情况下,如果我们需要测试挂起函数,实际上只需使用 runBlocking 和一些经典的断言工具。这就是单元测试在许多项目中的样子,下面是 Kt.Academy 后台项目的一个单元测试用例:

class UserTests : KtAcademyFacadeTest() 
    @Test
    fun `should modify user details`() = runBlocking 
        // given
        thereIsUser(aUserToken, aUserId)

        // when
        facade.updateUserSelf(
            aUserToken,
            PatchUserSelfRequest(
                bio = aUserBio,
                bioPl = aUserBioPl,
                publicKey = aUserPublicKey,
                customImageUrl = aCustomImageUrl
            )
        )

        // then
        with(findUser(aUserId)) 
            assertEquals(aUserBio, bio)
            assertEquals(aUserBioPl, bioPl)
            assertEquals(aUserPublicKey, publicKey)
            assertEquals(aCustomImageUrl, customImageUrl)
        
    
    //...

我们只使用 runBlocking,测试挂起函数和阻塞函数的行为几乎没有区别。

测试时间依赖性

但是当我们想要测试函数对时间依赖性时,差异就出现了。例如,请思考一下下面这个函数:

suspend fun produceCurrentUserSeq(): User 
    val profile = repo.getProfile()
    val friends = repo.getFriends()
    return User(profile, friends)


suspend fun produceCurrentUserSym(): User = coroutineScope 
    val profile = async  repo.getProfile() 
    val friends = async  repo.getFriends() 
    User(profile.await(), friends.await())

两个函数将会产生相同的结果,但不同的是:第一个是按顺序产生的,而第二个是同时进行的。如果获取配置文件和好友列表各需要1秒, 那么第一个函数需要大概2秒,而第二个函数只需要1秒,你将如何测试这种差异?

请注意,只有 getProfilegetFriends 执行确实是需要一些时间才会产生差异。如果它们都是即时的,两种产生用户信息的方式是无法区分的,因此,我们可以通过伪造耗时函数来模拟数据加载的场景:

class FakeDelayedUserDataRepository : UserDataRepository 
    override suspend fun getProfile(): Profile 
        delay(1000)
        return Profile("Example description")
    
    
    override suspend fun getFriends(): List<Friend> 
        delay(1000)
        return listOf(Friend("some-friend-id-1"))
    

现在,在单元测试中可以看到区别:调用 produceCurrentUserSeq 将花费大约1秒的时间,而调用 produceCurrentUserSym 则需要大约2秒的时间。问题是我们不希望单元测试花费这么多时间,我们的项目中通常有数千个单元测试,我们希望所有的测试都尽可能快的执行。如何兼得鱼和熊掌呢?答案是使用虚拟的时间。下面我们将介绍 kotln-coroutines-test 及其 StandardTestDispatcher

本章介绍了1.6版本中引入的 kotlin-coroutines-test 功能和类。如果你使用的是这个库的旧版本。在大多数情况下,用 runBlockingTest 代替 runTest,用 TestCoroutinesDispatcher 代替 StandardTestDispatcher ,用 TestCoroutineScope 代替 TestScope 就足够了。另外,旧版本中的 advanceTimeBy 类似于1.6以上版本中的 advanceTimeByrunCurrent

TestCoroutineScheduler 和 StandardTestDispatcher

当我们调用 delay 时,我们的协程被挂起并在一段时间后恢复。kotlinx-coroutines-test 中的 TestCoroutineScheduler 可以改变这种行为,它使得 delay 在虚拟时间上操作,这是完全模拟的,不依赖于实时。

fun main() 
    val scheduler = TestCoroutineScheduler()
    
    println(scheduler.currentTime) // 0
    scheduler.advanceTimeBy(1_000)
    println(scheduler.currentTime) // 1000
    scheduler.advanceTimeBy(1_000)
    println(scheduler.currentTime) // 2000

TestCoroutineScheduler 以及 StandardTestDispatcherTestScoperunTest 仍然是实验性的。

要在协程上使用 TestCoroutineScheduler ,我们应该使用一个调度器来协助它。标准的选择是 StandardTestDispatcher。与大多数调度器不同,它不仅仅用于决定协程在哪个线程上运行。除非我们不提前时间,否则它启动的协程不会运行。提前时间使用的最典型的方式是调用 advanceUntilIdle 函数,它会提前虚拟时间,并调用在此间的所有操作:

fun main() 
    val scheduler = TestCoroutineScheduler()
    val testDispatcher = StandardTestDispatcher(scheduler)
    
    CoroutineScope(testDispatcher).launch 
        println("Some work 1")
        delay(1000)
        println("Some work 2")
        delay(1000)
        println("Coroutine done")
    
    
    println("[$scheduler.currentTime] Before")
    scheduler.advanceUntilIdle()
    println("[$scheduler.currentTime] After")

// [0] Before
// Some work 1
// Some work 2
// Coroutine done
// [2000] After

StandardTestDispatcher 会默认创建一个 TestCoroutineScheduler ,所以我们不需要显式的创建它。我们可以通过 scheduler 属性来访问它。

fun main() 
    val dispatcher = StandardTestDispatcher()

    CoroutineScope(dispatcher).launch 
        println("Some work 1")
        delay(1000)
        println("Some work 2")
        delay(1000)
        println("Coroutine done")
    

    println("[$dispatcher.scheduler.currentTime] Before")
    dispatcher.scheduler.advanceUntilIdle()
    println("[$dispatcher.scheduler.currentTime] After")

// [0] Before
// Some work 1
// Some work 2
// Coroutine done
// [2000] After

重要的是,要注意 StandardTestDispatcher 本身不会提前时间,如果我们不额外操作,协程将永远不会恢复。

fun main() 
    val testDispatcher = StandardTestDispatcher()
    
    runBlocking(testDispatcher) 
        delay(1)
        println("Coroutine done")
    

// (代码永远运行下去)

另一种延迟的方式是使用 advanceTimeBy 和具体的毫秒数。该函数提前具体时间并执行在此期间发生的所有操作。这意味着如果我们延迟2ms,所有延迟小于2ms的内容都将被恢复。为了恢复恰好在第二毫秒调度的操作,我们需要额外调用 runCurrent 函数:

fun main() 
    val testDispatcher = StandardTestDispatcher()

    CoroutineScope(testDispatcher).launch 
        delay(1)
        println("Done1")
    

    CoroutineScope(testDispatcher).launch 
        delay(2)
        println("Done2")
    

    testDispatcher.scheduler.advanceTimeBy(2) // Done
    testDispatcher.scheduler.runCurrent() // Done2

下面是使用 advanceTimeByrunCurrent 的更大一点的例子。

fun main() 
    val testDispatcher = StandardTestDispatcher()

    CoroutineScope(testDispatcher).launch 
        delay(2)
        print("Done")
    

    CoroutineScope(testDispatcher).launch 
        delay(4)
        print("Done2")
    

    CoroutineScope(testDispatcher).launch 
        delay(6)
        print("Done3")
    

    for (i in 1..5) 
       print(".")
       testDispatcher.scheduler.advanceTimeBy(1)
       testDispatcher.scheduler.runCurrent()
    

// ..Done..Done2.

它的底层是如何工作的?当调用 delay 时,它检查 dispatcher(带有 CoroutinuationInterceptor 的键)是否实现了 Delay 接口(StandardTestDispatcher 实现了)。对于这样的调度器,将会调用它们的 scheduleResumeAfterDelay 函数,而不是实时等待的 DefaultDelay

为了了解虚拟时间真正独立于实时时间,请参见下面的示例。添加 Thread.sleep 并不会影响到 StandardTestDispatcher 的协程。还需注意,对 advanceUntilIdle 的调用只需要几毫秒。因此它不需要等待任何实时时间。它会立即推送虚拟时间并执行协程操作。

fun main() 
    val dispatcher = StandardTestDispatcher()

    CoroutineScope(dispatcher).launch 
        delay(1000)
        println("Coroutine done")
    

    Thread.sleep(Random.nextLong(2000)) // 这里睡眠多少秒都没有关系
    // 它不会影响结果
    
    val time = measureTimeMillis 
        println("[$dispatcher.scheduler.currentTime] Before")
        dispatcher.scheduler.advanceUntilIdle()
        println("[$dispatcher.scheduler.currentTime] After")
    
    println("Took $time ms")

// [0] Before
// Coroutine done
// [1000] After
// Took 15 ms (或者一个更小的数字)

在上面的例子中,我们使用 StandardTestDispatcher 并用一个作用域来包装它。作为替代,我们可以使用 TestScope,它做了同样的事情(它用 CoroutineExceptionHander 来收集所有异常)。关键在于,在这个作用域上,我们还可以使用 advanceUntileIdleadvanceTimeBycurrentTime 等属性和函数,所有这些函数都被委托给这个作用域使用的调度器。这样就更加便利了。

fun main() 
    val scope = TestScope()
    
    scope.launch 
       delay(1000)
       println("First done")
       delay(1000)
       println("Coroutine done")
    

    println("[$scope.currentTime] Before") // [0] Before
    scope.advanceTimeBy(1000)
    scope.runCurrent() // First done
    println("[$scope.currentTime] Middle") // [1000] Middle
    scope.advanceUntilIdle() // Coroutine done
    println("[$scope.currentTime] After") // [2000] After

稍后我们将了解到 StandardTestDispatcher 经常直接在 android 上用于测试 ViewModels 、 Presenter、Fragments 等。我们还可以用它来测试 produceCurrentUserSeqproduceCurrentUserSym 函数,方法是在协程中启动它们,将时间推到空闲,并检查它们花费了多少虚拟时间。然而,这相当复杂。相反,我们应该使用 runTest,它就是为了解决这个问题而设计的。

runTest

runTest 是 kotlinx-coroutines-test 中最常用的函数。它用 TestScope 来启动一个协程,并立即推进它直到空闲。包装其的作用域类型是 TestScope,因此我们可以在任意点检查 currentTime

class TestTest 
    @Test
    fun test1() = runTest 
        assertEquals(0, currentTime)
        delay(1000)
        assertEquals(1000, currentTime)
    

    @Test
    fun test2() = runTest 
        assertEquals(0, currentTime)
        coroutineScope 
            launch  delay(1000) 
            launch  delay(1500) 
            launch  delay(2000) 
        
        assertEquals(2000, currentTime)
    

让我们回到函数,在函数中,我们依次或同时加载用户数据。使用 runTest,测试它们很容易。假设我们的伪造存储库对每个函数调用需要1秒,那么顺序处理应该需要2秒,而同时处理应该只需要1秒。由于我们使用的是虚拟时间,所以测试是即使的,currentTime 的值是精确的。

@Test
fun `Should produce user sequentially`() = runTest 
     // given
     val userDataRepository = FakeDelayedUserDataRepository()
     val useCase = ProduceUserUseCase(userDataRepository)
     
     // when
     useCase.produceCurrentUserSeq()
     
     // then
     assertEquals(2000, currentTime)


@Test
fun `Should produce user simultaneously`() = runTest 
    // given
    val userDataRepository = FakeDelayedUserDataRepository()
    val useCase = ProduceUserUseCase(userDataRepository)
    
    // when
    useCase.produceCurrentUserSym()
    
    // then
    assertEquals(1000, currentTime)

由于它是一个重要的用例,所以让我们来看一下完整示例:

class FetchUserUseCase(
    private val repo: UserDataRepository,
) 
    suspend fun fetchUserData(): User = coroutineScope 
        val name = async  repo.getName() 
        val friends = async  repo.getFriends() 
        val profile = async  repo.getProfile() 

        User(
            name = name.await(),
            friends = friends.await(),
            profile = profile.await()
        )
    


class FetchUserDataTest 
    @Test
    fun `should load data concurrently`() = runTest 
        // given
        val userRepo = FakeUserDataRepository()
        val useCase = FetchUserUseCase(userRepo)

        // when
        useCase.fetchUserData()

        // then
        assertEquals(1000, currentTime)
    
    
    @Test
    fun `should construct user`() = runTest 
        // given
        val userRepo = FakeUserDataRepository()
        val useCase = FetchUserUseCase(userRepo)
        
        // when
        val result = useCase.fetchUserData()
        
        // then
        val expectedUser = User(
            name = "Ben",
            friends = listOf(Friend("some-friend-id-1")),
            profile = Profile("Example description")
        )

        assertEquals(expectedUser, result)
    
    
    class FakeUserDataRepository : UserDataRepository 
        override suspend fun getName(): String 
            delay(1000)
            return "Ben"
        

        override suspend fun getFriends(): List<Friend> 
            delay(1000)
            return listOf(Friend("some-friend-id-1"))
        
    
        override suspend fun getProfile(): Profile 
            delay(1000)
            return Profile("Example description")
        
    


interface UserDataRepository 
    suspend fun getName(): String
    suspend fun getFriends(): List<Friend>
    suspend 深潜Kotlin协程:Dispatchers 协程调度器

深潜Kotlin协程:Dispatchers 协程调度器

深潜Kotlin协程(十九):Flow 概述

深潜Kotlin协程(十八):冷热数据流

深潜Kotlin协程(十三):构建协程作用域

深潜Kotlin协程(十三):构建协程作用域