Practicing TDD on Android with UI tests

0
224
TDD with UI Testing

Hello everyone, I hope you are doing well. This post is about practicing TDD (Test-Driven-Development) with UI (User Interface) tests. This post is not about Unit Testing, if you would like to learn that instead, do have a read of my previous blog post here. Before getting further I would like to tell a little about how this post came into existence. Although practicing TDD with UI tests is widely debated, I found it particularly true that some form of tests is better than no tests at all and Donn Felker who I consider one of the greatest in the field of Android Application Development has written this blog post where he discusses practicing TDD with your UI Layer.

This post is an extension on his post and I am going to briefly discuss how I found it better to have UI tests before writing my UI and validation logic. I assume that you have basic idea of creating UIs and writing basic Kotlin code in your Activities and stuff. Having said that, lets get into it

Lets say, you have a form, maybe a Login form, SignUp form or other form that has several input fields and you want to validate the input from the user before actually creating your models, saving them into a local database or sending them into a remote server.

Lets go ahead and create a LoginActivity:

import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle

class Login1Activity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_login)
    }
}

Its, XML layout is as follows:

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
        xmlns:app="http://schemas.android.com/apk/res-auto"
        android:orientation="vertical"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:background="@color/colorAccent">
</androidx.constraintlayout.widget.ConstraintLayout>

Now what we would normally do is we started writing onClickListener for our btn_login or we would start writing validation logic for our input fields, but that is not very TDD.

Instead what I want you to do is write UI tests for your Login Screen. Now UI tests are tests that test the interaction with User. We dont have to put much thought here since how a User interacts with the UI of our app is really upto us. Lets now start writing UI tests. For writing UI tests you need to have following dependencies in your app/build.gradle file:

    testImplementation 'junit:junit:4.12'
    androidTestImplementation 'androidx.test:runner:1.2.0'
    androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0'
    androidTestImplementation 'androidx.test.ext:junit:1.1.1'
    androidTestImplementation 'androidx.test:rules:1.2.0'
    androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0'

The first three of these dependencies maybe already there in your app/build.gradle file. Now to create a Test for your LoginActivity, you can press Ctrl+Shift+T while the cursor is right next to LoginActivity.
Or you can right click androidTest/<your_package_name> folder and create a new Kotlin class there. Name it LoginActivityTest.

class LoginActivityTest{
}

Now Lets start writing test for our LoginActivity. First off, lets define some valid and invalid data for our input fields. We can do that using companion object which is basically Kotlin static final data members which is initialized as soon as the Class is initialized. Put the following inside the test class:

companion object{

        const val INVALID_EMAIL = "test"
        const val VALID_EMAIL = "[email protected]"
        const val INVALID_PASSWORD = "test"
        const val VALID_PASSWORD = "[email protected]"
    }

    @Rule
    @JvmField
    var mActivityTestRule = ActivityTestRule(Login1Activity::class.java)

The @Rule annotation comes from JUnit which lets the compiler know that we need this rule to execute our tests. Since we are testing our Activity, the mActivityTestRuleis the test rule for our LoginActivity.

Now let us write our first test method which is going to be trivial but important nonetheless. Put the following inside your LoginActivityTestclass:

 /*
    Check if the views are displayed
     */
    @Test
    fun checkViewsDisplayed(){
        onView(withId(R.id.et_email)).check(matches(isDisplayed()))
        onView(withId(R.id.et_password)).check(matches(isDisplayed()))
        onView(withId(R.id.tv_forgot_password)).check(matches(isDisplayed()))
    }

As you see we have written some tests with Espresso which is fairly simple, we have ViewMatcher to match views with Id and ViewAssertions to check if they match with certain constraints, in our case, isDisplayed(). The full test class file at this point is:

import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.rule.ActivityTestRule
import org.junit.Rule
import org.junit.Test

@LargeTest
@RunWith(AndroidJUnit4::class)
class Login1ActivityTest{
    companion object{

        const val INVALID_EMAIL = "test"
        const val VALID_EMAIL = "[email protected]"
        const val INVALID_PASSWORD = "test"
        const val VALID_PASSWORD = "[email protected]"
    }

    @Rule
    @JvmField
    var mActivityTestRule = ActivityTestRule(Login1Activity::class.java)

    @Test
    fun checkViewsDisplayed(){
        onView(withId(R.id.et_email)).check(matches(isDisplayed()))
        onView(withId(R.id.et_password)).check(matches(isDisplayed()))
        onView(withId(R.id.tv_forgot_password)).check(matches(isDisplayed()))
    }

}

