当前位置:网站首页>Deep dive kotlin synergy (XV): Test kotlin synergy

Deep dive kotlin synergy (XV): Test kotlin synergy

2022-06-27 04:25:00 RikkaTheWorld

Series eBook : Portal


in the majority of cases , Testing a suspended function is no different from testing a normal function . Look at the following FetchUserUseCase.fetchUserData. By forging Fake( Or simulation Mock) And simple assertions , We can easily test whether it displays the data as expected :

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")
    }
}

The above test function should not be used as a reference standard . There are many ways to express unit tests . I used it here forge fake instead of simulation mock, This will not introduce any third-party libraries ( I personally prefer this way ). I also try to make all the tests as simple as possible , To make them easier to read .

Allied , in many instances , If we need to test the suspend function , In fact, just use runBlocking And some classic assertion tools . This is what unit testing looks like in many projects , Here is Kt.Academy A unit test case of the background project :

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)
        }
    }
    //...
}

We only use runBlocking, There is little difference between testing the behavior of suspended and blocked functions .

Test time dependency

But when we want to test the time dependence of functions , The difference arises . for example , Consider the following function :

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())
}

Both functions will produce the same result , But the difference is : The first one is produced in sequence , And the second is done at the same time . If you need to get a profile and a friends list 1 second , So the first function needs to be about 2 second , The second function only needs 1 second , How will you test this difference ?

Please note that , Only getProfile and getFriends It does take some time for the implementation to make a difference . If they are all instant , The two ways of generating user information are indistinguishable , therefore , We can simulate the scenario of data loading by forging time-consuming functions :

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"))
    }
}

Now? , You can see the difference in unit tests : call produceCurrentUserSeq It will cost about 1 The second time , And call produceCurrentUserSym It takes about 2 The second time . The problem is that we don't want unit testing to take so much time , There are usually thousands of unit tests in our projects , We want all the tests to be performed as quickly as possible . How to have both fish and bear's paws ? The answer is to use virtual time . Next we will introduce kotln-coroutines-test And its StandardTestDispatcher.

This chapter introduces 1.6 Version of kotlin-coroutines-test Functions and classes . If you are using an older version of this library . in the majority of cases , use runBlockingTest Instead of runTest, use TestCoroutinesDispatcher Instead of StandardTestDispatcher , use TestCoroutineScope Instead of TestScope That's enough . in addition , In the old version advanceTimeBy Be similar to 1.6 In the above version advanceTimeBy and runCurrent.

TestCoroutineScheduler and StandardTestDispatcher

When we call delay when , Our collaboration was suspended and resumed after a period of time .kotlinx-coroutines-test Medium TestCoroutineScheduler Can change this behavior , It makes the delay Operate on virtual time , This is completely simulated , Not dependent on real-time .

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 as well as StandardTestDispatcherTestScope and runTest Still experimental .

To be used in the collaboration process TestCoroutineScheduler , We should use a scheduler to assist it . The standard choice is StandardTestDispatcher. Unlike most schedulers , It is not just used to determine which thread the collaboration runs on . Unless we don't advance the time , Otherwise, the process it starts will not run . The most typical way to use lead time is to call advanceUntilIdle function , It will advance the virtual time , And call all the operations here :

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 Will create a... By default TestCoroutineScheduler , So we don't need to explicitly create it . We can go through scheduler Property to access it .

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

It is important to , it is to be noted that StandardTestDispatcher It will not advance the time , If we don't do anything extra , The process will never recover .

fun main() {
    
    val testDispatcher = StandardTestDispatcher()
    
    runBlocking(testDispatcher) {
    
        delay(1)
        println("Coroutine done")
    }
}
// ( The code runs forever )

Another way to delay is to use advanceTimeBy And the specific number of milliseconds . This function advances a specific time and performs all operations that occur during this period . This means that if we delay 2ms, All delays are less than 2ms Will be restored . In order to resume the operation scheduled just in the second millisecond , We need an extra call runCurrent function :

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
}

Here's how to use advanceTimeBy and runCurrent A larger example of .

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.

