Android 协同程序-单元测试viewModelScope.launch方法

Android 协同程序-单元测试viewModelScope.launch方法,android,kotlin,android-livedata,kotlin-coroutines,Android,Kotlin,Android Livedata,Kotlin Coroutines,我正在为我的viewModel编写单元测试,但在执行测试时遇到了问题。runBlocking{…}块实际上并不等待内部代码完成,这让我感到惊讶 测试失败,因为结果为空。为什么runBlocking{…}不在ViewModel中以阻塞方式运行launchblock 我知道如果我将它转换为一个async方法,返回一个Deferred对象,那么我可以通过调用wait()来获取该对象,或者我可以返回一个Job并调用join()但是,我想通过将ViewModel方法保留为void函数来实现这一点,有什么方

我正在为我的viewModel编写单元测试,但在执行测试时遇到了问题。
runBlocking{…}
块实际上并不等待内部代码完成,这让我感到惊讶

测试失败,因为
结果
。为什么
runBlocking{…}
不在ViewModel中以阻塞方式运行
launch
block

我知道如果我将它转换为一个
async
方法,返回一个
Deferred
对象,那么我可以通过调用
wait()
来获取该对象,或者我可以返回一个
Job
并调用
join()
但是,我想通过将ViewModel方法保留为
void
函数来实现这一点,有什么方法可以做到这一点吗

// MyViewModel.kt

class MyViewModel(application: Application) : AndroidViewModel(application) {

    val logic = Logic()
    val myLiveData = MutableLiveData<Result>()

    fun doSomething() {
        viewModelScope.launch(MyDispatchers.Background) {
            System.out.println("Calling work")
            val result = logic.doWork()
            System.out.println("Got result")
            myLiveData.postValue(result)
            System.out.println("Posted result")
        }
    }

