ViewModel is a part of the Android Jetpack library and is designed to store and manage UI-related data in lifecycle-conscious way. It allows data to survive configuration changes such as screen rotation and it also separates the UI data from the Activity or Fragment, making it easier to test and maintain.
ViewModel can also communicate with a repository to get data from different sources like a database or remote API. In this case ViewModel is not responsible for handling data storage and it just receives the data from repository and it is used to update the UI.
There are several ways to test a ViewModel. If the ViewModel doesn’t get data from the repository, we can test it directly by creating ViewModel instance in the test class and than write the test methods.
If the ViewModel gets data from the repository, we need to take care of the repository class first, and then work with the ViewModel. For this purpose we will create a fake repository and pass it to the ViewModel:
@Before
fun Setup() {
fal fakeRepository = FakeTestRepository()
val viewModel = MyViewModel(fakeRepository)
FakeRepository extends BaseRepository interface (same as in DefaultRepository that we use in main program) and create temporary list that we will use for testing instead of the Room database.
class FakeTestRepository: BaseRepository {
private val imageItemList = mutableListOf<ImageDataModel>()
override var allImagesFromDao: Flow<List<ImageDataModel>> = flow { emit(imageItemList) }
override suspend fun insert(imageDataModel: ImageDataModel): Long {
imageItemList.add(imageDataModel)
return 1 }
override suspend fun update(imageDataModel: ImageDataModel): Int {
imageItemList.add(imageDataModel)
return 1 }
override suspend fun delete(imageDataModel: ImageDataModel): Int {
imageItemList.remove(imageDataModel)
return 1 }
override suspend fun deleteAll(): Int {
imageItemList.removeAll(imageItemList)
return 1 }
}
In the test folder we will create MyViewModelTest class and annotated it with @RunWith(MockitoJUnitRunner::class)
. After that we need to define InstantTaskExecutorRule which swaps the background executor used by the Architecture Components with a different one which executes each task synchronously.
@RunWith(MockitoJUnitRunner::class)
class MyViewModelTest {
@get:Rule
var instantTaskExecutorRule = InstantTaskExecutorRule()
@OptIn(ExperimentalCoroutinesApi::class)
@get:Rule
var mainCoroutineRule = MainCoroutineRule()
The second rule is MainCoroutineRule() which we use to test coroutines. Since coroutines can be asynchronous and run across multiple threads, we meed to use TestDispachers to run the test on a single test thread if new coroutines are created during the test.
@ExperimentalCoroutinesApi
class MainCoroutineRule (
private val dispatcher: CoroutineDispatcher = TestCoroutineDispatcher()
) : TestWatcher(), TestCoroutineScope by TestCoroutineScope(dispatcher) {
override fun starting(description: Description?) {
super.starting(description)
Dispatchers.setMain(dispatcher)
}
override fun finished(description: Description?) {
super.finished(description)
cleanupTestCoroutines()
Dispatchers.resetMain()
}
}
Our ViewModel uses LiveData, and to be able to access that data we need to use Observer and CountDownLatch in our code:
@Test
fun `insert data, check if data are inserted and return success`(){
val img1 = ImageDataModel(id = 1, "http://www.example.com/img1.png", "image1")
viewModel.insert(img1)
val latch = CountDownLatch(1)
val observer = Observer<List<ImageDataModel>> {
assertEquals(img1, it[0])
latch.countDown()
}
viewModel.getSavedImages().observeForever(observer)
if (!latch.await(2, TimeUnit.SECONDS)) {
throw TimeoutException("LiveData value was never set.")
}
}
The CountDownLatch counter is initialized with a number of threads. The counter is decremented each time a thread completes its execution. When the count reaches zero, it means that all threads have completed their execution, and the main thread waiting on the latch resumes the execution. That way we can observe our livedata and get necessary information for test. To avoid duplication code in each test, we can use a generic function that we can call from our test:
/* Copyright 2019 Google LLC.
SPDX-License-Identifier: Apache-2.0 */
fun <T> LiveData<T>.getOrAwaitValue(
time: Long = 2,
timeUnit: TimeUnit = TimeUnit.SECONDS
): T {
var data: T? = null
val latch = CountDownLatch(1)
val observer = object : Observer<T> {
override fun onChanged(o: T?) {
data = o
latch.countDown()
this@getOrAwaitValue.removeObserver(this)
}
}
this.observeForever(observer)
// Don't wait indefinitely if the LiveData is not set.
if (!latch.await(time, timeUnit)) {
throw TimeoutException("LiveData value was never set.")
}
@Suppress("UNCHECKED_CAST")
return data as T
}
And now the test functions can look like this:
@Test
fun `insert data, get list from livedata, return success if equal`() {
val img1 = ImageDataModel(id = 1, "http://www.example.com/img1.png", "image1")
viewModel.insert(img1)
assertEquals(viewModel.getSavedImages().getOrAwaitValue()[0].title, "image1")
assertEquals(viewModel.getSavedImages().getOrAwaitValue()[0], img1)
}
And for validating input in our ViewModel we can do:
@Test
fun `verify user input, empty value in title input field, return false`() {
val result = viewModel.validateInput(
"",
"http://www.example.com/image2.jpg"
)
assertThat(result).isFalse()
}
Test function for deleting item:
@Test
fun `delete item, return true`() {
val item = ImageDataModel(id = 1, "www.example.com/image2.jpg", "image2")
viewModel.insert(item)
viewModel.deleteItem(item)
assertThat(viewModel.stmessage.value).isEqualTo("1 Row deleted successfully")
}
The rest of the code you can find here