Writing unit tests for ViewModel
In this age of TDD where we are supposed write tests first and then implement functionality, we are at-least expected to write test cases for our code. Someone new in android development or unit testing might find difficulties to write tests for a ViewModel. In this article we will try to write test cases for some very common scenarios such as:
- Basic class
- Class with dependency(using Mockito)
- ViewModel containing LiveData
- ViewModel which uses coroutines
- ViewModel which uses interface for callbacks
First let’s create an application which will have a simple login functionality. I just used the login activity template provided in Android Studio (File->New-> New project->Login Activity).
1. Basic class
We will write test case for login method inside LoginDataSource that was generated with the template. I have edited LoginDataSource class to something like this:
class LoginDataSource {
fun login(username: String, password: String): Result<LoggedInUser> {
return if (username in users) {
val fakeUser = LoggedInUser(java.util.UUID.randomUUID().toString(), username)
Result.Success(fakeUser)
} else {
Result.Error(Exception("Invalid User"))
}
}
private val users = arrayOf("Joe Paul", "Jane Doe", "Peter Parker")
}
First we can create a test class by ⌥ Option +
Return on the class name. Select Junit4 and check the setup/@Before option. This will generate a test class for us.
Let’s see what we need to test in this method. We need to assert that when we pass valid credentials, it will return success. Otherwise it should return error. eg. login(“Joe Paul”, “123456”) should return success and login(“Jacob Zach”, “123456”) should return error.
Test class will look something like this:
class LoginDataSourceTest {
private lateinit var dataSource: LoginDataSource
@Before
fun setUp() {
dataSource = LoginDataSource()
}
@Test
fun `login with valid credential should return success`() {
assert(dataSource.login("Joe Paul", "123456") is Result.Success)
}
@Test
fun `login with invalid credential should return error`() {
assert(dataSource.login("Jacob Zach", "123456") is Result.Error)
}
}
This is a basic test and I think it is self explanatory. We are creating the data source in setup method and it will be used in test cases.
2. Class with dependency
Now imagine that the class we are about to test is having some dependency on some other class and it is being passed through the constructor.
class LoginRepository(val dataSource: LoginDataSource) {
var user: LoggedInUser? = null
private set
val isLoggedIn: Boolean
get() = user != null
init {
user = null
}
fun login(username: String, password: String): Result<LoggedInUser> {
val result = dataSource.login(username, password)
if (result is Result.Success) {
setLoggedInUser(result.data)
}
return result
}
private fun setLoggedInUser(loggedInUser: LoggedInUser) {
this.user = loggedInUser
}
}
Here dataSource is passed as a parameter through the constructor. What we can test here is when the method login is called with valid credentials isLoggedIn should return true. But here we are dependent on LoginDataSource for making the login call. Imagine if the dataSource making an actual api call for each test. This will take too much time to run the test suite.
One option is to create a fake data source class. But for that we will be creating an entire new class which serves no functionality and will be used only for testing. Instead we can use Mockito framework to mock the dataSource. Setup mockito like mentioned in this link. We will be using MockitoJUnitRunner to run the test. Here we will be mocking the data source using the Mock annotation which will create a mock object for the LoginDataSource.
@Mock
private lateinit var dataSource: LoginDataSource
As this will be a mock object we need define its required behaviour. Lets say when the login method in dataSource is called, we need to specify its behaviour.
Mockito.`when`(dataSource.login("Joe Paul", "123456")).thenReturn(
Result.Success(
LoggedInUser("1234567890", "Joe paul")
)
)
If we test the scenario when valid user credentials are provided, it will look something like this.
@RunWith(MockitoJUnitRunner::class)
class LoginRepositoryTest {
@Mock
private lateinit var dataSource: LoginDataSource
private lateinit var repository: LoginRepository
@Before
fun setUp() {
repository = LoginRepository(dataSource)
}
@Test
fun `login with valid credential should set user`() {
Mockito.`when`(dataSource.login("Joe Paul", "123456")).thenReturn(
Result.Success(
LoggedInUser("1234567890", "Joe paul")
)
)
repository.login("Joe Paul", "123456")
assert(repository.isLoggedIn)
}
}
3. ViewModel containing LiveData
Now we have reached the main section of this article. Everything we mentioned in the previous sections will be applicable here. Let’s have a look at our ViewModel class first.
class LoginViewModel(private val loginRepository: LoginRepository, dispatcher: CoroutineDispatcher) : ViewModel() {
private val _loginForm = MutableLiveData<LoginFormState>()
val loginFormState: LiveData<LoginFormState> = _loginForm
private val inputCredentials = MutableLiveData<Pair<String, String>>()
fun login(username: String, password: String) {
inputCredentials.value = Pair(username, password)
}
val loginResult: LiveData<LoginResult> = Transformations.switchMap(inputCredentials) {
liveData(dispatcher) {
val result = loginRepository.login(it.first, it.second)
if (result is Result.Success) {
emit(LoginResult(success = LoggedInUserView(displayName = result.data.displayName)))
} else {
emit(LoginResult(error = R.string.login_failed))
}
}
}
fun loginDataChanged(username: String, password: String) {
if (!isUserNameValid(username)) {
_loginForm.value = LoginFormState(usernameError = R.string.invalid_username)
} else if (!isPasswordValid(password)) {
_loginForm.value = LoginFormState(passwordError = R.string.invalid_password)
} else {
_loginForm.value = LoginFormState(isDataValid = true)
}
}
fun isUserNameValid(username: String): Boolean {
return if (username.contains('@')) {
PatternsCompat.EMAIL_ADDRESS.matcher(username).matches()
} else {
username.isNotBlank()
}
}
private fun isPasswordValid(password: String): Boolean {
return password.length > 5
}
}
We will write a test case for the function loginDataChanged. We need to test that livedata loginFormState will hold the correct value during scenarios such as invalid username, invalid password and valid input. First thing we need to remember while writing test case involving LiveData is to add InstantTaskExecutorRule so that the code will run synchronously. For that we need to add the following library to our build.gradle file.
testImplementation "android.arch.core:core-testing:1.1.1"
We will try to write test case for the scenario when username is invalid.
@RunWith(MockitoJUnitRunner::class)
class LoginViewModelTest {
@Mock
private lateinit var repository: LoginRepository
private lateinit var viewModel: LoginViewModel
@Rule
@JvmField
val instantTaskExecutorRule = InstantTaskExecutorRule()
@Before
fun setUp() {
viewModel = LoginViewModel(repository)
}
@Test
fun `loginDataChanged with valid username should set loginFormState as error`() {
viewModel.loginDataChanged("", "123456")
assert(viewModel.loginFormState.value?.usernameError != null)
}
}
Here we are asserting that usernameError is not null when we pass an empty username. You can find a great article about testing livedata written by Jose Alcérreca in this link. An important thing to remember for testing a livedata which is transformed is to attach an observer to that livedata. As we are not transforming livedata here, we skipped adding an observer.
4. ViewModel which uses coroutines
We can rewrite the login method in our ViewModel in such a way that we are launching a coroutine from ViewModel and the returned data is converted to a LiveData. This is something we have already done in our previous article. We will be using Livedata-ktx for this. Important thing to remember here is to pass our CoroutineDispatcher through constructor instead of hardcoding it to make it more testable. So our viewmodel looks something like this:
class LoginViewModel(private val loginRepository: LoginRepository, dispatcher: CoroutineDispatcher) : ViewModel() {
private val _loginForm = MutableLiveData<LoginFormState>()
val loginFormState: LiveData<LoginFormState> = _loginForm
private val inputCredentials = MutableLiveData<Pair<String, String>>()
fun login(username: String, password: String) {
inputCredentials.value = Pair(username, password)
}
val loginResult: LiveData<LoginResult> = Transformations.switchMap(inputCredentials) {
liveData(dispatcher) {
val result = loginRepository.login(it.first, it.second)
if (result is Result.Success) {
emit(LoginResult(success = LoggedInUserView(displayName = result.data.displayName)))
} else {
emit(LoginResult(error = R.string.login_failed))
}
}
}
fun loginDataChanged(username: String, password: String) {
if (!isUserNameValid(username)) {
_loginForm.value = LoginFormState(usernameError = R.string.invalid_username)
} else if (!isPasswordValid(password)) {
_loginForm.value = LoginFormState(passwordError = R.string.invalid_password)
} else {
_loginForm.value = LoginFormState(isDataValid = true)
}
}
private fun isUserNameValid(username: String): Boolean {
return if (username.contains('@')) {
Patterns.EMAIL_ADDRESS.matcher(username).matches()
} else {
username.isNotBlank()
}
}
private fun isPasswordValid(password: String): Boolean {
return password.length > 5
}
}
We have used a switchMap transformation here so that whenever inputCredentials changes, login method will be triggered. How can we test this method? We already know that as we are using transformation here we need to observe the livedata to test it. For that we will use the getOrAwaitValue method mentioned in the link. Now we move to the coroutines. Jetbrains provides a library which has TestCoroutineDispatcher which can be used in our tests. More about that can be found in this link.
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.3.6'
Now we can replace our main dispatcher with TestCoroutineDispatcher. For that we can create a custom rule and apply that rule to all the test classes with coroutines.
@ExperimentalCoroutinesApi
class MainCoroutineRule(
val testDispatcher: TestCoroutineDispatcher = TestCoroutineDispatcher()
) : TestWatcher() {
override fun starting(description: Description?) {
super.starting(description)
Dispatchers.setMain(testDispatcher)
}
override fun finished(description: Description?) {
super.finished(description)
Dispatchers.resetMain()
testDispatcher.cleanupTestCoroutines()
}
}
Finally our test class for ViewModel will look like this:
@ExperimentalCoroutinesApi
@RunWith(MockitoJUnitRunner::class)
class LoginViewModelTest {
@Mock
private lateinit var repository: LoginRepository
private lateinit var viewModel: LoginViewModel
@Rule
@JvmField
val instantTaskExecutorRule = InstantTaskExecutorRule()
@Rule
@JvmField
val testCoroutineRule = MainCoroutineRule()
@Before
fun setUp() {
viewModel = LoginViewModel(repository, testCoroutineRule.testDispatcher)
}
@Test
fun `login with valid username should set loginResult as success`() {
testCoroutineRule.testDispatcher.runBlockingTest {
Mockito.`when`(repository.login("Joe Paul", "123456")).thenReturn(
Result.Success(
LoggedInUser("31322", "Joe Paul")
)
)
viewModel.login("Joe Paul", "123456")
assert(viewModel.loginResult.getOrAwaitValue().success != null)
}
}
}
We are using testDispatcher inside our testCoroutineRule to run the coroutine block. We will assert that loginResult contains a success value if we pass valid credentials.
5. ViewModel which uses interface for callbacks
Now imagine that we are working some legacy code which is using interfaces for callbacks from our data source or repository class and we don’t want to make any changes in those classes. In this scenario, we will be passing the callback object to our repository through login method. So we will change login method in ViewModel accordingly.
fun login(username: String, password: String) {
loginRepository.login(username, password, object : LoginCallback {
override fun dataFetched(result: Result<LoggedInUser>?) {
if (result is Result.Success) {
_loginResult.value =
LoginResult(success = LoggedInUserView(displayName = result.data.displayName))
} else {
_loginResult.value = LoginResult(error = R.string.login_failed)
}
}
})
}
LoginCallback.kt
interface LoginCallback {
fun dataFetched(result: Result<LoggedInUser>?)
}
And login method in repository will be:
fun login(username: String, password: String, callback: LoginCallback) {
val result = dataSource.login(username, password)
callback.dataFetched(result)
}
As we have made a mock object for our repository it won’t be able to trigger the ‘dataFetched’ call while running a test. ArgumentCaptor in Mockito comes to our rescue. It will be able to capture the parameter(LoginCallback) we are passing to the login method and we will be able to manually trigger ‘dataFetched’. Mockito also provides ArgumentMatchers which helps us to verify the arguments provided to login method.
@Test
fun `login with valid username should set loginResult as success`() {
viewModel.login("Joe Paul", "123456")
val captor = ArgumentCaptor.forClass(LoginCallback::class.java)
Mockito.verify(repository).login(eq("Joe Paul"), eq("123456"), captor.capture())
captor.value.dataFetched(Result.Success(LoggedInUser("1234567", "Joe Paul")))
assert(viewModel.loginResult.value?.success != null)
}
If we run this test we will get an NullPointerException saying “eq(“Joe Paul”) must not be null”. This is a known issue when we use mockito in kotlin. In android architecture sample code, they are providing some fixes for this issue by adding the following methods. In this article we will using that. Otherwise you can always use the mockito-kotlin library.
fun <T> eq(obj: T): T = Mockito.eq<T>(obj)
fun <T> any(): T = Mockito.any<T>()
fun <T> capture(argumentCaptor: ArgumentCaptor<T>): T = argumentCaptor.capture()
inline fun <reified T : Any> argumentCaptor(): ArgumentCaptor<T> =
ArgumentCaptor.forClass(T::class.java)
Until mockito comes up with their own solution I think we can use either of them. So our final test class which uses the new methods will look like this:
@RunWith(MockitoJUnitRunner::class)
class LoginViewModelTest {
@Mock
private lateinit var repository: LoginRepository
private lateinit var viewModel: LoginViewModel
@Rule
@JvmField
val instantTaskExecutorRule = InstantTaskExecutorRule()
@Before
fun setUp() {
viewModel = LoginViewModel(repository)
}
@Test
fun `login with valid username should set loginResult as success`() {
viewModel.login("Joe Paul", "123456")
val captor = argumentCaptor<LoginCallback>()
Mockito.verify(repository).login(eq("Joe Paul"), eq("123456"), capture(captor))
captor.value.dataFetched(Result.Success(LoggedInUser("1234567", "Joe Paul")))
assert(viewModel.loginResult.value?.success != null)
}
}
Conclusion
In this article we tried to cover some common scenarios in a viewmodel although we have excluded rxjava and coroutine flow. Please let me know in the comments if you have any suggestions, improvements or queries.