问题
I am writing unit tests for my viewModel, but having trouble executing the tests. The runBlocking { ... }
block doesn't actually wait for the code inside to finish, which is surprising to me.
The test fails because result
is null
. Why doesn't runBlocking { ... }
run the launch
block inside the ViewModel in blocking fashion?
I know if I convert it to a async
method that returns a Deferred
object, then I can get the object by calling await()
, or I can return a Job
and call join()
. But, I'd like to do this by leaving my ViewModel methods as void
functions, is there a way to do this?
// 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)
}
})
}
}
}
// MyViewModelTest.kt
@RunWith(RobolectricTestRunner::class)
class MyViewModelTest {
lateinit var viewModel: MyViewModel
@get:Rule
val rule: TestRule = InstantTaskExecutorRule()
@Before
fun init() {
viewModel = MyViewModel(ApplicationProvider.getApplicationContext())
}
@Test
fun testSomething() {
runBlocking {
System.out.println("Called doSomething")
viewModel.doSomething()
}
System.out.println("Getting result value")
val result = viewModel.myLiveData.value
System.out.println("Result value : $result")
assertNotNull(result) // Fails here
}
}
回答1:
As others mentioned, runblocking just blocks the coroutines launched in it's scope, it's separate from your viewModelScope. What you could do is to inject your MyDispatchers.Background and set the mainDispatcher to use dispatchers.unconfined.
回答2:
What you need to do is wrap your launching of a coroutine into a block with given 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()
}
}
Notice ui, io and background at the top. Everything here is top-level + extension functions.
Then in viewModel you start your coroutine like this:
uiJob {
when (val result = fetchRubyContributorsUseCase.execute()) {
// ... handle result of suspend fun execute() here
}
And in test you need to call this method in @Before block:
@ExperimentalCoroutinesApi
private fun unconfinifyTestScope() {
ui = Dispatchers.Unconfined
io = Dispatchers.Unconfined
background = Dispatchers.Unconfined
}
(Which is much nicer to add to some base class like BaseViewModelTest)
回答3:
I tried the top answer and worked, but I didn't want to go over all my launches and add a dispatcher reference to main or unconfined in my tests. So I ended up adding this code to my base testing class. I am defining main dispatcher as unconfined
private val testingDispatcher = Dispatchers.Unconfined
@BeforeEach
private fun doBeforeEach() {
Dispatchers.setMain(testingDispatcher)
}
@AfterEach
private fun doAfterEach() {
Dispatchers.resetMain()
}
In my case I am using a Flow
which is then switched to LiveData
, I was able to implement observeForever
in the unit test, and the breakpoint is getting into a halt inside of it.
回答4:
As @Gergely Hegedus mentions above, the CoroutineScope needs to be injected into the ViewModel. Using this strategy, the CoroutineScope is passed as an argument with a default null
value for production. For unit tests the TestCoroutineScope will be used.
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()
...
}
}
回答5:
The problem you are having stems not from runBlocking, but rather from LiveData not propagating a value without an attached observer.
I have seen many ways of dealing with this, but the simplest is to just use observeForever
and a 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)
}
This pattern is quite common and you are likely to see many projects with some variation of it as a function/method in some test utility class/file, e.g.
@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
}
Which you can call like this:
val result = viewModel.myLiveData.getTestValue()
Other projects make it a part of their assertions library.
Here is a library someone wrote dedicated to LiveData testing.
You may also want to look into the Kotlin Coroutine CodeLab
Or the following projects:
https://github.com/googlesamples/android-sunflower
https://github.com/googlesamples/android-architecture-components
来源:https://stackoverflow.com/questions/55765190/coroutines-unit-testing-viewmodelscope-launch-methods