How its bottom layer works ? When calling delay when , It checks dispatcher( with CoroutinuationInterceptor Key ) Is it implemented Delay Interface (StandardTestDispatcher Realized ). For such a scheduler , Will call their scheduleResumeAfterDelay function , Instead of waiting in real time DefaultDelay .

To understand that virtual time is truly independent of real time , See the example below . add to Thread.sleep It doesn't matter StandardTestDispatcher Cooperation of . Attention is also needed , Yes advanceUntilIdle It only takes a few milliseconds to call . So it doesn't have to wait for any real time . It will immediately push the virtual time and execute the co process operation .

fun main() {
    
    val dispatcher = StandardTestDispatcher()

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

    Thread.sleep(Random.nextLong(2000)) //  It doesn't matter how many seconds you sleep here 
    //  It doesn't affect the results 
    
    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 ( Or a smaller number )

In the example above , We use StandardTestDispatcher And wrap it with a scope . As a substitute , We can use TestScope, It did the same thing ( It USES CoroutineExceptionHander To collect all exceptions ). The key lies in , On this scope , We can also use advanceUntileIdleadvanceTimeBy or currentTime And other properties and functions , All these functions are delegated to the scheduler used by this scope . This is more convenient .

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
}

Later we will learn about StandardTestDispatcher Often directly in Android For testing ViewModels 、 Presenter、Fragments etc. . We can also use it to test produceCurrentUserSeq and produceCurrentUserSym function , The method is to start them in the process , Push time to leisure , And check how much virtual time they spend . However , It's quite complicated . contrary , We should use runTest, It is designed to solve this problem .

runTest

runTest yes kotlinx-coroutines-test The most commonly used function in . It USES TestScope So let's start a coroutine , And immediately push it until it is free . The scope type that wraps it is TestScope, So we can check at any point 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)
    }
}

Let's go back to the function , In the function , We load user data sequentially or simultaneously . Use runTest, It's easy to test them . Suppose that our fake repository needs... For each function call 1 second , Then sequential processing should require 2 second , Simultaneous processing should only require 1 second . Because we use virtual time , So the test is even ,currentTime The value of is accurate .

@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)
}

Because it is an important use case , So let's take a look at the complete example :

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 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)

 Insert picture description here

UnconfinedTestDispatcher

except StandardTestDispatcher, We also have UnconfinedTestDispatcher. The biggest difference between them is that the former will not perform any operation before we use its scheduler . The latter will immediately perform all operations before the first delay of the startup process , This is the code print below “C” Why :

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

    CoroutineScope(UnconfinedTestDispatcher()).launch {
    
        print("C")
        delay(1)
        print("D")
    }
}
// C

stay 1.6 Version of kotlinx-coroutines-test Introduced in runTest function , Before this version , We use runBlockingTest, Its behavior is closer to using UnconfinedTestDispatcher.runTest, therefore , If you want to go directly from runBlockingTest Migrate to runTest, The code needs to look like this :

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

Using simulation

Use in forged objects delay It's simple but not direct enough . Many developers prefer to call... Directly in test functions delay. One way is to use mock simulation :

@Test
fun `should load data concurrently`() = runTest {
    
    // given
    val 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)
    
    // when
    useCase.fetchUserData()
    
    // then
    assertEquals(800, currentTime)
}

In the example above , I use the MockK library .

Test function change scheduler

stay Dispatchers Co scheduler Chapter 1 , We introduced the setting details Dispatchers The typical case . for example , We use Dispachers.IO ( Or customize the scheduler ) To call the blocking function , Use Dispachers.Default To execute CPU Intensive operation . Such functions rarely need to be executed at the same time , So we usually use runBlocking Testing them is enough . It's very simple , In fact, it is no different from testing blocking functions . for example , Consider the following function :

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
        )
    }

We can use runBlocking The behavior of wrapping these functions , But how to check whether these functions actually use the scheduler ? If we simulate the function being called , And capture the name of the thread used internally , That can be done .

