Minute of Pain #4. ViewModel Testing

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:

Jetpack is a collection of Android software components to make it easier for you to develop great Android apps. These components help you follow best practices, free you from writing boilerplate code, and simplify complex tasks, so you can focus on the code you care about.

So when I joined to this project, I’ve started to develop big chunk of new functionality. I’ve created a separate module and during the development process I’ve written the code as a main application was written (using MVVM).

At some point I decided to cover the presentation layer with appropriate tests. So my aim was to mock components, used by ViewModel and test it.

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

When process button is clicked (when onProcessBtnClicked called) view state updates isLoading property to true and SomeService starts its job (makeJob is called). When job is finished view state updates isLoading to false and change its result or error to appropriate result of job.

Now let’s write our small test:

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

We provided testing schedulers for controlling SomeService delivering results time and also we mocked SomeService for controlling results of makeJob method.

Seems easy, right? It should be, but unfortunately we receive:

Main Looper, wtf?!

The problem lies underneath LiveData, since its method setValue has mainThread checking:

private static void assertMainThread(String methodName) {
if (!ArchTaskExecutor.getInstance().isMainThread()) {
throw new IllegalStateException("Cannot invoke " + methodName + " on a background"
+ " thread");
}
}

Since this method is static and using ArchTaskExecutor as main thread check TaskExecutor is hardcoded and ArchTaskExecutor uses DefaultTaskExecutor inside, we receive this error:

@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
public class DefaultTaskExecutor extends TaskExecutor {

//...

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

Why is that? — you asking.

It looks like serious architecture flaw, but I hope, Google developers had important reasons to do that.

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

So the whole code of MyViewModelTest will be next:

@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

First of all @Rule annotation is used to mark test rules that are need to be applied for each test. TestRule is just an interface that declare wrapping method. It will envelope each test with some additional preparation and clean up logic. There is a TestWatcher abstract class, which provides more convenient methods for further inheritors:

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)

That just one method in TestRule :

Statement apply(Statement base, Description description)

So, if you want to implement your own test rule, you need write your class and either implement TestRule or one of its inheritors, like TestWatcher.

It is a convenient solution, if you, for example, have somewhere in the code a Singleton with mutable state, and you want to adapt it for testing, but don’t want to remember, that each time after test invocation you need to move Singleton back to original state.

I’ve already wrote couple of years ago, why Singletons with mutable state are bad. You can check it, if you interested. But the truth is, that sometimes mutable stateful Singletons happens in a projects. That happened with Architecture components. That’s we Google provided it’s own test rule. If we go and see, what happens inside, everything will become clear:

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

ArchTaskExecutor internally uses DefaultTaskExecutor which is android specific executor (it uses Handler inside). But you can specify your own executor through setDelegate calling. If you pass null then DefaultTaskExecutor will be selected again.

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.

If you liked this article, please, do not forget to clap and read my other articles like: retrofit + client cache, android’s PagedList + ViewPager or creating operation scopes with kotlin.

--

--

--

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

Love podcasts or audiobooks? Learn on the go with our new app.

Recommended from Medium

CS373 Spring 2022: Dale Kang

Using generics and descriptors to standardise icons, images and placeholders on iOS

Get a Basic Understanding of the Life Cycles of Software Development

Configure Nginx for a Production Environment

How I learned to Read Documentation Using Bootstrap

Blog 7 CS373 Fall 2021

ShardingSphere 4.x Sharding Read-write splitting Unsupported Items

Lessons I Learned From Renaming in Ruby

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store
Michael Spitsin

Michael Spitsin

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

More from Medium

fragment stuck cannot move to another fragment in Android Navigation

Android Development — MockK 101 (Part 1)

Android Test Driven Development

Generic SharedPreferences in Android