Mastering development through testing in Android using UI tests

Hello. In anticipation of the launch of a new set on base and advanced Android development courses have prepared a translation of interesting material.


Over the last year of the Android development team at Buffer, we talked a lot about the cleanliness of our project and increasing its stability. One of the factors was the introduction of () tests, which, as we have already found out, help us avoid regressions in our code and give us great confidence in the features we provide. And now that we are launch new products in Buffer, we want to make sure that the same approach is applied when it comes to them – just so that we are not in the same situation as before.

Background

When writing unit tests for Buffer applications, we always adhered to development practices through testing (Test-Driven-Development – hereinafter referred to as TDD). There are many resources about what TDD is, about its advantages, and where they come from, but we will not dwell on this topic, since there is a lot of information on the Internet. At a high level, I personally note some of them:

  • Reduced development time
  • Simpler, cleaner, and more maintainable code
  • More reliable code with more confidence in our work.
  • Higher test coverage (this seems kind of obvious)

But until recently, we only followed the principles of TDD only in the form of unit tests for our implementations that were not based on the user interface …

I know, I know … We always had the habit of writing UI tests after what was implemented was completed – and that makes no sense. We monitored TDD for the backend so that the code we write met the requirements that the tests define, but when it comes to user interface tests, we write tests that satisfy the implementation of a specific feature. As you can see, this is a bit controversial, and in some ways it is about why TDD is used in the first place.

So, here I want to consider why this is so, and how we are experimenting with change. But why do we pay attention to this in the first place?

It was always difficult to work with existing activities in our application because of how they are written. This is not a complete excuse, but its many dependencies, responsibilities, and close ties make them extremely difficult to test. For the new activities that we added, out of habit, I myself always wrote UI tests after implementation – besides habit, there was no other reason for this. However, when creating our boilerplate codeReady for new projects, I thought about a change. And you will be happy to know that this habit has been broken, and now we are working on ourselves, exploring TDD for UI-tests

First steps

What we are going to explore here is a fairly simple example, so that the concept is easier to follow and understand – I hope this will be enough to see some of the advantages of this approach.

We are going to start by creating a basic activity. We need to do this so that we can run our UI test – imagine that this setup is the basis for our implementation, and not the implementation itself. Here’s what our underlying activity looks like:

class LoginActivity: AppCompatActivity(), LoginContract.View {

    @Inject lateinit var loginPresenter: LoginContract.Presenter

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

    override fun setPresenter(presenter: LoginContract.Presenter) {
        loginPresenter = presenter
    }

    override fun showServerErrorMessage() {
        TODO("not implemented")
    }

    override fun showGeneralErrorMessage() {
        TODO("not implemented")
    }

    override fun showProgress() {
        TODO("not implemented")
    }

    override fun hideProgress() {
        TODO("not implemented")
    }

    override fun showInvalidEmailMessage() {
        TODO("not implemented")
    }

    override fun hideInvalidEmailMessage() {
        TODO("not implemented")
    }

    override fun showInvalidPasswordMessage() {
        TODO("not implemented")
    }

    override fun hideInvalidPasswordMessage() {
        TODO("not implemented")
    }

}

You may notice that this activity does nothing but the initial setup, which is required for the activity. In the onCreate () method, we simply set the link to the layout, we also have a link to our View interface, which is implemented using activity, but they do not yet have implementations.

One of the most common things that we find in Espresso tests is reference views and strings by resource IDs found in our application. In this regard, we again need to provide a layout file for use in our activity. This is due to the fact that: a) our activity needs a layout file to display the layout during the tests, and b) we need an ID view for links in our tests. Let’s go ahead and make a very simple layout for our login activity:




    

    

    

Here you can see that we were not worried about any style or position, remember – while we create foundation, not implementation.

And for the last part of the setup, we are going to define the lines that will be used in this exercise. Again, we will need to reference them in tests – until you add them to your XML layout or activity class, just define them in the file strings.xml.

You may notice that in this setup we write as little as possible, but provide enough details about our activity and its layout to write tests for it. Our activity does not currently work, but it opens and has a view that can be referenced. Now that we have enough minimum to work, let’s continue and add some tests.

Adding Tests

So, we have three situations that we must implement, so we are going to write some tests for them.

  • When a user enters an invalid email address in the email input field, we need to display an error message. So, we are going to write a test that checks whether this error message is displayed.
  • When the user starts again to enter data in the email input field, the above error message should disappear – therefore we are going to write a test for this.
  • Finally, when the API returns an error message, it should be displayed in a warning dialog box – so we will also add a test for this.

@Test
fun invalidEmailErrorHidesWhenUserTypes() {
    activity.launchActivity(null)

    onView(withId(R.id.button_login))
            .perform(click())

    onView(withId(R.id.input_email))
            .perform(typeText("j"))

    onView(withText(R.string.error_message_invalid_email))
            .check(doesNotExist())
}

@Test
fun invalidPasswordErrorDisplayed() {
    activity.launchActivity(null)

    onView(withId(R.id.button_login))
            .perform(click())

    onView(withText(R.string.error_message_invalid_password))
            .check(matches(isDisplayed()))
}

