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:
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:
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.