Go ahead and run this test, by clicking on the green play button on gutter (left side of your editor). You will see that this test fails, as we do not have the views with those ids in our xml file. This is the first step of TDD. We wrote tests, which fail.
Now lets make them pass by writing our UI xml.

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
        xmlns:app="http://schemas.android.com/apk/res-auto"
        android:orientation="vertical"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:background="@color/colorAccent">

    <LinearLayout
            android:id="@+id/linearLayout2"
            android:padding="18dp"
            android:background="#ffffff"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintTop_toBottomOf="parent"
            app:layout_constraintBottom_toTopOf="parent"
            android:orientation="vertical"
            android:layout_width="match_parent"
            android:layout_height="wrap_content">

        <EditText
                android:id="@+id/et_email"
                android:hint="[email protected]"
                android:inputType="textEmailAddress"
                android:drawablePadding="16dp"
                android:drawableLeft="@drawable/ic_email"
                android:background="@drawable/edit_text_round_gray_background"
                android:layout_width="match_parent"
                android:padding="12dp"
                android:layout_marginBottom="12dp"
                android:layout_height="match_parent"/>

        <EditText
                android:id="@+id/et_password"
                android:inputType="textPassword"
                android:hint="password"
                android:drawablePadding="16dp"
                android:drawableLeft="@drawable/ic_lock"
                android:background="@drawable/edit_text_round_gray_background"
                android:layout_width="match_parent"
                android:layout_marginBottom="12dp"
                android:padding="12dp"
                android:layout_height="match_parent"/>

        <TextView
                android:id="@+id/tv_forgot_password"
                android:textColor="@color/colorPrimaryDark"
                android:layout_marginBottom="12dp"
                android:textAppearance="@style/TextAppearance.AppCompat.Medium"
                android:text="Forget Password?"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"/>

        <Button
                android:id="@+id/btn_sign_in"
                android:layout_gravity="center_horizontal"
                android:textAllCaps="false"
                android:text="Sign In"
                android:layout_width="150dp"
                android:layout_height="wrap_content"/>

    </LinearLayout>

</androidx.constraintlayout.widget.ConstraintLayout>

Run the tests again and see them pass. The tests pass because we have views with ids which we are checking for. Now you may think this is not quite useful. Hang on, something interesting is coming your way.


As we see from our layout xml, we have two input fields: et_email and et_password. What we desire for this screen is that, whenever the Sign In Button btn_sign_in is clicked, we want the email and password typed by the user to be validated before sending data to rest webservice or saving them to a database. We will not be doing that in this post. Lets now write tests to see if clicking the sign in button initiates validation of the input email and password. Lets first write the test as TDD says Tests before Feature:

    @Test
    fun clickSignInButton_emptyEmailAndPassword_displayErrorOnEmail(){
        onView(withId(R.id.btn_sign_in)).perform(click())
        onView(withId(R.id.et_email)).check(matches(hasErrorText("Invlaid Email")))
    }

In this test method there is a new function peform which is a part of ViewActions from Espresso. You can view a full list of ViewActions, ViewMatchers and ViewAssertions in the image below:

Go ahead and run this test, it will fail. Now lets make the test pass. Go to your LoginActivity and write the following inside onCreate()

  btn_sign_in.setOnClickListener { 
            if (et_email.text.isNullOrEmpty()){
                et_email.error = "Invalid Email"
            }
        }

To use btn_sign_in directly from your xml layout you will have to add dependency for kotlin synthetic which you can do by adding the following line at the top of your app/build.gradle

apply plugin: 'kotlin-android-extensions'

Now this is your feature code. Go ahead and run your LoginActivityTest to see your test passing. Coding this way is quite good since you are thinking about what tests the app needs to pass before actually writing features. You can go ahead and cover tests for other cases where the user may type an invalid email address in the email field or the case where they may type valid email address and empty password and click sign in button. But you get the idea. I will write one more test method here and call it a day.

    @Test
    fun clickSignInButton_invalidEmailAndValidPassword_displayErrorOnEmail(){
        onView(withId(R.id.et_email)).perform(typeText(INVALID_EMAIL))
        onView(withId(R.id.et_password)).perform(typeText(VALID_PASSWORD))
        onView(withId(R.id.btn_sign_in)).perform(click())
        onView(withId(R.id.et_email)).check(matches(hasErrorText("Email Invalid")))
    }

To make this pass, we write the following in our LoginActivity within the setOnclickListener:

            if (et_email.text.isNullOrEmpty()){
                et_email.error = "Invalid Email"
            }

            if(!Patterns.EMAIL_ADDRESS.matcher(et_email.text).matches()){
                et_email.error = "Invalid Email"
            }
            
            if (et_password.text.isNullOrEmpty()){
                et_password.error = "Invalid Password"
            }

Run the test again and see it pass. I hope this was useful for you. if you have any questions on the topic feel free to comment of contact me. For additional reading, I want to recommend Official Android Guide on Espresso UI Testing.

LEAVE A REPLY

Please enter your comment!
Please enter your name here