@Test
fun serverErrorMessageDisplays() {
    val response = ConnectResponseFactory.makeConnectResponseForError()
    stubConnectRepositorySignIn(Single.just(response))
    activity.launchActivity(null)

    onView(withId(R.id.input_email))
            .perform(typeText("joe@example.com"))

    onView(withId(R.id.input_password))
            .perform(typeText(DataFactory.randomUuid()))

    onView(withId(R.id.button_login))
            .perform(click())

    onView(withText(response.message))
            .check(matches(isDisplayed()))
}

Ok, now we have written tests – let’s continue and run them.

And it is not surprising that they failed – this is because we do not yet have an implementation, so this should be expected. In any case, we should be glad to see red for tests right now!

So now we need to add implementations for our activity until the tests pass. As we write focused tests that test only one concept (or at least it should be!), We can add implementations one by one and also watch our tests turn green one by one.

So, let’s look at one of the failed tests, let’s start with the test invalidPasswordErrorDisplayed (). We know a few things:

  • To start the login process, the user enters his password, and then presses the login button, so we need to implement a listener for the login button, which calls our speaker login method:

private fun setupLoginButtonClickListener() {
    button_login.setOnClickListener {
        loginPresenter.performSignIn(input_email.text.toString(),
                input_password.text.toString()) }
}

  • When the user does not enter a password in the password field, we need to implement logic to display this error message. We use the TextInputLayout component, so we can simply assign the value of its error message to our error line, which we defined earlier:

override fun showInvalidPasswordMessage() {
    layout_input_password.error = getString(R.string.error_message_invalid_password)
}

Now we have added the logic for this situation, let’s continue and run our tests again!

Great, it looks like validation invalidPassworrdErrorDisplays() was successful. But we have not finished yet, we still have two tests that have not been passed for those parts of our login function that we must implement.

Next we will look at the test. serverErrorMessageDisplays(). It is quite simple, we know that when the API returns an error response (and not a general error from our network library), the application should display an error message to the user in a warning dialog box. To do this, we just need to create an instance of the dialog using our server error message in the dialog text:

override fun showServerErrorMessage(message: String) {
    DialogFactory.createSimpleInfoDialog(this, R.string.error_message_login_title, message, 
            R.string.error_message_login_ok).show()
}

Let’s continue and run our tests again:

Hurrah! We are moving forward, now we have only one test left, this is a test invalidEmailErrorHidesWhenUserTypes(). Again, this is a simple case, but let’s look at it:

  • When the user clicks the login button and the email address is missing or the email address is incorrect, we show the user an error message. We have already implemented this, I just ruled it out for simplicity
  • However, when the user begins to enter data into the field again, the error message should be removed from the display. To do this, we need to listen when the text content of the input field changes:

private fun setupOnEmailTextChangedListener() {
    input_email.addTextChangedListener(object : TextWatcher {

        override fun afterTextChanged(s: Editable) {}

        override fun beforeTextChanged(s: CharSequence, start: Int,
                                       count: Int, after: Int) {
        }

        override fun onTextChanged(s: CharSequence, start: Int,
                                   before: Int, count: Int) {
            loginPresenter.handleEmailTextChanged(s)
        }
    })
}

Now this should be enough to guarantee that our error message will be hidden when changing the contents of the input field. But we have tests to confirm our changes:

Excellent! Our implementation requirements are met as we pass our tests – it’s great to see the green light

Conclusion

It is important to note that the example to which we applied TDD is extremely primitive. Imagine that we are developing a complex screen, such as a content feed, in which you can perform several actions with feed elements (for example, as in the Buffer app for Android) – in these cases we will use many different functions that should be implemented in the given activity / fragment. These are situations when TDD will be revealed even more in UI tests, because what can lead to writing too complicated code for these functions can be reduced to implementations that satisfy the given tests that we wrote.

To summarize, I will share some points from my experience:

  • I sometimes noticed that people say that TDD slows down development. I did not feel that this was the case here for several reasons. To begin with, Espresso is written in a fluent language (presentation / text followed by the expected state), so writing these tests takes very little time. When it came to writing activity logic, I felt that my requirements were clearly laid out and my tests were there to test the behavior. This eliminates the need to write tests to satisfy the code and actually write tests based on implementation requirements.
  • In turn, this point means that in more complex implementations than in the example, we most likely will write less code than if we wrote tests after. This is because we write code to satisfy our tests, therefore, as soon as our tests are passed, this means that our implementations are good enough (provided that we write our tests correctly!). Because of this, it is important to write small and focused user interface tests. As soon as we begin to group several test cases into separate tests, then, probably, we will miss something.
  • I felt that writing Ui tests in the first place gave me an even better and clearer understanding of the requirements for what I implemented, which would not always be valid otherwise. This, in turn, is likely to lead to a shorter development process for implementation, in contrast to the opinions of other colleagues mentioned in the first paragraph.
  • The approach ensures that we write a complete set of tests. Tests will not be forgotten or not implemented, since our requirements are formulated by our tests – since we need to implement the fact that the tests are written in the first place, which makes it quite difficult to skip some of them, or neglect them for any reason.
  • It seems more natural. Because TDD is already used for unit tests, you feel a little backwards when you write unit tests, followed by implementations, followed by UI tests. You will feel more natural by going full step with TDD, rather than halfway.

Are you already using TDD when writing user interface tests and are doing something similar or completely different? Or do you want to know a little more and ask a few questions? Feel free to comment below or tweet us at @bufferdevs

That’s all. We are waiting for you at the courses:

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *