深潜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秒,你将如何测试这种差异?
请注意,只有 getProfile
和 getFriends
执行确实是需要一些时间才会产生差异。如果它们都是即时的,两种产生用户信息的方式是无法区分的,因此,我们可以通过伪造耗时函数来模拟数据加载的场景:
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以上版本中的 advanceTimeBy
和 runCurrent
。
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
以及 StandardTestDispatcher
、TestScope
和 runTest
仍然是实验性的。
要在协程上使用 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
下面是使用 advanceTimeBy
和 runCurrent
的更大一点的例子。
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
来收集所有异常)。关键在于,在这个作用域上,我们还可以使用 advanceUntileIdle
、 advanceTimeBy
或 currentTime
等属性和函数,所有这些函数都被委托给这个作用域使用的调度器。这样就更加便利了。
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 等。我们还可以用它来测试 produceCurrentUserSeq
和 produceCurrentUserSym
函数,方法是在协程中启动它们,将时间推到空闲,并检查它们花费了多少虚拟时间。然而,这相当复杂。相反,我们应该使用 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 协程调度器