In this blog we are going to see how to write test cases for the login feature we added in the previous part. If you haven’t checked that already, check that out before moving forward. Login feature
To know about testing and different types of testing check out this blog to start. Testing
To start with, let’s write unit test cases for the EmailValidator.kt file.
To create a unit test file , right click inside the file -> generate -> test -> choose junit4, provide the class name ( preferably the class name with suffix test ).
Since it is a unit test we can choose the test directory.
Next step is writing our first unit test case. Make sure to write all the failure cases first and then write the success case.
In this case,
- The email can be null or empty
- It can be an incomplete email address
- And a valid registered or unregistered email address.
class EmailValidatorTest {
@Test
fun `when email is valid test passed`() {
val email = "user@mail.com"
Assert.assertTrue(EmailValidator.isValidEmail(email))
}
@Test
fun `when email is invalid test fails`() {
val email = "user@mail"
Assert.assertFalse(EmailValidator.isValidEmail(email))
}
@Test
fun `when email is empty or null test fails`(){
Assert.assertFalse(EmailValidator.isValidEmail(null))
}
}
You can either run all three test cases together or each test case individually.
Right click on the run icon near the class or near the fun and then select run.
Once the execution is complete, the result will be displayed below with the number of test cases executed and their status.
Code Coverage :
We can also check the code coverage of the test cases we have written. This will help us in writing more test cases to cover most of the functionalities present in a particular class.
To check the code coverage ,
This will run the test cases and display the result and code covered by the test cases.
Similarly , we can add unit tests for viewmodels and repositories. In addition to that we can add UI testing to the application in the androidTest folder for activities and fragments.
Next we need to write test cases for each module level.
Repository , ViewModel and activity / fragments. Let’s start with repository testing.
Repository testing :
To test a repository we need to create a fake repository which implements the same functionality as the actual repository. This way we don’t have to make an api call to test the repository.
The fake repository can return fake responses which can be used to assert in the unit testing. Let’s create a FakeLoginRepository.kt file and implement the LoginRepositoryInterface.kt which provides all the methods used in the actual repository.
class LoginRepositoryTest {
private val loginRepository = FakeLoginRepository()
@Test
fun loginWithUnRegisteredUser() = runBlocking {
Assert.assertFalse(loginRepository.loginUser("unregistereduser@mail.com", "password"))
}
@Test
fun loginWithRegisteredUser() = runBlocking {
Assert.assertTrue(loginRepository.loginUser("fakeuser@mail.com", "password"))
}
}
loginUser() is a suspended function call. To run that is jvm we added runBlocking { }. You can add a delay before the async call in the repository to simulate it like an actual api call.
ViewModel testing :
Create a unit test file for LoginActivityViewModel.kt. We don’t need to worry about hilt, we can create the viewmodel instance and directly inject the fake repository.
class LoginActivityViewModelTest {
private lateinit var loginActivityViewModel: LoginActivityViewModel
@Before
fun setUp() {
loginActivityViewModel = LoginActivityViewModel(FakeLoginRepository())
}
@After
fun cleanUp() {
}
}
The function annotated with @Before will be executed before a test case is executed. And the function annotated with @After will be executed when a test case execution is completed. These functions can be used to initialize variables and cleanup.
Now let’s write our first test case for LoginViewModel.
We have only the login() function in the viewmodel.
In our current flow the login button will be enabled only when valid email id and password is entered.
Let’s create a method to update the email and password live data value in the test file.
private fun setData(email: String, password: String) {
loginActivityViewModel.emailMLD.value = email
loginActivityViewModel.passwordMLD.value = password
}
Next step we need to check whether the login button is enabled or not. Which we can determine by the isLoginEnabled live data value.
Live data is mutable and it needs to be observed to get the latest data. To observe we need a lifecycle owner, which we don’t have in this scenario, since we are not having any activity/fragment.
To observe we can user observe forever which will be observing the live data. In testing scenarios we generally use a fake repository which will return data immediately or with some delay.
We can create an extension function for the live data to observe for a few seconds and return the data instead of observing it forever.
https://developer.android.com/codelabs/advanced-android-kotlin-training-testing-basics#8
Check out the best practice to test the viewmodel live data
Create LiveDataExtension.kt file in the test folder
@VisibleForTesting(otherwise = VisibleForTesting.NONE)
fun <T> LiveData<T>.getOrAwaitValue(
time: Long = 2,
timeUnit: TimeUnit = TimeUnit.SECONDS,
afterObserve: () -> Unit = {}
): 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)
try {
afterObserve.invoke()
// Don't wait indefinitely if the LiveData is not set.
if (!latch.await(time, timeUnit)) {
throw TimeoutException("LiveData value was never set.")
}
} finally {
this.removeObserver(observer)
}
@Suppress("UNCHECKED_CAST")
return data as T
}
Let’s add failure test cases where the button is not enabled. In these cases either the email is not valid or the password is not valid. isLoginEnabled live data value will be false and all the test cases will be passed.
@Test
fun loginWithEmptyEmail() {
setData("", "")
Assert.assertFalse(loginActivityViewModel.isLoginEnabled.getOrAwaitValue())
}
@Test
fun loginWithInvalidEmail() {
setData("user@mail", "")
Assert.assertFalse(loginActivityViewModel.isLoginEnabled.getOrAwaitValue())
}
@Test
fun loginWithEmptyPassword() {
setData("user@mail.com", "")
Assert.assertFalse(loginActivityViewModel.isLoginEnabled.getOrAwaitValue())
}
@Test
fun loginWithInvalidPassword() {
setData("user@mail.com", "pass")
Assert.assertFalse(loginActivityViewModel.isLoginEnabled.getOrAwaitValue())
}
That’s it we have added test cases for the view model. Now let’s try to run the test cases.
When you try to run these test cases you might get the following error.
That’s because viewmodel is an architecture component which runs separately from the main thread. We need to add a few rules to run the viewmodel test cases in our JVM.
Add the gradle dependency to get the test rules.
dependencies {
...
testImplementation "androidx.arch.core:core-testing:2.1.0"
}
Then add the below rule at the start of the class.
class LoginActivityViewModelTest {
@get:Rule
val instantTaskExecutorRule = InstantTaskExecutorRule() ... }
InstantTaskExecutorRule is a JUnit Test Rule that swaps the background executor used by the Architecture Components with a different one which executes each task synchronously.
You can use this rule for your host side tests that use Architecture Components.
The test cases will be passed now.
Next we need to check the login function. Once the login button is enabled we can make the login call and check whether the user is logged in successfully or not.
@Test
fun loginWithUnregisteredUser() {
setData("unregistereduser@mail.com", "password")
Assert.assertTrue(loginActivityViewModel.isLoginEnabled.getOrAwaitValue())
loginActivityViewModel.login()
val result = loginActivityViewModel.loginSuccessMLD.getOrAwaitValue()
Assert.assertFalse(result)
Assert.assertEquals("user not found", loginActivityViewModel.errorMLD.value)
}
@Test
fun loginWithValidUser() {
setData("fakeuser@mail.com", "password")
Assert.assertTrue(loginActivityViewModel.isLoginEnabled.getOrAwaitValue())
loginActivityViewModel.login()
val result = loginActivityViewModel.loginSuccessMLD.getOrAwaitValue()
Assert.assertTrue(result)
}
Steps done in the test cases
- Set the email and password value.
- Check login button is enabled
- Then call the login() function.
- Check the result.
And that’s it, viewmodel testing is done.
Activity testing :
Writing unit tests for activity / fragment is not possible as it requires android framework components to run which needs a simulator or a real device.
We need to write instrumentation testing for activity/fragments or any other components which require an android framework.
To resolve this requirement Robolectric comes in, It provides a way to run the test cases for activity/fragment in our local JVM.
Now let’s write test cases for activity.
Scenario :
- Entering inputs
- Clicking login button
- Checking whether the user is navigated to the next screen once logged in.
To know about how an activity/fragment is initiated we need to know about the required rule.
Feel free to know more about the rules from the doc – https://developer.android.com/training/testing/junit-rules
androidTestImplementation “androidx.arch.core:core-testing:2.1.0” |
Add the dependency to get the rules.
Create a new file in the androidTest package for LoginActivity.kt.
LoginActivityTest.kt
@RunWith(AndroidJUnit4::class)
class LoginActivityTest{
@get:Rule(order = 1)
val activityScenarioRule = ActivityScenarioRule(LoginActivity::class.java)
fun startUp() {
Intents.init()
}
fun cleanUp() {
Intents.release()
}
}
Add the above test rule scenario to initiate the activity.
Next step, create a test case function and using Espresso try to enter input in the input field.
To use the Espresso action in any test class i create EspressoActions class to avoid boilerplate code on entering inputs and validating.
Check out the doc – https://developer.android.com/training/testing/espresso to know more about how to access the views and do actions.
open class EspressoActions {
open fun enterInput(viewId: Int, input: String) {
Espresso.onView(ViewMatchers.withId(viewId))
.perform(ViewActions.typeText(input), ViewActions.closeSoftKeyboard())
}
open fun clickView(viewId: Int) {
Espresso.onView(ViewMatchers.withId(viewId))
.perform(ViewActions.click())
}
open fun matchToast(message: String) {
Espresso.onView(ViewMatchers.withText(message)).inRoot(ToastMatcher())
.check(ViewAssertions.matches(ViewMatchers.isDisplayed()))
}
open fun matchIntent(className: String) {
Intents.intended(IntentMatchers.hasComponent(className))
}
open fun isViewDisabled(viewId: Int) {
Espresso.onView(ViewMatchers.withId(viewId))
.check(ViewAssertions.matches(not(ViewMatchers.isEnabled())))
}
open fun isViewEnabled(viewId: Int) {
Espresso.onView(ViewMatchers.withId(viewId))
.check(ViewAssertions.matches(ViewMatchers.isEnabled()))
}
}
class LoginActivityActionBuilder : EspressoActions() {
fun withEmail(email: String) {
enterInput(R.id.email_et, email)
}
fun withPassword(password: String) {
enterInput(R.id.password_et, password)
}
fun clickLogin() {
clickView(R.id.login_btn)
}
fun isLoginButtonDisabled() {
isViewDisabled(R.id.login_btn)
}
fun isLoginButtonEnabled() {
isViewEnabled(R.id.login_btn)
}
}
This above class is a simple action builder which extends the EspressoActions. It will pass the view ID of that particular view to the EspressoAction functions.
Create an empty test function and try to run it. It should open the activity. Make sure that a real device or simulator is connected.
@Test
fun openActivity(){ }
You might get an error something related to HiltAndroidRule.
Since we used Hilt for DI, we need to make sure that hilt is configured correctly for the test.
Follow this doc to setup hilt for testing – https://developer.android.com/training/dependency-injection/hilt-testing
Add these dependencies in the app level build.gradle file
dependencies {
// For Robolectric tests.
testImplementation 'com.google.dagger:hilt-android-testing:2.35'
// ...with Kotlin.
kaptTest 'com.google.dagger:hilt-android-compiler:2.35'
// ...with Java.
testAnnotationProcessor 'com.google.dagger:hilt-android-compiler:2.35'
// For instrumented tests.
androidTestImplementation 'com.google.dagger:hilt-android-testing:2.35'
// ...with Kotlin.
kaptAndroidTest 'com.google.dagger:hilt-android-compiler:2.35'
// ...with Java.
androidTestAnnotationProcessor 'com.google.dagger:hilt-android-compiler:2.35'
}
Let’s create a BaseHiltTest.kt class to be extended by any activity/fragments to inject dependencies.
@HiltAndroidTest
open class BaseHiltTest {
@get:Rule(order = 0)
val hiltAndroidRule = HiltAndroidRule(this)
@Before
open fun startUp() {
hiltAndroidRule.inject()
}
@After
open fun cleanUp() {
}
}
Our final LoginActivityTest file will be like this when extended with BaseHiltTest class.
@HiltAndroidTest
class LoginActivityTest : BaseHiltTest() {
@get:Rule(order = 1)
val activityScenarioRule = ActivityScenarioRule(LoginActivity::class.java)
override fun startUp() {
super.startUp()
Intents.init()
}
override fun cleanUp() {
super.cleanUp()
Intents.release()
}
@Test
fun openActivity(){ }
}
Now try to run the test again. This time the test will pass.
Let’s write a few test cases with actual scenarios that the user might experience.
@Test
fun noEmailPassword_buttonDisabled() {
val loginActivityActionBuilder = LoginActivityActionBuilder()
loginActivityActionBuilder.isLoginButtonDisabled()
}
@Test
fun invalidEmailValidPassword_buttonDisabled() {
val loginActivityActionBuilder = LoginActivityActionBuilder()
loginActivityActionBuilder.withEmail("user@mail")
loginActivityActionBuilder.withPassword("password")
loginActivityActionBuilder.isLoginButtonDisabled()
}
@Test
fun validEmailNoPassword_buttonDisabled() {
val loginActivityActionBuilder = LoginActivityActionBuilder()
loginActivityActionBuilder.withEmail("unregistereduser@mail.com")
loginActivityActionBuilder.isLoginButtonDisabled()
}
@Test
fun validEmailInvalidPassword_buttonDisabled() {
val loginActivityActionBuilder = LoginActivityActionBuilder()
loginActivityActionBuilder.withEmail("user@mail.com")
loginActivityActionBuilder.withPassword("pass")
loginActivityActionBuilder.isLoginButtonDisabled()
}
These are the cases where the login button is still in disabled mode.
Let’s write cases where a user enters valid email and password and the login button is enabled.
@Test
fun validEmailPassword_buttonEnabled_navigatedToNextScreen() {
val loginActivityActionBuilder = LoginActivityActionBuilder()
loginActivityActionBuilder.withEmail("fakeuser@mail.com")
loginActivityActionBuilder.withPassword("password")
loginActivityActionBuilder.isLoginButtonEnabled()
loginActivityActionBuilder.clickLogin()
loginActivityActionBuilder.matchIntent(ProductActivity::class.java.name)
}
@Test
fun unregisteredEmailPassword_buttonEnabled_errorDisplayed() {
val loginActivityActionBuilder = LoginActivityActionBuilder()
loginActivityActionBuilder.withEmail("unregistereduser@mail.com")
loginActivityActionBuilder.withPassword("password")
loginActivityActionBuilder.isLoginButtonEnabled()
loginActivityActionBuilder.clickLogin()
loginActivityActionBuilder.matchToast("user not found")
}
In the 1st case the user enters valid registered login credentials, and on clicking the login button will navigate to the main screen.
In the 2nd case the user enters a valid email and password but the user is not registered. In this case we show an error message will be displayed in a toast message.
To validate the toast message , we need to get the toast view that appears on the screen. The below class will help us to get the toast view. This can be used with espresso as below to check whether the toast with a given message is displayed or not.
class ToastMatcher : TypeSafeMatcher<Root>() {
override fun describeTo(description: Description?) {
}
override fun matchesSafely(root: Root?): Boolean {
if (root != null) {
val type: Int = root.windowLayoutParams.get().type
if (type == WindowManager.LayoutParams.TYPE_TOAST) {
val windowToken: IBinder = root.decorView.windowToken
val appToken: IBinder = root.decorView.applicationWindowToken
if (windowToken === appToken) {
// windowToken == appToken means this window isn't contained by any other windows.
// if it was a window for an activity, it would have TYPE_BASE_APPLICATION.
return true
}
}
}
return false
}
}
That’s it we have successfully added activity testing.
Now run all the test cases and check the connected device. You can see all the test cases passed successfully.
Hope this blog helps you in understanding and writing unit / instrumented test cases.
Check out the other blogs Android best practice to know more about writing clean and testable code in the upcoming features.
Happy learning
Team Appmetry