    private class Logic {
        suspend fun doWork(): Result? {
          return suspendCoroutine { cont ->
              Network.getResultAsync(object : Callback<Result> {
                      override fun onSuccess(result: Result) {
                          cont.resume(result)
                      }

                     override fun onError(error: Throwable) {
                          cont.resumeWithException(error)
                      }
                  })
          }
    }
}

您遇到的问题并非源于运行阻塞,而是源于LiveData在没有附加观察者的情况下没有传播值

我见过很多处理这种情况的方法,但最简单的方法就是使用
observeForever
countdownlock

@Test
fun testSomething() {
    runBlocking {
        viewModel.doSomething()
    }
    val latch = CountDownLatch(1)
    var result: String? = null
    viewModel.myLiveData.observeForever {
        result = it
        latch.countDown()
    }
    latch.await(2, TimeUnit.SECONDS)
    assertNotNull(result)
}
这种模式非常常见,您可能会看到许多项目在某些测试实用程序类/文件中以函数/方法的形式存在一些变化,例如

@Throws(InterruptedException::class)
fun <T> LiveData<T>.getTestValue(): T? {
    var value: T? = null
    val latch = CountDownLatch(1)
    val observer = Observer<T> {
        value = it
        latch.countDown()
    }
    latch.await(2, TimeUnit.SECONDS)
    observeForever(observer)
    removeObserver(observer)
    return value
}
@Throws(InterruptedException::class)
有趣的LiveData.getTestValue():T?{
变量值:T?=null
val闩锁=倒计时闩锁(1)
观察者{
值=它
倒数计时
}
等待(2,时间单位秒)
观察者(观察者)
removeObserver(观察员)
返回值
}
你可以这样称呼它:

uiJob {
    when (val result = fetchRubyContributorsUseCase.execute()) {
    // ... handle result of suspend fun execute() here         
}
val result=viewModel.myLiveData.getTestValue()

其他项目将其作为断言库的一部分

有人专门写了LiveData测试

您可能还想调查

或以下项目:


正如其他人提到的,runblocking只会阻止在其作用域中启动的协同程序,它与viewModelScope是分开的。
您可以插入MyDispatchers.Background并将MainDispatchers设置为使用dispatchers.unconfined。

您需要做的是将协同程序的启动包装到具有给定dispatcher的块中

var ui: CoroutineDispatcher = Dispatchers.Main
var io: CoroutineDispatcher =  Dispatchers.IO
var background: CoroutineDispatcher = Dispatchers.Default

fun ViewModel.uiJob(block: suspend CoroutineScope.() -> Unit): Job {
    return viewModelScope.launch(ui) {
        block()
    }
}

fun ViewModel.ioJob(block: suspend CoroutineScope.() -> Unit): Job {
    return viewModelScope.launch(io) {
        block()
    }
}

fun ViewModel.backgroundJob(block: suspend CoroutineScope.() -> Unit): Job {
    return viewModelScope.launch(background) {
        block()
    }
}
请注意顶部的ui、io和背景。这里的一切都是顶级+扩展功能

然后在viewModel中,按如下方式启动协同程序:

uiJob {
    when (val result = fetchRubyContributorsUseCase.execute()) {
    // ... handle result of suspend fun execute() here         
}
在测试中,您需要在@Before块中调用此方法:

@ExperimentalCoroutinesApi
private fun unconfinifyTestScope() {
    ui = Dispatchers.Unconfined
    io = Dispatchers.Unconfined
    background = Dispatchers.Unconfined
}

(将其添加到诸如BaseViewModelTest之类的基类中会更好)

我尝试了最上面的答案并成功了,但我不想检查所有的启动,并在测试中添加对main或unconfined的dispatcher引用。因此,我最终将此代码添加到我的基本测试类中。我将调度程序定义为TestCoroutineDispatcher()

在我的基础测试课上,我有

@ExtendWith(MockitoExtension::class, InstantExecutorExtension::class)
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
abstract class BaseTest {

    @BeforeAll
    private fun doOnBeforeAll() {
        MockitoAnnotations.initMocks(this)
    }
}
正如@Gergely Hegedus一样,需要将CoroutineScope注入到ViewModel中。使用此策略,将CoroutineScope作为参数传递,并为生产提供默认的
null
值。对于单元测试,将使用TestCoroutineScope

SomeUtils.kt

SomeViewModel.kt

SomeTest.kt

@experimentalRoutinesAPI
类FeedTest:BeforeAllCallback,AfterAllCallback{
private val testDispatcher=TestCoroutineDispatcher()
私有val testScope=TestCoroutineScope(testDispatcher)
私有val存储库=mockkClass(FeedRepository::class)
私有变量loadNetworkIntent=MutableStateFlow(null)
覆盖以前的乐趣(上下文:ExtensionContext?){
//设置协程调度器。
Dispatchers.setMain(testDispatcher)
}
覆盖乐趣(上下文:ExtensionContext?){
Dispatchers.resetMain()
//重置协程调度器和作用域。
testDispatcher.cleanupTestCoroutines()文件
testScope.cleanupTestCoroutines()文件
}
@试验
fun topCafesPoc()=testDispatcher.runBlockingTest{
...
val viewModel=FeedViewModel(testScope,存储库)
viewmodel.getSomeData()
...
}
}

您不必更改ViewModel的代码,在测试ViewModel时,只需更改即可正确设置协同路由范围(和调度程序)

将此添加到单元测试:

    @get:Rule
    open val coroutineTestRule = CoroutineTestRule()

    @Before
    fun injectTestCoroutineScope() {
        // Inject TestCoroutineScope (coroutineTestRule itself is a TestCoroutineScope)
        // to be used as ViewModel.viewModelScope fro the following reasons:
        // 1. Let test fail if coroutine launched in ViewModel.viewModelScope throws exception;
        // 2. Be able to advance time in tests with DelayController.
        viewModel.injectScope(coroutineTestRule)
    }

CoroutineTestRule.kt

    @Suppress("EXPERIMENTAL_API_USAGE")
    class CoroutineTestRule : TestRule, TestCoroutineScope by TestCoroutineScope() {

    val dispatcher = coroutineContext[ContinuationInterceptor] as TestCoroutineDispatcher

    override fun apply(
        base: Statement,
        description: Description?
    ) = object : Statement() {

        override fun evaluate() {
            Dispatchers.setMain(dispatcher)
            base.evaluate()

            cleanupTestCoroutines()
            Dispatchers.resetMain()
        }
    }
}
由于更换了主调度器,代码将按顺序执行(您的测试代码,然后查看模型代码,然后启动协同程序)

var ui: CoroutineDispatcher = Dispatchers.Main
var io: CoroutineDispatcher =  Dispatchers.IO
var background: CoroutineDispatcher = Dispatchers.Default

fun ViewModel.uiJob(block: suspend CoroutineScope.() -> Unit): Job {
    return viewModelScope.launch(ui) {
        block()
    }
}

fun ViewModel.ioJob(block: suspend CoroutineScope.() -> Unit): Job {
    return viewModelScope.launch(io) {
        block()
    }
}

fun ViewModel.backgroundJob(block: suspend CoroutineScope.() -> Unit): Job {
    return viewModelScope.launch(background) {
        block()
    }
}
上述方法的优点:

  • 正常编写测试代码,无需使用
    runBlocking
  • 每当协程中发生崩溃时,这将导致测试失败(因为每次测试后都调用了
    cleanupTestCoroutines()
  • 您可以测试内部使用
    延迟的协同程序。对于该测试,应在
    coroutinetesture.runBlockingTest{}
    中运行代码,并使用
    advanceTimeBy()
    移动到未来

  • 请确保您的问题显示了一个最小的、完整的、可验证的示例(),这将使回答您的问题更容易。runBlocking将只等待子协同路由。使用viewModelScope创建的协同路由与runBlocking中的作用域无关。更好的方法是将dispatcher上下文传递给viewmodel,这样您就可以通过测试dispatcher。。在你的测试中!我不认为问题在于实时数据没有传播价值。如果将viewmodel中的执行器更改为同步执行器,则测试通过。因此,它肯定与协同程序有关,
    InstantTaskExecutorRule
    规则确保livedata发布值instantlyDid是否尝试?InstantTaskExecutorRule只确保AAC同步运行,您仍然需要一个观察者。谢谢@Gergely Hegedus。概述了一个。您是否有任何简单的示例来展示如何组织生产/测试代码?下面是一个将
    viewModelScope
    注入到ViewModel中的策略:。@ExperimentalCoroutinesApi class FeedTest : BeforeAllCallback, AfterAllCallback { private val testDispatcher = TestCoroutineDispatcher() private val testScope = TestCoroutineScope(testDispatcher) private val repository = mockkClass(FeedRepository::class) private var loadNetworkIntent = MutableStateFlow<LoadNetworkIntent?>(null) override fun beforeAll(context: ExtensionContext?) { // Set Coroutine Dispatcher. Dispatchers.setMain(testDispatcher) } override fun afterAll(context: ExtensionContext?) { Dispatchers.resetMain() // Reset Coroutine Dispatcher and Scope. testDispatcher.cleanupTestCoroutines() testScope.cleanupTestCoroutines() } @Test fun topCafesPoc() = testDispatcher.runBlockingTest { ... val viewModel = FeedViewModel(testScope, repository) viewmodel.getSomeData() ... } }
        @get:Rule
        open val coroutineTestRule = CoroutineTestRule()
    
        @Before
        fun injectTestCoroutineScope() {
            // Inject TestCoroutineScope (coroutineTestRule itself is a TestCoroutineScope)
            // to be used as ViewModel.viewModelScope fro the following reasons:
            // 1. Let test fail if coroutine launched in ViewModel.viewModelScope throws exception;
            // 2. Be able to advance time in tests with DelayController.
            viewModel.injectScope(coroutineTestRule)
        }
    
    
        @Suppress("EXPERIMENTAL_API_USAGE")
        class CoroutineTestRule : TestRule, TestCoroutineScope by TestCoroutineScope() {
    
        val dispatcher = coroutineContext[ContinuationInterceptor] as TestCoroutineDispatcher
    
        override fun apply(
            base: Statement,
            description: Description?
        ) = object : Statement() {
    
            override fun evaluate() {
                Dispatchers.setMain(dispatcher)
                base.evaluate()
    
                cleanupTestCoroutines()
                Dispatchers.resetMain()
            }
        }
    }