Testing ViewModels and Activities in Your MVVM App
By Yawei Li & Shaurya Arora
This is part 3 of our 3-part series on implementing MVVM (Model-View-ViewModel) on Android using Google’s ViewModel
and LiveData
architecture components. In this part, we will take a look at how to write unit tests for an app that uses ViewModel and LiveData.
Read from the beginning if you’re not familiar with MVVM or Google’s architecture components.
Testing the view model
One of the main goals of MVVM is to minimize the complexity of activities and fragments. As a result, most of the logic lives inside the view model. Therefore, it’s especially important to test our view model thoroughly. Here’s a quick recap of our ViewModel
class:
We have 3 LiveData
s exposed to the activity: teams
, shouldShowError
and isLoading
. isLoading()
is initialized to true
and changed to false
at the end of loadTeams()
. This happens regardless of repository.refreshTeams()
successfully fetching data from the network, or throwing an exception. In both situations, we set isLoading
’s value to false
so that our observing activity can hide the loading animation. Data is fetched from the network and stored locally in the database in a separate DataRepository
class. Moreover, we only want to fetch data from the network if it is not already locally present.
Setting up the test
We run our test class with RobolectricTestRunner
. This is because ViewModel
depends on classes within the Android SDK. We create the necessary variables, as well as mocks for dependencies. Note that, since TeamViewModel
actually performs a transformation on repository.teams
, we need to make teamListLiveData
a spy. If it were just a mock, the transformation wouldn’t be triggered even though viewModel.teams
is being observed.
Test 1: isLoading should be false after calling repo.refreshTeams()
In the first test case we can verify that isLoading
’s value starts out as true
, and that after calling loadTeams()
, the value gets changed to false
. We can also verify that calling loadTeams()
triggers refreshTeams()
inside the repository. The test is wrapped inside runBlocking {}
to ensure that all running coroutines get a chance to complete before the test function returns.
Test 2: isError should be true if repo.refreshTeams() throws some exception
For this case, we want to verify that isError
is initially set to false
, and that it gets changed to true
after loadTeams()
is called if repo.refreshTeams()
threw an exception.
Note: Kotlin allows us to write our test function names as sentences by enclosing them in back-ticks (`). This improves readability in general, and especially when running your test suite.
Test 3: repo.refreshTeams() should be called when no data in the database
In this test case, if data returned from the database is empty, we want to verify that repo.refreshTeams()
is called — that is, data is fetched in the network.
At the beginning of the test, we simulate the condition of “no data in the database” by setting teamListLiveData
’s value as an empty list. We start observing viewModel.teams
via the observeForever()
method, which simulates an observer in an active state. This ensures that an update in the value of the LiveData
object backing viewModel.teams
is prepared to be delivered, hence triggering the map transformation inside the view model. At the end, we also assert that isLoading
and isError
have the expected values.
Test 4: repo.refreshTeams() should not be called if data already in the database
In this final test, we want to verify that repo.refreshTeams()
is never called if the list of teams is already present in the local database.
The structure of this test is quite similar to that of the last test. However, now, we set the value of teamListLiveData
to an actual list of teams to simulate data that is already stored in the database. Moreover, we verify that repo.refreshTeams()
is never called using the never()
invocation.
Testing the activity
Let’s take a quick look at our Activity
class before we begin writing tests:
As we can see, the majority of the data and logic is outside the activity. MainActivity
is simply observing the data points exposed by TeamViewModel
and updating the UI based on updates in those data points. Therefore, to test the activity, we will verify that our UI is in the correct state when LiveData
s in the view model deliver updates to MainActivity
via the onChanged()
method of the Observer
s. Note, also, that we have a @TestOnly
setter for the viewModel
property so that we can inject a mock view model in tests (can be avoided by using a DI framework such as Koin). We moved the subscribeUi()
call from onCreate()
to onStart()
so that in our tests, we have a chance to inject the mock ViewModel
object before the Observer
s are attached.
Setting up the test
We create variables for an instance of MainActivity
, as well as for an instance of ActivityController<MainActivity>
which will enable us to drive the MainActivity
instance through different lifecycle stages. We create mocks for TeamViewModel
, as well as for each of the three LiveData
s exposed by it (teams
, isLoading
and isError
). Finally, we create ArgumentCaptor
s for each of the three Observer
s in MainActivity
, and add in our MockitoRule
to initialize all annotated elements.
In the setUp()
function, we first invoke onCreate()
of the activity (via activityController.create()
). Then we swap out the real view model inside the activity with a mock via the setTestViewModel()
function. Only then do we start the activity via activityController.start()
.
Calling activityController.start()
invokes MainActivity
’s onStart()
function, which in turn invokes the subscribeUi()
function where all the observe
calls take place. We use Mockito’s verify()
function on each of the LiveData
mocks together with our captors to get the Observer
s that are attached inside subscribeUi()
.
Tests 1 and 2: initial view states
To start, we can write 2 quick and simple tests to assert that initially, the loading spinner is visible, and the error view and teams RecyclerView
are not shown.
Test 3: list of teams should be displayed when successfully retrieved
In this test, we simulate the success case: a valid list of teams is sent through by the view model.
As noted in the setup stage, we use our captors to simulate different test cases. Here, in the success case, we call onChanged()
on the Observer
for teams with a valid list containing two Team
objects. Moreover, we call onChanged()
on the Observer
s for isLoading
and isError
with false
. Then, we assert that the teams RecyclerView
is visible, and the loading spinner and error view are not shown. We also assert that the adapter of that RecyclerView
is holding on to a list of teams which is identical to the one that we passed in.
Test 4: error message should be shown if ViewModel
encounters exception
Finally, we test the error case: an error message should be displayed if the ViewModel
signals an error in retrieving the list of teams.
In this case, we don’t really care whether the Observer
for teams is invoked or not. We mostly care that the Observer
for isError
is invoked with true
, and that the Observer
for isLoading
is invoked with false
. We can see in `TeamViewModel` that this is the exact error scenario. Now, we assert that only the error message is visible, and that all other UI elements are not shown.
And that’s how you can test ViewModel
s and related activities!