Minute of Pain #4. ViewModel Testing

Or how to be surprised with flaw of LiveData

Recently I have joined to new project, where MVVM was implemented. As a view models the ViewModel architecture component was taken. Our view state was stored in LiveData, since it has attachment to lifecycle of Activity or Fragment and since it is a part of Android Jetpack, which is provided as one of best practices to use in your application development:

The Problem

Suppose we have next View Model:

interface SomeService {
fun makeJob(): Single<String>
}

data class ViewState(
private val isLoading: Boolean,
private val result: String,
private val error: String
)

class MyViewModel(
private val service: SomeService,
private val ioScheduler: Scheduler = Schedulers.io(),
private val mainScheduler: Scheduler = AndroidSchedulers.mainThread()
) : ViewModel() {
val data = MutableLiveData<ViewState>()

init {
//init data
data.value = ViewState(isLoading = false, result = "", error = "")
}

fun onProcessBtnClicked() {
data.reassign { copy(isLoading = true) }
service
.makeJob()
.subscribeOn(ioScheduler)
.observeOn(mainScheduler)
.subscribe(
{ data.reassign { copy(
isLoading = false,
result = it,
error = ""
) } },
{ data.reassign { copy(
isLoading = false,
result = "",
error = it.message ?: "error"
) } }
)
}

private inline fun <T> MutableLiveData<T>.reassign(block: T.() -> T) {
value = value?.block()
}
}
@RunWith(MockitoJUnitRunner::class)
class MyViewModelTest {
@Mock lateinit var serviceStub: SomeService
private val ioScheduler = TestScheduler()
private val mainScheduler = TestScheduler()

private val testViewModel: MyViewModel by lazy { MyViewModel(serviceStub, ioScheduler, mainScheduler) }

@Test
fun `when process btn is clicked, start job`() {
whenever(serviceStub.makeJob()) doReturn Single.just("")
testViewModel.onProcessBtnClicked()
verify(serviceStub, only()).makeJob()
}
}
Main Looper, wtf?!
private static void assertMainThread(String methodName) {
if (!ArchTaskExecutor.getInstance().isMainThread()) {
throw new IllegalStateException("Cannot invoke " + methodName + " on a background"
+ " thread");
}
}
@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
public class DefaultTaskExecutor extends TaskExecutor {

//...

@Override
public boolean isMainThread() {
return Looper.getMainLooper().getThread() == Thread.currentThread();
}
//...
}

Solution

As it turns out, it is not end of the world, and there is a workaround (provided by Google) that will help you to fix this problem. Here is just a line of code you need to add to your test class:

@get:Rule var rule: TestRule = InstantTaskExecutorRule()
@RunWith(MockitoJUnitRunner::class)
class MyViewModelTest {
@get:Rule var rule: TestRule = InstantTaskExecutorRule()

@Mock lateinit var serviceStub: SomeService
private val ioScheduler = TestScheduler()
private val mainScheduler = TestScheduler()

private val testViewModel: MyViewModel by lazy { MyViewModel(serviceStub, ioScheduler, mainScheduler) }

@Test
fun `when process btn is clicked, start job`() {
whenever(serviceStub.makeJob()) doReturn Single.just("")
testViewModel.onProcessBtnClicked()
verify(serviceStub, only()).makeJob()
}
}

How it works

Lets deep dive into newly added line of code and try to understand, what happens

protected void starting(Description description)
protected void finished(Description description)
protected void succeeded(Description description)
protected void failed(Throwable e, Description description)
protected void skipped(AssumptionViolatedException e, Description description)
Statement apply(Statement base, Description description)
public class InstantTaskExecutorRule extends TestWatcher {
@Override
protected void starting(Description description) {
super.starting(description);
ArchTaskExecutor.getInstance().setDelegate(new TaskExecutor() {
@Override
public void executeOnDiskIO(Runnable runnable) {
runnable.run();
}

@Override
public void postToMainThread(Runnable runnable) {
runnable.run();
}

@Override
public boolean isMainThread() {
return true;
}
});
}

@Override
protected void finished(Description description) {
super.finished(description);
ArchTaskExecutor.getInstance().setDelegate(null);
}
}

Afterwords

I can not say, why guys from Google team are decided to make things that way, but I’m glad that they remained a back door for unit tests.

Love being creative to solve some problems with an simple and elegant ways