@Test
fun `should change dispatcher`() = runBlocking {
    
    // given
    val csvReader = mockk<CsvReader>()
    val startThreadName = "MyName"
    var usedThreadName: String? = null
    
    every {
    
        csvReader.readCsvBlocking(
            aFileName,
            GameState::class.java
        )
    } coAnswers {
    
        usedThreadName = Thread.currentThread().name
        aGameState
    }
    
    val saveReader = SaveReader(csvReader)
    
    // when
    withContext(newSingleThreadContext(startThreadName)) {
    
        saveReader.readSave(aFileName)
    }
    
    // then
    assertNotNull(usedThreadName)
    val expectedPrefix = "DefaultDispatcher-worker-"
    assert(usedThreadName!!.startsWith(expectedPrefix))
}

In the function above , I can't use forgery , because CsvReader It's a class, not an interface , So I used mock. remember , Scheduler Dispachers.Default and Dispatchers.IO Share the same thread pool .

However , In rare cases , We might want to test the time dependency in the function . It's a tricky situation , Because the new scheduler replaces our StandardTestDispatcher, So we stop working on virtual time , To illustrate this point more clearly , Let's use withContext(Disptachers.IO) packing fetchUserData function .

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()
    )
}

Now? , All the tests we have implemented before will be waiting in real time , currentTime Will always be 0. To prevent this from happening , The simplest way is to inject the scheduler through the constructor , And replace it in unit tests .

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()
        )
    }
}

Now? , We should not provide... In unit tests Disptachers.IO, It should use runTest Medium StandardTestDispatcher. We can use ContinuationInterceptor Key from coroutineContext Get it .

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

Another possibility is to ioDispacher Convert to CoroutineContext, And use in unit test EmptyCoroutineContext Replace it :

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

Test what happens during function execution

Imagine a function : It displays a progress bar during execution , Then hide it :

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

If we only check the final result , We cannot verify whether the progress bar has changed its state during execution . under these circumstances , The trick is to start this function in a new coroutine , And control the virtual time externally . Be careful ,runTest Use StandardTestDispatcher The scheduler creates a coroutine , And advance the time to idle ( Use advanceUntilIde function ). This means that once the parent process starts waiting for the child process , The time of the subprocess will start .

@Test
fun `should show progress bar when sending data`() = runTest {
    
    // given
    val database = FakeDatabase()
    val vm = UserViewModel(database)
    
    // when
    launch {
    
        vm.sendUserData()
    }

    // then
    assertEquals(false, vm.progressBarVisible.value)
    // when
    advanceTimeBy(1000)
    // then
    assertEquals(false, vm.progressBarVisible.value)
    // when
    runCurrent()
    // then
    assertEquals(true, vm.progressBarVisible.value)
    // when
    advanceUntilIdle()
    // then
    assertEquals(false, vm.progressBarVisible.value)
}

Thanks to runCurrent, We can accurately check the change of some values .

If we use delay, A similar effect can be achieved . It's like having two separate processes : One is doing a task , The other one is checking whether the first one behaves normally .

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

Use advanceTimeBy Such explicit functions are considered better than using delay Higher readability .

Test the function that starts the new coroutine

The process needs to start somewhere . On the back end , They are usually used by the framework we use ( for example Spring or Ktor) start-up , But sometimes we may need to construct a scope ourselves , And start the coordination process on it .

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

If the notification is sent concurrently , How do we test sendNotification ? Again , In unit test , We need to use StandardTestDispatcher As part of the scope . We should also add some delay to invoke send and markAsSent.

@Test
fun testSendNotifications() {
    
    // given
    val 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
    )

    // when
    sender.sendNotifications()

    testScope.advanceUntilIdle()
    //  Then all the notifications will be sent and marked 
    assertEquals(
        notifications.toSet(),
        service.notificationsSent.toSet()
    )
    assertEquals(
        notifications.map {
     it.id }.toSet(),
        repo.notificationsMarkedAsSent.toSet()
    )
    //  All notifications will be sent concurrently 
    assertEquals(700, testScope.currentTime)
}

Be careful , There is no need to use... In the above code runBlocking. sendNotifications and advanceUntilIdle Are regular functions .

Replace the main regulator

