Unit Test for Android ViewModel: LiveData, Coroutines, and Mock

Hoang Dinh
ITNEXT
Published in
6 min readMar 26, 2023

--

Photo by Ferenc Almasi on Unsplash

When I develop an Android application, I follow some “best practice” articles about Coroutines, LiveData, MVVM patterns… There are a lot of things that need to be catchup, especially for a backend developer like me.

When I tried to write unit tests for my main logic — ViewModel class, it did not work! Some articles are out of date, library versions do not match… And some kind of error that I don't know where they come from.

And this story is just a simple note — “How to make it work?”. It may not be a best practice, but I think that is enough for me and my project for now.

Demo android application

This is a simple application with LoginActivity that accepts the user enter username, password, and press Login button to log in to the application.

class LoginActivity : AppCompatActivity() {
private lateinit var viewModel: LoginViewModel
private lateinit var usernameTextView: TextView
private lateinit var passwordTextView: TextView
private lateinit var messageTextView: TextView
private lateinit var loginButton: Button
private lateinit var processBar: ProgressBar

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_login)
usernameTextView = findViewById(R.id.text_username)
passwordTextView = findViewById(R.id.text_password)
messageTextView = findViewById(R.id.text_message)
loginButton = findViewById(R.id.button_login)
processBar = findViewById(R.id.progress_bar)

viewModel = LoginViewModel(UserRepository(), SessionManager())

viewModel.viewState.observe(this) {
updateUI(it)
}

loginButton.setOnClickListener {
viewModel.login(usernameTextView.text.toString(), passwordTextView.text.toString())
}
}

private fun updateUI(viewState: LoginViewModel.ViewState) {
when (viewState) {
is LoginViewModel.ViewState.Loading -> {
processBar.visibility = View.VISIBLE
messageTextView.visibility = View.INVISIBLE
}
is LoginViewModel.ViewState.Content -> {
processBar.visibility = View.GONE
finish()
startActivity(Intent(this, MainActivity::class.java))
}
is LoginViewModel.ViewState.Error -> {
processBar.visibility = View.GONE
messageTextView.text = viewState.message
messageTextView.visibility = View.VISIBLE
}
}
}
}

That is a simple android activity, we create a view model “manually” by calling its constructor function with all dependencies.

The UI will be updated depending on the type of view state — Loading, Content, Error The updateUI method is called by the viewModel.viewState observer.

Finally, the viewModel.login function will be called when the loginButton is clicked.

Let’s take a look into LoginViewModel class:

class LoginViewModel(
private val userRepository: UserRepository,
private val sessionManager: SessionManager,
) : ViewModel() {
private val _viewState = MutableLiveData<ViewState>()
val viewState: LiveData<ViewState> get() = _viewState

fun login(username: String, password: String) {
viewModelScope.launch {
_viewState.postValue(ViewState.Loading)

try {
val result = userRepository.login(username, password)
_viewState.postValue(ViewState.Content(result))
sessionManager.saveAccessToken(result.accessToken)
} catch (exception: Exception) {
_viewState.postValue(ViewState.Error(exception.message!!))
}
}
}

sealed class ViewState {
object Loading : ViewState()
data class Error(val message: String) : ViewState()
data class Content(val loginResponse: UserRepository.LoginResponse) : ViewState()
}
}

There are 2 dependencies:

  • UserRepository : Connects to an api to verify the user’s credentials, and respond an accessToken string.
  • SessionManager : Use Preferences api to store the user access token

The login function uses coroutines to perform the login process asynchronously ( userRepository.login is a suspend function)

Test it

For now, our view model just includes only one function, let’s cover it.

With Android Studio, we can create a test for a class easily by selecting the class, hitting Option + Return then select Create test

Create test

I’m not sure about Testing library but I can run the test without extending any supper class.

We want to test the view model, then we will try to mock all dependencies. I select mockk indeed of mockito because it looks simple to start! Let’s install mockk library by updating dependencies block of app/build.gradle file:

testImplementation 'io.mockk:mockk:1.13.4'

Is that enough? First, try

package io.codetheworld.viewmodelunittestdemo

import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import io.mockk.verify
import org.junit.Assert
import org.junit.Before
import org.junit.Test

class LoginViewModelTest {
private val userRepository: UserRepository = mockk()
private val sessionManager: SessionManager = mockk()
private lateinit var viewModel: LoginViewModel
private val username = "username"
private val password = "password"

@Before
fun setup() {
viewModel = LoginViewModel(userRepository, sessionManager)
}

@Test
fun `should emit error object when api response error`() {
val message = "Error from api"
coEvery {
userRepository.login(any(), any())
} throws IllegalAccessException(message)

viewModel.login(username, password)

coVerify {
userRepository.login(username, password)
}
Assert.assertEquals(LoginViewModel.ViewState.Error(message), viewModel.viewState.value)
verify(exactly = 0) {
sessionManager.saveAccessToken(any())
}
}
}

When I run the test, it throws something like

Exception in thread "Test worker" java.lang.IllegalStateException: Module with the Main dispatcher had failed to initialize. For tests Dispatchers.setMain from kotlinx-coroutines-test module can be used

Ok, It requires another module — `kotlinx-coroutines-test`

testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.4'

And update the test to set a dispatcher for the test worker

