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

时间:2019-04-19 16:56:21

标签: android kotlin android-livedata kotlin-coroutines

我正在为viewModel编写单元测试,但是执行测试时遇到麻烦。 runBlocking { ... }块实际上并不等待内部代码完成,这令我感到惊讶。

测试失败,因为resultnull。为什么runBlocking { ... }不能以阻塞方式在ViewModel内部运行launch块?

我知道是否将其转换为返回async对象的Deferred方法,然后可以通过调用await()来获取对象,或者可以返回{{1} },然后致电Job但是,我想通过将ViewModel方法保留为join()函数来做到这一点,有没有办法做到这一点?

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

6 个答案:

答案 0 :(得分:2)

作为 @Gergely Hegedus mentions above,CoroutineScope需要注入到ViewModel中。使用此策略,CoroutineScope作为带有默认null值的参数传递给生产。对于单元测试,将使用TestCoroutineScope。

SomeUtils.kt

/**
 * Configure CoroutineScope injection for production and testing.
 *
 * @receiver ViewModel provides viewModelScope for production
 * @param coroutineScope null for production, injects TestCoroutineScope for unit tests
 * @return CoroutineScope to launch coroutines on
 */
fun ViewModel.getViewModelScope(coroutineScope: CoroutineScope?) =
    if (coroutineScope == null) this.viewModelScope
    else coroutineScope

SomeViewModel.kt

class FeedViewModel(
    private val coroutineScopeProvider: CoroutineScope? = null,
    private val repository: FeedRepository
) : ViewModel() {

    private val coroutineScope = getViewModelScope(coroutineScopeProvider)

    fun getSomeData() {
        repository.getSomeDataRequest().onEach {
            // Some code here.            
        }.launchIn(coroutineScope)
    }

}

SomeTest.kt

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

答案 1 :(得分:1)

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

答案 2 :(得分:0)

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

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这样的基类中要好得多)

答案 3 :(得分:0)

我尝试了最佳答案并工作了,但是我不想遍历所有发布内容,并在测试中添加对main或unconfined的调度程序引用。因此,我最终将此代码添加到了基础测试类中。我将调度程序定义为TestCoroutineDispatcher()

class InstantExecutorExtension : BeforeEachCallback, AfterEachCallback {
    private val mainThreadDispatcher = TestCoroutineDispatcher()

    override fun beforeEach(context: ExtensionContext?) {
        ArchTaskExecutor.getInstance()
            .setDelegate(object : TaskExecutor() {
                override fun executeOnDiskIO(runnable: Runnable) = runnable.run()

                override fun postToMainThread(runnable: Runnable) = runnable.run()

                override fun isMainThread(): Boolean = true
            })

        Dispatchers.setMain(mainThreadDispatcher)
    }

    override fun afterEach(context: ExtensionContext?) {
        ArchTaskExecutor.getInstance().setDelegate(null)
        Dispatchers.resetMain()
    }
}

我的基础测试班有

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

    @BeforeAll
    private fun doOnBeforeAll() {
        MockitoAnnotations.initMocks(this)
    }
}

答案 4 :(得分:0)

您不必更改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()
        }
    }
}

由于替换了主调度程序,因此代码将按顺序执行(您的测试代码,然后查看模型代码,然后启动协程)。

上述方法的优点:

  1. 正常编写测试代码,无需使用runBlocking左右;
  2. 每当协程程序 中发生崩溃时,该测试都将失败(因为在每次测试后都会调用cleanupTestCoroutines())。
  3. 您可以测试内部使用delay的协程。为此,应在coroutineTestRule.runBlockingTest { }中运行测试代码,并使用advanceTimeBy()迈向未来。

答案 5 :(得分:-1)

您遇到的问题不是由于runBlocking,而是由于LiveData在没有附加观察者的情况下没有传播值。

我已经看到了许多解决方法,但是最简单的方法就是只使用CountDownLatch@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
}

这种模式非常普遍,您可能会在许多测试实用程序类/文件中看到许多项目,这些项目的功能/方法有所不同,例如

val result = viewModel.myLiveData.getTestValue()

您可以这样拨打电话:

&

其他项目使其成为其断言库的一部分。

Here is a library有人专门写了LiveData测试。

您可能还想研究Kotlin Coroutine CodeLab

或以下项目:

https://github.com/googlesamples/android-sunflower

https://github.com/googlesamples/android-architecture-components