There is no main function in the unit test . It means , If we try to use it , Our test will fail . appear “Module with the Main dispatcher is missing” abnormal . On the other hand , Each injection into the main thread will be very difficult , therefore kotlinx-coroutines-test Library in Dispatchers I have provided setMain spread function .

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()

    @Before
    fun setup() {
    
        Dispatchers.setMain(mainDispatcher)
    }

    @After
    fun tearDown() {
    
        Dispatchers.resetMain()
    }

    @Test
    fun testSomeUI() = runBlocking {
    
        // given
        val view = FakeMainView()
        val repo = FakeDataRepo()
        val presenter = MainPresenter(view, repo)
        
        // when
        presenter.onCreate()

        // show  stay  Dispatchers.Main  Called on 
        assertEquals(
            listOf(Dispatchers.Main),
            view.dispatchersUsedToShow
        )
    }
}

Notice the example above . stay assertEquals in , I compared dispatrchersUsedToShow and Dispatchers.Main. And the reason for that is mainDispatcher Is set to delegate Dispatchers.Main .

We often do this on base classes that are extensions of all unit tests setup function ( with @Before or @BeforeEach Function of ) Define the master tuner on the . therefore , We are always sure that we can Dispatcher.Main Run the collaboration on . We should also use Dispatchers.resetMain() Reset to initial state .

Test the startup process Android function

stay Android On , We usually ViewModelsPresentersFragments or Activities Start the process . These are very important classes , We should test it . Look at the following MainViewModel Realization :

class MainViewModel(
    private val userRepo: UserRepository,
    private val newsRepo: NewsRepository,
) : BaseViewModel() {
    

    private val _userName: MutableLiveData<String> =
        MutableLiveData()
    val userName: LiveData<String> = _userName

    private val _news: MutableLiveData<List<News>> = MutableLiveData()
    val news: LiveData<List<News>> = _news

    private val _progressVisible: MutableLiveData<Boolean> =
       MutableLiveData()
    val progressVisible: LiveData<Boolean> = _progressVisible
    
    fun onCreate() {
    
        viewModelScope.launch {
    
            val user = userRepo.getUser()
            _userName.value = user.name
        }
        viewModelScope.launch {
    
            _progressVisible.value = true
            val news = newsRepo.getNews()
                .sortedByDescending {
     it.date }
            _news.value = news
            _progressVisible.value = false
        }
    }
}

Is likely to , We will create our own scope to replace viewModelScope, It is also possible that we use Presenter、 Activity Or other classes instead of ViewModel. This has nothing to do with our example , In the class that tests each startup coroutine , We should use StandardTestDispatcher As part of the scope , before , We can inject a different scope through dependency injection , But now there is a simpler way : stay Android On , We use Dispatcher.Main As the default scheduler , And we can use StandardTestDispatcher Replace it , Thank you Dispatchers.setMain function .

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

@After
fun tearDown() {
    
    Dispatchers.resetMain()
}

After setting the main regulator in this way , onCreate The collaboration will take place in testDispatcher Up operation , So we can control their time . We can use advanceTimeBy Function to pretend that it's been a while . We can also use advanceUntilIdle To restore all the processes , Until they're done .

class MainViewModelTests {
    
    private lateinit var scheduler: TestCoroutineScheduler
    private lateinit var viewModel: MainViewModel

    @BeforeEach
    fun setUp() {
    
        scheduler = TestCoroutineScheduler()
        Dispatchers.setMain(StandardTestDispatcher(scheduler))
        viewModel = MainViewModel(
            userRepo = FakeUserRepository(aName),
            newsRepo = FakeNewsRepository(someNews)
        )
    }

    @AfterEach
    fun tearDown() {
    
        Dispatchers.resetMain()
        viewModel.onCleared()
    }

    @Test
    fun `should show user name and sorted news`() {
    
        // when
        viewModel.onCreate()
        scheduler.advanceUntilIdle()

        // then
        assertEquals(aName, viewModel.userName.value)

        val someNewsSorted = listOf(News(date1), News(date2), News(date3))
        assertEquals(someNewsSorted, viewModel.news.value)
    }

    @Test
    fun `should show progress bar when loading news`() {
    
        // given
        assertEquals(null, viewModel.progressVisible.value)
        // when
        viewModel.onCreate()
        // then
        assertEquals(false, viewModel.progressVisible.value)
        // when
        scheduler.advanceTimeBy(200)
        // then
        assertEquals(true, viewModel.progressVisible.value)
        // when
        scheduler.runCurrent()
        // then
        assertEquals(false, viewModel.progressVisible.value)
    }