diff --git a/app/src/test/java/io/codetheworld/viewmodelunittestdemo/LoginViewModelTest.kt b/app/src/test/java/io/codetheworld/viewmodelunittestdemo/LoginViewModelTest.kt
index 0e5f067..59eec1f 100644
--- a/app/src/test/java/io/codetheworld/viewmodelunittestdemo/LoginViewModelTest.kt
+++ b/app/src/test/java/io/codetheworld/viewmodelunittestdemo/LoginViewModelTest.kt
@@ -4,22 +4,37 @@ import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import io.mockk.verify
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.TestDispatcher
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import kotlinx.coroutines.test.resetMain
+import kotlinx.coroutines.test.setMain
+import org.junit.After
import org.junit.Assert
import org.junit.Before
import org.junit.Test

+@OptIn(ExperimentalCoroutinesApi::class)
class LoginViewModelTest {
private val userRepository: UserRepository = mockk()
private val sessionManager: SessionManager = mockk()
private lateinit var viewModel: LoginViewModel
private val username = "username"
private val password = "password"
+ private val dispatcher: TestDispatcher = UnconfinedTestDispatcher()

@Before
fun setup() {
+ Dispatchers.setMain(dispatcher)
viewModel = LoginViewModel(userRepository, sessionManager)
}

+ @After
+ fun tearDown() {
+ Dispatchers.resetMain()
+ }
+
@Test
fun `should emit error object when api response error`() {
val message = "Error from api"

Run again, throws an error again :|

Exception in thread "Test worker @coroutine#3" java.lang.RuntimeException: Method getMainLooper in android.os.Looper not mocked. See https://developer.android.com/r/studio-ui/build/not-mocked for details.

It seems Looper is not mocked??? I found the answer on SO

Add a new dependency

testImplementation 'androidx.arch.core:core-testing:2.2.0'

Then, the test file should be updated following these changes

diff --git a/app/src/test/java/io/codetheworld/viewmodelunittestdemo/LoginViewModelTest.kt b/app/src/test/java/io/codetheworld/viewmodelunittestdemo/LoginViewModelTest.kt
index 59eec1f..013cd9f 100644
--- a/app/src/test/java/io/codetheworld/viewmodelunittestdemo/LoginViewModelTest.kt
+++ b/app/src/test/java/io/codetheworld/viewmodelunittestdemo/LoginViewModelTest.kt
@@ -1,5 +1,6 @@
package io.codetheworld.viewmodelunittestdemo

+import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
@@ -13,7 +14,9 @@ import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Assert
import org.junit.Before
+import org.junit.Rule
import org.junit.Test
+import org.junit.rules.TestRule

@OptIn(ExperimentalCoroutinesApi::class)
class LoginViewModelTest {
@@ -24,6 +27,9 @@ class LoginViewModelTest {
private val password = "password"
private val dispatcher: TestDispatcher = UnconfinedTestDispatcher()

+ @get:Rule
+ val rule: TestRule = InstantTaskExecutorRule()
+
@Before
fun setup() {
Dispatchers.setMain(dispatcher)

Finally, It works

Test result

Refactor, and cover more case

We have to set up a dispatcher and reset it when we have a new test file. It is a little noisy. We can create an extended TestWatcher class which will extend `InstantTaskExecutorRule` (Magic), then just use the new test rule as our test rule.

import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.*
import org.junit.runner.Description

@OptIn(ExperimentalCoroutinesApi::class)
class MainDispatcherRule(
private val dispatcher: TestDispatcher = UnconfinedTestDispatcher()
) : InstantTaskExecutorRule() {
override fun starting(description: Description) {
super.starting(description)

Dispatchers.setMain(dispatcher)
}

override fun finished(description: Description) {
super.finished(description)

Dispatchers.resetMain()
}
}

And the test will become

package io.codetheworld.viewmodelunittestdemo

import io.codetheworld.viewmodelunittestdemo.helpers.MainDispatcherRule
import io.mockk.*
import kotlinx.coroutines.ExperimentalCoroutinesApi
import org.junit.Assert
import org.junit.Before
import org.junit.Rule
import org.junit.Test

@OptIn(ExperimentalCoroutinesApi::class)
class LoginViewModelTest {
private val userRepository: UserRepository = mockk()
private val sessionManager: SessionManager = mockk()
private lateinit var viewModel: LoginViewModel
private val username = "username"
private val password = "password"
private lateinit var viewStates: MutableList<LoginViewModel.ViewState>

@get:Rule
val rule = MainDispatcherRule()

@Before
fun setup() {
viewModel = LoginViewModel(userRepository, sessionManager)
viewStates = mutableListOf()
viewModel.viewState.observeForever {
viewStates.add(it)
}
}

@Test
fun `should emit error object when api response error`() {
val message = "Error from api"
coEvery {
userRepository.login(any(), any())
} throws IllegalAccessException(message)

viewModel.login(username, password)

coVerify {
userRepository.login(username, password)
}
Assert.assertEquals(LoginViewModel.ViewState.Loading, viewStates[0])
Assert.assertEquals(LoginViewModel.ViewState.Error(message), viewStates[1])
verify(exactly = 0) {
sessionManager.saveAccessToken(any())
}
}

@Test
fun `should emit content object when api response success`() {
val loginResponse = UserRepository.LoginResponse("accessToken")
coEvery {
userRepository.login(any(), any())
} returns loginResponse

viewModel.login(username, password)

coVerify {
userRepository.login(username, password)
}
Assert.assertEquals(LoginViewModel.ViewState.Loading, viewStates[0])
Assert.assertEquals(LoginViewModel.ViewState.Content(loginResponse), viewStates[1])
verify {
sessionManager.saveAccessToken(loginResponse.accessToken)
}
}
}
Test result

Conclusion

That’s all for my story, but still, there are a lot of things to learn like spy, stub…But that is good for me, and we can start building a new application by following TDD process.

Hope my story will help you guys who start writing unit tests for android code.

Example source code is published at GitHub.

Thanks!

--

--