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

2023-11-28 17:59

本文主要是介绍深潜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 {@Testfun `should construct user`() = runBlocking {// givenval repo = FakeUserDataRepository()val useCase = FetchUserUseCase(repo)// whenval result = useCase.fetchUserData()// thenval 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() {@Testfun `should modify user details`() = runBlocking {// giventhereIsUser(aUserToken, aUserId)// whenfacade.updateUserSelf(aUserToken,PatchUserSelfRequest(bio = aUserBio,bioPl = aUserBioPl,publicKey = aUserPublicKey,customImageUrl = aCustomImageUrl))// thenwith(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) // 0scheduler.advanceTimeBy(1_000)println(scheduler.currentTime) // 1000scheduler.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) // DonetestDispatcher.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] Beforescope.advanceTimeBy(1000)scope.runCurrent() // First doneprintln("[${scope.currentTime}] Middle") // [1000] Middlescope.advanceUntilIdle() // Coroutine doneprintln("[${scope.currentTime}] After") // [2000] After
}

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

runTest

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

class TestTest {@Testfun test1() = runTest {assertEquals(0, currentTime)delay(1000)assertEquals(1000, currentTime)}@Testfun 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 {// givenval userDataRepository = FakeDelayedUserDataRepository()val useCase = ProduceUserUseCase(userDataRepository)// whenuseCase.produceCurrentUserSeq()// thenassertEquals(2000, currentTime)
}@Test
fun `Should produce user simultaneously`() = runTest {// givenval userDataRepository = FakeDelayedUserDataRepository()val useCase = ProduceUserUseCase(userDataRepository)// whenuseCase.produceCurrentUserSym()// thenassertEquals(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 {@Testfun `should load data concurrently`() = runTest {// givenval userRepo = FakeUserDataRepository()val useCase = FetchUserUseCase(userRepo)// whenuseCase.fetchUserData()// thenassertEquals(1000, currentTime)}@Testfun `should construct user`() = runTest {// givenval userRepo = FakeUserDataRepository()val useCase = FetchUserUseCase(userRepo)// whenval result = useCase.fetchUserData()// thenval 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(): Stringsuspend fun getFriends(): List<Friend>suspend fun getProfile(): Profile
}data class User(val name: String,val friends: List<Friend>,val profile: Profile
)data class Friend(val id: String)
data class Profile(val description: String)

在这里插入图片描述

UnconfinedTestDispatcher

除了 StandardTestDispatcher,我们还有 UnconfinedTestDispatcher。它们最大的区别是前者在我们使用它的调度器前它不会执行任何操作。而后者会立即执行在启动协程的第一次延迟之前的所有操作,这就是下面到的代码打印 “C” 的原因:

fun main() {CoroutineScope(StandardTestDispatcher()).launch {print("A")delay(1)print("B")}CoroutineScope(UnconfinedTestDispatcher()).launch {print("C")delay(1)print("D")}
}
// C

在 1.6 版本的 kotlinx-coroutines-test 中引入了 runTest 函数,该版本之前,我们使用 runBlockingTest,它的行为更接近于使用 UnconfinedTestDispatcher.runTest,所以,如果要想直接从 runBlockingTest 迁移到 runTest,代码需要像下面这样:

@Test
fun testName() = runTest(UnconfinedTestDispatcher()) {//...
}

使用模拟

在伪造对象中使用 delay 很简单但不够直接。许多开发人员喜欢直接在测试函数中调用 delay。其中一种方式是使用 mock 模拟:

@Test
fun `should load data concurrently`() = runTest {// givenval userRepo = mockk<UserDataRepository>()coEvery { userRepo.getName() } coAnswers {delay(600)aName}coEvery { userRepo.getFriends() } coAnswers {delay(700)someFriends}coEvery { userRepo.getProfile() } coAnswers {delay(800)aProfile}val useCase = FetchUserUseCase(userRepo)// whenuseCase.fetchUserData()// thenassertEquals(800, currentTime)
}

在上面的例子中,我使用了 MockK 库。

测试函数改变调度器

Dispatchers协程调度器一章中,我们介绍了设置具体 Dispatchers 的典型案例。例如,我们使用 Dispachers.IO (或者自定义调度器)来调用阻塞函数,使用 Dispachers.Default 来执行 CPU 密集型操作。这样的函数很少需要同时执行,所以通常使用 runBlocking 测试它们就足够了。这种情况很简单,实际上与测试阻塞函数没有什么区别。例如,思考下面函数:

suspend fun readSave(name: String): GameState =withContext(Dispatchers.IO) {reader.readCsvBlocking(name, GameState::class.java)}suspend fun calculateModel() =withContext(Dispatchers.Default) {model.fit(dataset = newTrain,epochs = 10,batchSize = 100,verbose = false)}

我们可以使用 runBlocking 包装这些函数的行为,但是如何检查这些函数是否真的使用了调度器呢?如果我们模拟所调用的函数,并在内部捕获所使用线程的名称,可以做到这一点。

@Test
fun `should change dispatcher`() = runBlocking {// givenval csvReader = mockk<CsvReader>()val startThreadName = "MyName"var usedThreadName: String? = nullevery {csvReader.readCsvBlocking(aFileName,GameState::class.java)} coAnswers {usedThreadName = Thread.currentThread().nameaGameState}val saveReader = SaveReader(csvReader)// whenwithContext(newSingleThreadContext(startThreadName)) {saveReader.readSave(aFileName)}// thenassertNotNull(usedThreadName)val expectedPrefix = "DefaultDispatcher-worker-"assert(usedThreadName!!.startsWith(expectedPrefix))
}

在上面的函数中,我不能使用伪造,因为 CsvReader 是一个类而非接口,所以我使用了 mock。记住,调度器 Dispachers.DefaultDispatchers.IO 是共享同一个线程池的。

然而,在极少数情况下,我们可能希望测试函数中的时间依赖关系。这是一个棘手的情况,因为新的调度器取代了我们的 StandardTestDispatcher,所以我们停止在虚拟时间上操作,为了更清楚的说明这一点,让我们使用 withContext(Disptachers.IO) 包装 fetchUserData 函数。

suspend fun fetchUserData() = withContext(Dispatchers.IO) {val name = async { userRepo.getName() }val friends = async { userRepo.getFriends() }val profile = async { userRepo.getProfile() }User(name = name.await(),friends = friends.await(),profile = profile.await())
}

现在,我们之前实现的所有测试都将实时等待, currentTime 将一直为0。为了防止这种情况发生,最简单的方法就是通过构造函数来注入调度器,并在单元测试中替换它。

class FetchUserUseCase(private val userRepo: UserDataRepository,private val ioDispatcher: CoroutineDispatcher =Dispatchers.IO
) {suspend fun fetchUserData() = withContext(ioDispatcher) {val name = async { userRepo.getName() }val friends = async { userRepo.getFriends() }val profile = async { userRepo.getProfile() }User(name = name.await(),friends = friends.await(),profile = profile.await())}
}

现在,我们不应该在在单元测试中提供 Disptachers.IO,而是应该使用 runTest 中的 StandardTestDispatcher。我们可以使用 ContinuationInterceptor 键从 coroutineContext 获取它。

val testDispatcher = this.coroutineContext[ContinuationInterceptor]as CoroutineDispatcherval useCase = FetchUserUseCase(userRepo = userRepo,ioDispatcher = testDispatcher,
)

另一种可能是将 ioDispacher 转换为 CoroutineContext,并在单元测试中用 EmptyCoroutineContext 替换它:

val useCase = FetchUserUseCase(userRepo = userRepo,ioDispatcher = EmptyCoroutineContext,
)

测试函数执行期间发生的情况

请想象有这么一个函数:它在执行过程中先展示一个进度条,之后会隐藏它:

suspend fun sendUserData() {val userData = database.getUserData()progressBarVisible.value = trueuserRepository.sendUserData(userData)progressBarVisible.value = false
}

如果我们只检查最终结果,我们就无法验证进度条是否在执行期间改变了它的状态。在这种情况下,技巧是在一个新的协程中启动这个函数,并在外部控制虚拟时间。注意,runTest 使用 StandardTestDispatcher 调度器创建一个协程,并将其时间提前到空闲(使用 advanceUntilIde函数)。这意味着一旦父协程开始等待子协程,子进程的时间就会开始。

@Test
fun `should show progress bar when sending data`() = runTest {// givenval database = FakeDatabase()val vm = UserViewModel(database)// whenlaunch {vm.sendUserData()}// thenassertEquals(false, vm.progressBarVisible.value)// whenadvanceTimeBy(1000)// thenassertEquals(false, vm.progressBarVisible.value)// whenrunCurrent()// thenassertEquals(true, vm.progressBarVisible.value)// whenadvanceUntilIdle()// thenassertEquals(false, vm.progressBarVisible.value)
}

多亏有了 runCurrent,我们可以精确地检查某些值的变化。

如果我们使用 delay,也可以达到类似的效果。这就像有两个独立的进程:一个在做任务,而另一个在检查第一个的行为是否正常。

@Test
fun `should show progress bar when sending data`() =runTest {val database = FakeDatabase()val vm = UserViewModel(database)launch {vm.showUserData()}// thenassertEquals(false, vm.progressBarVisible.value)delay(1000)assertEquals(true, vm.progressBarVisible.value)delay(1000)assertEquals(false, vm.progressBarVisible.value)}

使用 advanceTimeBy 这样的显式函数被认为比使用 delay 具有更高的可读性。

测试启动新的协程的函数

协程需要从某个地方开始。在后端,它们通常由我们使用的框架(例如 Spring 或 Ktor)启动,但有时我们可能还需要自己构造一个作用域,并在上面启动协程。

@Scheduled(fixedRate = 5000)
fun sendNotifications() {notificationsScope.launch {val notifications = notificationsRepository.notificationsToSend()for (notification in notifications) {launch {notificationsService.send(notification)notificationsRepository.markAsSent(notification.id)}}}
}

如果通知是并发发送的,我们该如何测试 sendNotification ? 同样,在单元测试中,我们需要使用 StandardTestDispatcher 作为作用域的一部分。我们还应该添加一些延迟来调用 sendmarkAsSent

@Test
fun testSendNotifications() {// givenval notifications = List(100) { Notification(it) }val repo = FakeNotificationsRepository(delayMillis = 200,notifications = notifications,)val service = FakeNotificationsService(delayMillis = 300,)val testScope = TestScope()val sender = NotificationsSender(notificationsRepository = repo,notificationsService = service,notificationsScope = testScope)// whensender.sendNotifications()testScope.advanceUntilIdle()// 然后所有的通知都会被发送和标记assertEquals(notifications.toSet(),service.notificationsSent.toSet())assertEquals(notifications.map { it.id }.toSet(),repo.notificationsMarkedAsSent.toSet())// 所有的通知都会并发的发送assertEquals(700, testScope.currentTime)
}

注意,上面的代码中不需要使用 runBlockingsendNotificationsadvanceUntilIdle 都是常规函数。

替换主调度器

单元测试中没有主函数。这意味着,如果我们试图去使用它,我们的测试将会失败。出现“Module with the Main dispatcher is missing”异常。另一方面,每次注入主线程都会很吃力,因此 kotlinx-coroutines-test 库在 Dispatchers 上提供了 setMain 扩展函数。

class MainPresenter(private val mainView: MainView,private val dataRepository: DataRepo
) {suspend fun onCreate() = coroutineScope {launch(Dispatchers.Main) {val data = dataRepository.fetchData()mainView.show(data)}}
}class FakeMainView : MainView {var dispatchersUsedToShow: List<CoroutineContext?> =emptyList()override suspend fun show(data: Data) {dispatchersUsedToShow +=coroutineContext[ContinuationInterceptor]}
}class FakeDataRepo : DataRepo {override suspend fun fetchData(): Data {delay(1000)return Data()}
}class SomeTest {private val mainDispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher()@Beforefun setup() {Dispatchers.setMain(mainDispatcher)}@Afterfun tearDown() {Dispatchers.resetMain()}@Testfun testSomeUI() = runBlocking {// givenval view = FakeMainView()val repo = FakeDataRepo()val presenter = MainPresenter(view, repo)// whenpresenter.onCreate()// show 在 Dispatchers.Main 上被调用了assertEquals(listOf(Dispatchers.Main),view.dispatchersUsedToShow)}
}

注意上面的例子。在 assertEquals 中,我比较了 dispatrchersUsedToShowDispatchers.Main。这样做的原因是 mainDispatcher 被设置为委托 Dispatchers.Main

我们经常在是所有单元测试扩展的基类上的 setup 函数(带有 @Before@BeforeEach 的函数)上定义主调度器。因此,我们总是确信可以在 Dispatcher.Main 上运行协程。我们还应该使用 Dispatchers.resetMain() 重置为初始状态。

测试启动协程的 Android 函数

在 Android 上,我们通常在 ViewModelsPresentersFragmentsActivities 上启动协程。这些都是非常重要的类,我们应该对其进行测试。看看下面的 MainViewModel 实现:

class MainViewModel(private val userRepo: UserRepository,private val newsRepo: NewsRepository,
) : BaseViewModel() {private val _userName: MutableLiveData<String> =MutableLiveData()val userName: LiveData<String> = _userNameprivate val _news: MutableLiveData<List<News>> = MutableLiveData()val news: LiveData<List<News>> = _newsprivate val _progressVisible: MutableLiveData<Boolean> =MutableLiveData()val progressVisible: LiveData<Boolean> = _progressVisiblefun onCreate() {viewModelScope.launch {val user = userRepo.getUser()_userName.value = user.name}viewModelScope.launch {_progressVisible.value = trueval news = newsRepo.getNews().sortedByDescending { it.date }_news.value = news_progressVisible.value = false}}
}

很有可能,我们会创建自己的作用域去替换 viewModelScope,也有可能我们使用的 Presenter、 Activity 或其它类而非 ViewModel。这与我们的例子无关,在测试每个启动协程的类中,我们应该使用 StandardTestDispatcher 作为作用域的一部分,以前,我们可以通过依赖注入来注入一个不同的作用域,但现在有一个更简单的方法:在 Android 上,我们使用 Dispatcher.Main 作为默认的调度器,并且我们可以使用 StandardTestDispatcher 替换它,这要感谢 Dispatchers.setMain 函数。

private val testDispatcher = StandardTestDispatcher()@Before
fun setUp() {Dispatchers.setMain(testDispatcher)
}@After
fun tearDown() {Dispatchers.resetMain()
}

通过这种方式设置主调度器后, onCreate 协程将在 testDispatcher 上运行,因此我们可以控制它们的时间。我们可以使用 advanceTimeBy 函数来假装已经经过了一段时间。我们也可以使用 advanceUntilIdle 来恢复所有的协程,直到它们完成。

class MainViewModelTests {private lateinit var scheduler: TestCoroutineSchedulerprivate lateinit var viewModel: MainViewModel@BeforeEachfun setUp() {scheduler = TestCoroutineScheduler()Dispatchers.setMain(StandardTestDispatcher(scheduler))viewModel = MainViewModel(userRepo = FakeUserRepository(aName),newsRepo = FakeNewsRepository(someNews))}@AfterEachfun tearDown() {Dispatchers.resetMain()viewModel.onCleared()}@Testfun `should show user name and sorted news`() {// whenviewModel.onCreate()scheduler.advanceUntilIdle()// thenassertEquals(aName, viewModel.userName.value)val someNewsSorted = listOf(News(date1), News(date2), News(date3))assertEquals(someNewsSorted, viewModel.news.value)}@Testfun `should show progress bar when loading news`() {// givenassertEquals(null, viewModel.progressVisible.value)// whenviewModel.onCreate()// thenassertEquals(false, viewModel.progressVisible.value)// whenscheduler.advanceTimeBy(200)// thenassertEquals(true, viewModel.progressVisible.value)// whenscheduler.runCurrent()// thenassertEquals(false, viewModel.progressVisible.value)}@Testfun `user and news are called concurrently`() {// whenviewModel.onCreate()scheduler.advanceUntilIdle()// thenassertEquals(300, testDispatcher.currentTime)}class FakeUserRepository(private val name: String) : UserRepository {override suspend fun getUser(): UserData {delay(300)return UserData(name)}}class FakeNewsRepository(private val news: List<News>) : NewsRepository {override suspend fun getNews(): List<News> {delay(200)return news}}
}

使用规则设置调度器

JUnit 4允许我们定义一些规则。这些类包含了应该在一些测试类生命周期事件上调用的逻辑。例如,一个规则可以定义在所有测试之前和之后需要做什么,因此在我们的案例中可以使用它来设置我们的调度器,并在之后清理它。以下是这种规则的一个良好实现:

class MainCoroutineRule : TestWatcher() {lateinit var scheduler: TestCoroutineSchedulerprivate setlateinit var dispatcher: TestDispatcherprivate setoverride fun starting(description: Description) {scheduler = TestCoroutineScheduler()dispatcher = StandardTestDispatcher(scheduler)Dispatchers.setMain(dispatcher)}override fun finished(description: Description) {Dispatchers.resetMain()}
}

该规则需要扩展 TestWatcher,它提供了测试生命周期的方法,比如我们可以重写 startingfinished。它组合了 TestCoroutineSchedulerTestDispatcher,在使用该规则的类中每次测试之前, TestDispatcher 都将设置为 Main,每次测试之后,主调度器都会被重置。我们可以通过该规则接口的 scheduler 属性访问调度器。

class MainViewModelTests {@get:Rulevar mainCoroutineRule = MainCoroutineRule()// ...@Testfun `should show user name and sorted news`() {// whenviewModel.onCreate()mainCoroutineRule.scheduler.advanceUntilIdle()// thenassertEquals(aName, viewModel.userName.value)val someNewsSorted =listOf(News(date1), News(date2), News(date3))assertEquals(someNewsSorted, viewModel.news.value)}@Testfun `should show progress bar when loading news`() {// givenassertEquals(null, viewModel.progressVisible.value)// whenviewModel.onCreate()// thenassertEquals(true, viewModel.progressVisible.value)// whenmainCoroutineRule.scheduler.advanceTimeBy(200)// thenassertEquals(false, viewModel.progressVisible.value)}@Testfun `user and news are called concurrently`() {// whenviewModel.onCreate()mainCoroutineRule.scheduler.advanceUntilIdle()// thenassertEquals(300, mainCoroutineRule.currentTime)}
}

如果你想直接在 MainCoroutineRule 上调用 advanceUntileIdleadvanceTimeByrunCurrentcurrentTime,你可以将它们定义为扩展函数和属性。

这种测试 Kotlin 协程的方法在 Android 上相当常见。它甚至在谷歌的 Codelabs 中被推荐使用(Android测试协程)(目前是基于旧的 kotlinx-coroutines-test API),它与 JUnit5 类似,我们可以定义一个扩展:

@ExperimentalCoroutinesApi
class MainCoroutineExtension:BeforeEachCallback, AfterEachCallback {lateinit var scheduler: TestCoroutineSchedulerprivate setlateinit var dispatcher: TestDispatcherprivate setoverride fun beforeEach(context: ExtensionContext?) {scheduler = TestCoroutineScheduler()dispatcher = StandardTestDispatcher(scheduler)Dispatchers.setMain(dispatcher)}override fun afterEach(context: ExtensionContext?) {Dispatchers.resetMain()}
}

使用 MainCoroutineExtension 与使用 MainCoroutineRule 的规则几乎相同。不同的是,我们需要使用 @JvmField@RegisterExtension 来代替 @get:Rule 注释。

@JvmField
@RegisterExtension
var mainCoroutineExtension = MainCoroutineExtension()

总结

在本章中,我们讨论了 Kotlin 协程单元测试的最重要用例。有一些技巧是我们需要知道的,但最终我们的测试可以非常优雅,一切都可以很容易的测试。我希望你在使用 Kotlin Coroutines 在应用程序中编写好的测试时能受到本章的启发。

这篇关于深潜Kotlin协程(十五):测试 Kotlin 协程的文章就介绍到这儿,希望我们推荐的文章对编程师们有所帮助!



http://www.chinasem.cn/article/430062

相关文章

性能测试介绍

性能测试是一种测试方法,旨在评估系统、应用程序或组件在现实场景中的性能表现和可靠性。它通常用于衡量系统在不同负载条件下的响应时间、吞吐量、资源利用率、稳定性和可扩展性等关键指标。 为什么要进行性能测试 通过性能测试,可以确定系统是否能够满足预期的性能要求,找出性能瓶颈和潜在的问题,并进行优化和调整。 发现性能瓶颈:性能测试可以帮助发现系统的性能瓶颈,即系统在高负载或高并发情况下可能出现的问题

字节面试 | 如何测试RocketMQ、RocketMQ?

字节面试:RocketMQ是怎么测试的呢? 答: 首先保证消息的消费正确、设计逆向用例,在验证消息内容为空等情况时的消费正确性; 推送大批量MQ,通过Admin控制台查看MQ消费的情况,是否出现消费假死、TPS是否正常等等问题。(上述都是临场发挥,但是RocketMQ真正的测试点,还真的需要探讨) 01 先了解RocketMQ 作为测试也是要简单了解RocketMQ。简单来说,就是一个分

【测试】输入正确用户名和密码,点击登录没有响应的可能性原因

目录 一、前端问题 1. 界面交互问题 2. 输入数据校验问题 二、网络问题 1. 网络连接中断 2. 代理设置问题 三、后端问题 1. 服务器故障 2. 数据库问题 3. 权限问题: 四、其他问题 1. 缓存问题 2. 第三方服务问题 3. 配置问题 一、前端问题 1. 界面交互问题 登录按钮的点击事件未正确绑定,导致点击后无法触发登录操作。 页面可能存在

业务中14个需要进行A/B测试的时刻[信息图]

在本指南中,我们将全面了解有关 A/B测试 的所有内容。 我们将介绍不同类型的A/B测试,如何有效地规划和启动测试,如何评估测试是否成功,您应该关注哪些指标,多年来我们发现的常见错误等等。 什么是A/B测试? A/B测试(有时称为“分割测试”)是一种实验类型,其中您创建两种或多种内容变体——如登录页面、电子邮件或广告——并将它们显示给不同的受众群体,以查看哪一种效果最好。 本质上,A/B测

Verybot之OpenCV应用一:安装与图像采集测试

在Verybot上安装OpenCV是很简单的,只需要执行:         sudo apt-get update         sudo apt-get install libopencv-dev         sudo apt-get install python-opencv         下面就对安装好的OpenCV进行一下测试,编写一个通过USB摄像头采

使用协程实现高并发的I/O处理

文章目录 1. 协程简介1.1 什么是协程?1.2 协程的特点1.3 Python 中的协程 2. 协程的基本概念2.1 事件循环2.2 协程函数2.3 Future 对象 3. 使用协程实现高并发的 I/O 处理3.1 网络请求3.2 文件读写 4. 实际应用场景4.1 网络爬虫4.2 文件处理 5. 性能分析5.1 上下文切换开销5.2 I/O 等待时间 6. 最佳实践6.1 使用 as

BIRT 报表的自动化测试

来源:http://www.ibm.com/developerworks/cn/opensource/os-cn-ecl-birttest/如何为 BIRT 报表编写自动化测试用例 BIRT 是一项很受欢迎的报表制作工具,但目前对其的测试还是以人工测试为主。本文介绍了如何对 BIRT 报表进行自动化测试,以及在实际项目中的一些测试实践,从而提高了测试的效率和准确性 -------

可测试,可维护,可移植:上位机软件分层设计的重要性

互联网中,软件工程师岗位会分前端工程师,后端工程师。这是由于互联网软件规模庞大,从业人员众多。前后端分别根据各自需求发展不一样的技术栈。那么上位机软件呢?它规模小,通常一个人就能开发一个项目。它还有必要分前后端吗? 有必要。本文从三个方面论述。分别是可测试,可维护,可移植。 可测试 软件黑盒测试更普遍,但很难覆盖所有应用场景。于是有了接口测试、模块化测试以及单元测试。都是通过降低测试对象

day45-测试平台搭建之前端vue学习-基础4

目录 一、生命周期         1.1.概念         1.2.常用的生命周期钩子         1.3.关于销毁Vue实例         1.4.原理​编辑         1.5.代码 二、非单文件组件         2.1.组件         2.2.使用组件的三大步骤         2.3.注意点         2.4.关于VueComponen

Unity协程搭配队列开发Tips弹窗模块

概述 在Unity游戏开发过程中,提示系统是提升用户体验的重要组成部分。一个设计良好的提示窗口不仅能及时传达信息给玩家,还应当做到不干扰游戏流程。本文将探讨如何使用Unity的协程(Coroutine)配合队列(Queue)数据结构来构建一个高效且可扩展的Tips弹窗模块。 技术模块介绍 1. Unity协程(Coroutines) 协程是Unity中的一种特殊函数类型,允许异步操作的实现