    @Test
    fun `user and news are called concurrently`() {
    
        // when
        viewModel.onCreate()
        scheduler.advanceUntilIdle()
        // then
        assertEquals(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
        }
    }
}

Use rules to set up the scheduler

JUnit 4 Allow us to define some rules . These classes contain logic that should be invoked in some test class lifecycle Events . for example , A rule can define what needs to be done before and after all tests , So in our case, we can use it to set up our scheduler , And then clean it up . Here is a good implementation of this rule :

class MainCoroutineRule : TestWatcher() {
    
    lateinit var scheduler: TestCoroutineScheduler
        private set
    
    lateinit var dispatcher: TestDispatcher
         private set
    
    override fun starting(description: Description) {
    
        scheduler = TestCoroutineScheduler()
        dispatcher = StandardTestDispatcher(scheduler)
        Dispatchers.setMain(dispatcher)
    }

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

This rule needs to be extended TestWatcher, It provides a way to test the lifecycle , For example, we can rewrite starting and finished. It combines TestCoroutineScheduler and TestDispatcher, Before each test in a class that uses this rule , TestDispatcher Will be set to Main, After each test , The main adjuster will be reset . We can use the rule interface scheduler Property access scheduler .

class MainViewModelTests {
    
    @get:Rule
    var mainCoroutineRule = MainCoroutineRule()

    // ...
    @Test
    fun `should show user name and sorted news`() {
    
        // when
        viewModel.onCreate()
        
        mainCoroutineRule.scheduler.advanceUntilIdle()
        // then
        assertEquals(aName, viewModel.userName.value)
        val someNewsSorted =
            listOf(News(date1), News(date2), News(date3))
        assertEquals(someNewsSorted, viewModel.news.value)
    }

    @Test
    fun `should show progress bar when loading news`() {
    
        // given
        assertEquals(null, viewModel.progressVisible.value)
        // when
        viewModel.onCreate()
        // then
        assertEquals(true, viewModel.progressVisible.value)
        // when
        mainCoroutineRule.scheduler.advanceTimeBy(200)
        // then
        assertEquals(false, viewModel.progressVisible.value)
    }

    @Test
    fun `user and news are called concurrently`() {
    
        // when
        viewModel.onCreate()

        mainCoroutineRule.scheduler.advanceUntilIdle()
        // then
        assertEquals(300, mainCoroutineRule.currentTime)
    }
}

If you want to go directly to MainCoroutineRule On the call advanceUntileIdleadvanceTimeByrunCurrent and currentTime, You can define them as extension functions and attributes .

This kind of test Kotlin The method of synergy is Android It is quite common . It's even in Google's Codelabs Is recommended in (Android Test collaboration )( The present is based on the old kotlinx-coroutines-test API), It is associated with JUnit5 similar , We can define an extension :

@ExperimentalCoroutinesApi
class MainCoroutineExtension:
    BeforeEachCallback, AfterEachCallback {
    
    lateinit var scheduler: TestCoroutineScheduler
        private set
    
    lateinit var dispatcher: TestDispatcher
        private set
    
    override fun beforeEach(context: ExtensionContext?) {
    
        scheduler = TestCoroutineScheduler()
        dispatcher = StandardTestDispatcher(scheduler)
        Dispatchers.setMain(dispatcher)
    }

    override fun afterEach(context: ExtensionContext?) {
    
        Dispatchers.resetMain()
    }
}

Use MainCoroutineExtension And use MainCoroutineRule The rules are almost the same . The difference is , We need to use @JvmField and @RegisterExtension Instead of @get:Rule notes .

@JvmField
@RegisterExtension
var mainCoroutineExtension = MainCoroutineExtension()

summary

In this chapter , We talked about Kotlin The most important use case of concurrent unit testing . There are some techniques we need to know , But ultimately our tests can be very elegant , Everything can be easily tested . I hope you are using Kotlin Coroutines This chapter can inspire you to write good tests in your application .

原网站

版权声明
本文为[RikkaTheWorld]所创,转载请带上原文链接,感谢
https://yzsam.com/2022/178/202206270402476957.html