Chapters

Hide chapters

Android Test-Driven Development by Tutorials

Second Edition · Android 11 · Kotlin 1.5 · Android Studio 4.2.1

Section II: Testing on a New Project

Section 2: 8 chapters
Show chapters Hide chapters

Section III: TDD on Legacy Projects

Section 3: 8 chapters
Show chapters Hide chapters

11. User Interface
Written by Tori Gonda

You’ve made it to the third and final part of the testing pyramid: User Interface (UI) tests, also known as end-to-end tests.

Almost all Android apps have a UI, and subsequently, an essential layer for testing. UI testing generally verifies two things:

  1. That the user sees what you expect them to see.
  2. That the correct events happen when the user interacts with the screen.

With UI tests, you can automate some of the testing you might otherwise need to do with tedious, manual click-testing. A step up from integration tests, these test your app most holistically.

Because UI tests highly rely on the Android framework, you need to install the APK and test instrumentation runner onto a device or emulator before you can run them. Once installed, you can run the tests that use the screen to display and perform actions that verify the behavior. Because of the work involved, UI tests are the slowest and most expensive to run, which means you’re less likely to run them, losing the benefit of quick feedback.

Note: With AndroidX Test, it’s possible to run these tests without a device or emulator and instead run them with Robolectric. This chapter will not elaborate on the specifics as the technique is the same as described in Chapter 8, “Integration.”

Following the TDD process requires running your tests frequently while building, so you won’t want to lean too heavily on UI tests. The length of time it takes to run them will increase the time it takes to write them. Test the logic you need to test with UI tests and push what you can into integration or unit tests. A good rule of thumb is the 10/20/70 split mentioned in Chapter 4, “The Testing Pyramid,” which explains that 10% of your tests should be UI tests. The idea is that you test for the main flows, putting whatever logic you can into classes that you can verify using a faster test.

Introducing Espresso

The main library used for testing the UI on Android is Espresso. Manually click-testing all parts of your app is slow and tedious. With Espresso, you can launch a screen, perform view interactions and verify what is or is not in view. Because this is common practice, Android Studio automatically includes the library for you when generating a new project.

Note: Google’s motivation behind this library is for you “to write concise, beautiful and reliable Android UI tests.”

Getting started

In this chapter, you’ll continue working on the Punchline Joke app that you worked on in Chapter 10, “Testing the Network Layer.” This is an app that shows you a new, random joke each time you press a button.

Open the project where you left off in Android Studio, or find the starter project in the materials for this chapter and open that.

Build and run the app. There’s not much to see yet because it’s your job to add the UI in this chapter.

By the end of the chapter, your app will look like this:

Getting familiar with the project

In this chapter, you’ll write tests and implementation for MainActivity. Find the following files, so you’re all set to go:

  • activity_main.xml: This is the layout file. At the moment, it’s sparse, but it won’t be when you’re done with it.
  • MainActivity.kt: This file is where you set up the view. Notice in onCreate() that it’s subscribing to LiveData, and then handling the results in render(). It’s using UiModel to hold the data that you need to display.

Using Espresso

As is the case when generating a new project in Android Studio, the dependency for Espresso is already included for you.

Open app ‣ build.gradle, and you’ll see the following testing dependency alongside the other testing dependencies:

androidTestImplementation 'androidx.test.espresso:espresso-core:3.3.0'

This is the primary library that you’ll be using in this chapter. You’ll be using this alongside some of the libraries and techniques you used from previous chapters.

You’re all set to go now, so it’s time to dive in!

What makes up Espresso?

There are three main classes you need to know when working with Espresso: ViewMatchers, ViewActions and ViewAssertions:

  • ViewMatchers: Contain methods that Espresso uses to find the view on your screen with which it needs to interact.
  • ViewActions: Contain methods that tell Espresso how to automate your UI. For example, it contains methods like click() that you can use to tell Espresso to click on a button.
  • ViewAssertions: Contain methods used to check if a view matches a specific set of conditions.

Setting up the test class

To get started, inside app ‣ src ‣ androidTest ‣ java ‣ com ‣ raywenderlich ‣ android ‣ punchline, create a file named MainActivityTest.kt. Add to it, an empty test class using androidx.test.ext.junit.runners.AndroidJUnit4 and org.koin.test.KoinTest for imports:

@RunWith(AndroidJUnit4::class)
class MainActivityTest: KoinTest {
}

You should be familiar with AndroidJUnit4 from previous chapters. You‘re extending from KoinTest because this project uses the Koin dependency injection framework. You don’t need to know much about Koin to see the power of using dependency injection to help with your UI tests.

Using dependency injection to set mocks

In previous chapters, you used Mockito mocks to stub out some functionality. For example, when you didn’t want to hit the network layer. In many of these cases, you can introduce these mocked classes by passing them through the constructor. But how would you do that for an Activity? You don’t have the same luxury, because the Android framework instantiates the class for you. This is why dependency injection is helpful when it comes to testing.

In this test, you’ll mock the repository so that you’re not hitting the network layer. This helps with speed and stability.

In KoinModules.kt, a Retrofit service is defined which you need to override. This Koin test rule will allow you to do this using Mockito. Add it to the top of MainActivityTest.kt:

@get:Rule
val mockProvider = MockProviderRule.create { clazz ->
  Mockito.mock(clazz.java)
}

By using declareMock(), you’re overriding the provided dependency injection Repository with a Mockito mock.

You’ll need a reference to this repository so that you can stub methods onto it later. Luckily, Koin will deliver it to you — all you need to do is ask. Add this property to your class, importing org.koin.test.inject:

private val mockRepository: Repository by inject()

By delegating the property instantiation to inject(), this sets mockRepository to the mock that Koin passes to the ViewModel used in the Activity.

One last thing to set up before writing tests. You’ll use the Faker library you learned in Chapter 10, “Testing the Network Layer” to generate random test data.

Add the property code to prepare this:

private var faker = Faker()

Great! Now you’re all set up for writing tests.

Writing a UI test

This Joke app has a button that makes a new joke appear, so the first test you’ll add checks if this button is visible. Following the usual pattern, this test will have set up, actions and verifications.

Start with the setup. Create a new test function with the following stub:

@Test
fun onLaunchButtonIsDisplayed() {
  declareMock<Repository> {
    whenever(getJoke())
        .thenReturn(Single.just(Joke(
            faker.idNumber().valid(),
            faker.lorem().sentence())))
  }
}

Here, you’re stubbing out the repository so that you don’t hit the network layer. It’s building on the skills you learned in the previous chapters using Mockito to stub a function that returns an RxJava Single. You’re also using Faker to generate random test data for you. Have fun with the different values you can generate. Just make sure the type is correct for creating your Joke.

Next, you need to open the activity and perform your verification. Add these lines to the bottom of your new test. Use the suggested androidx.test.espresso.* imports, and know that buttonNewJoke will be unresolved in the beginning:

// 1
ActivityScenario.launch(MainActivity::class.java)
// 2
onView(withId(R.id.buttonNewJoke))
    .check(matches(isDisplayed()))

There are a few new things here, so here’s how it works:

  1. You use ActivityScenario to launch MainActivity. This comes from the AndroidX Test library imported as androidx.test.ext:junit:1.1.2 in the app ‣ build.gradle file. Here, you’re only using it for one thing, but you can use ActivityScenario for several things, including driving the Activity’s lifecycle state.

  2. This is where you use the Espresso library. You’re passing the ViewMatcher, withId(), to onView to find the view with the ID buttonNewJoke. You haven’t created it yet, so there’s an error here. Then, you’re using the ViewAssertion matches() to assert that this view is also matched by the ViewMatcher isDisplayed().

You can almost run this test, but you need to write enough code to make it compile. Add the button to activity_main.xml, inside the ConstraintLayout tag:

<Button
    android:id="@+id/buttonNewJoke"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:visibility="gone"
    />

Android Studio might complain that you haven’t added constraints yet, but now’s not the time to make it pretty. You’re adding just enough code to make it compile and run. Notice that you did include android:visibility="gone". This is so you can see the test fail first, which lets you know that your test is working.

Before you run your test, turn off animations on your testing device. Whether it’s an emulator or physical device you’re working with, go to Settings ‣ Developer options and set all of the following to off:

  • Window animation scale.
  • Transition animation scale.
  • Animator duration scale.

While you’re there, make sure you have “Don’t keep activities” disabled; otherwise, the ActivityScenario cannot run.

Build and run the test. Android Studio might prompt you to pick a device on which to run the test, so take your pick. After you run it, you’ll see a long error that includes something like this:

Expected: is displayed on the screen to the user
Got: "AppCompatButton{id=2131165226, res-name=buttonNewJoke...

This indicates the test found a button with the ID buttonNewJoke, but it’s not being displayed. It’s an easy fix to make it pass. Remove the visibility attribute from the XML:

android:visibility="gone"

Run the test again, and it passes. You can now move on to making sure the joke shows up.

Note: If you’re snooping through all of the Android Studio options, you may notice there’s a Record Espresso Test option.

Yes, you can use this to automatically create Espresso tests by clicking through your app and entering details into a wizard. However, the result is brittle, hard-to-read tests. While it can be useful for setting up the starting boilerplate or learning how to match something you’re unsure of, it’s best to avoid using it.

Testing for text

When the app is first launched, you expect to see the first joke. In this test, you’ll make sure there’s a view that displays that joke right away.

Add this test to your test class:

@Test
fun onLaunchJokeIsDisplayed() {
  // 1
  val joke = Joke(
      faker.idNumber().valid(),
      faker.lorem().sentence())
  declareMock<Repository> {
    whenever(getJoke())
        .thenReturn(Single.just(joke))
  }
  // 2
  ActivityScenario.launch(MainActivity::class.java)
  onView(withId(R.id.textJoke))
      .check(matches(withText(joke.joke)))
}

This test is similar to the first test with some small but significant differences:

  1. You’re keeping a reference to the Joke you’re creating to stub the repository. You want to know what the joke was later, to make sure it’s on the screen.
  2. This verification uses many of the same elements you saw before, but this time you’re using withText() instead of isDisplayed() to match the text of the joke. withText() accepts both a String literal and a String reference ID.

Deciding which Matcher to use

Did you notice how many autocomplete options appeared after you typed “with” of withText() or withId()? With so many options, how do you know which to choose?

First, you must watch for accuracy and brittleness. Then, make sure what you’re matching can only match the one view you want to test.

This could tempt you to be very specific, but you also want to make sure your tests aren’t breaking with any little change to the UI. For example, what if you’re matching with the String literal "Okay" and it’s later changed to "OK" and then changed again to "Got it"? You’d have to update the test with each change.

This is why matching using IDs is common. Once you have an ID set, it’s likely the only view on-screen with that ID and unlikely to frequently change — unless it’s a collection.

Here’s Google’s handy Espresso Cheat Sheet for possible Matchers, Actions and Assertions: https://developer.android.com/training/testing/espresso/cheat-sheet

You’re almost ready to run this test. There’s no view with the ID textJoke yet, so add that to the XML immediately below your button:

<TextView
    android:id="@+id/textJoke"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    />

Once again, you have the bare minimum to make it compile and run. Everything will be scrunched into the corner, but don’t worry, you’ll fix that soon.

Run the test, and you’ll see an error similar to before:

Expected: with text: is "Dolores quia consequatur quos."
Got: "AppCompatTextView{id=2131165357, res-name=textJoke, text=,

It found the right view, but there’s no text. Because it’s failing, you know you’re following the right TDD steps.

Open MainActivity.kt and find showJoke(). render() already calls this for you when a Joke is loaded, so you don’t need to worry about the logic, only the UI (which makes sense with this being a chapter about UI testing).

With everything set up, all you need to do is connect the data to the view. Add this line to showJoke() using the suggested synthetic import:

binding.textJoke.text = joke.joke

Run your test to make sure it passes.

Refactoring

Run the app to see how it’s looking so far. It may not be pretty, but it sure is testable!

Now that you have some UI tests in place, you can take a break from test writing and do some refactoring in activity_main.xml.

Add these attributes to the TextView:

style="@style/TextAppearance.MaterialComponents.Headline6"
android:gravity="center_horizontal"
android:padding="16dp"
app:layout_constraintBottom_toTopOf="@+id/buttonNewJoke"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_chainStyle="packed"

Add these to the Button:

android:text="@string/new_joke"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/textJoke"

This adds a little style, along with some descriptive text and constraints. Run it again to see the changes.

Run your tests to make sure everything still works.

Note: Feel free to modify the view to make it look the way you want. Just make sure you don’t break your tests while you do it.

Regression testing

It’s relatively easy to keep things from breaking when you’re working with a simple UI like this. But these tests are extremely helpful when you’re working with a complicated UI with nested, reused views. Because you want to limit your UI tests, you may fall into a pattern of introducing regression tests.

Regression tests are tests that you add when something goes wrong. You can start by adding a couple of tests for the happy path in the UI layer. If you find something that’s broken, write a test for it to make sure it doesn’t break, or regress, again. You can use this at any layer of the testing pyramid, and it might look like this when paired with TDD:

  1. Something broke, a bug is reported, or there’s a crash.
  2. You write a test for the expected behavior that broke. Likely, there’s not already a test for this; otherwise, it would have caught the issue ahead of time.
  3. Fix the issue. Do what you need to do to fix the bug, make the test pass, and keep all other tests green.
  4. You now have a regression test to make sure the same issue doesn’t come up again. No zombie bugs coming back to life here.

These types of tests are especially helpful when working with legacy systems that aren’t well tested. It’s a way to start introducing valuable tests alongside your fixes. While you’re in there writing the regression test, you might take a few minutes to add other tests for vulnerable nearby areas.

Performing an action

There’s one more behavior to test and implement for this app: When a user taps the button, a new joke should appear. This is the final and most complex test you’ll write for this chapter, so you’ll write it in two steps.

Add this test to your test class:

@Test
fun onButtonClickNewJokeIsDisplayed() {
  // 1
  val joke = Joke(
      faker.idNumber().valid(),
      faker.lorem().sentence())
  // 2
  val jokeQueueAnswer = object: Answer<Single<Joke>> {
    val jokes = listOf(
        Joke(
            faker.idNumber().valid(),
            faker.lorem().sentence()),
        joke
    )
    var currentJoke = -1
    override fun answer(invocation: InvocationOnMock?): Single<Joke> {
      currentJoke++
      return Single.just(jokes[currentJoke])
    }
  }
  // 3
  declareMock<Repository> {
    whenever(getJoke())
        .thenAnswer(jokeQueueAnswer)
  }
}

This is a longer one, so take it step-by-step:

  1. Create the joke you want to see after the button is pressed.
  2. Create a Mockito Answer. This allows you to create a more complex response. In this case, you’re queuing jokes for each request because you want a new joke when the button is pressed.
  3. Use that Answer to mock out getJoke()

Great! Now you can finish up your test. Add this to the bottom:

ActivityScenario.launch(MainActivity::class.java)
// 1
onView(withId(R.id.buttonNewJoke))
    .perform(click())
// 2
onView(withId(R.id.textJoke))
    .check(matches(withText(joke.joke)))
  1. This is where you use a ViewAction for the first time. You locate the view using a ViewMatcher, then pass in the ViewAction click() to perform() to perform that action.
  2. Finally, verify the new joke is shown in the TextView.

Phew! That was a lot. Everything is already compiling, so go ahead and run your test. You’ll see a familiar error:

Expected: with text: is "Error ut sed doloremque qui."
Got: "AppCompatTextView{id=2131165357, res-name=textJoke, ...
     text=Laudantium et quod dolor.,

It looks like the new joke never showed up! That’s because nothing is happening when you click the button. Don’t worry, you can fix that.

In MainActivity, add this click listener to the bottom of onCreate():

binding.buttonNewJoke.setOnClickListener {
  viewModel.getJoke()
}

Again, all of the logic is already there for you to fetch the new joke. Call showJoke() when it’s finished. Run your test to see it pass.

You made it! You finished Punchline with fully functioning UI tests. Run your app and play around with it.

You can now refactor the UI and make it as visually appealing as you’d like. Just remember to run the tests periodically. Oh yeah, and do your best to remember the jokes — you may need them for your next party! :]

Using sharedTest (optional)

In Chapter 8, “Integration,” you learned that you could run Android tests on either a device or locally using Robolectric. For this to work, your test must be in the correct test/ or androidTest/ directory. With a small configuration change and a new sharedTest/ directory, you’ll be able to run your tests both ways without needing to move the file.

Note: If you drag and drop a test from test/ or androidTest/ into sharedTest/, Android Studio might have some problems running it because of caching issues.

The first step to setting up shared tests is modifying app ‣ build.gradle so that it pulls the shared tests into the test/ and androidTest/ source sets. Add this to the android block of app ‣ build.gradle:

sourceSets {
  String sharedTestDir = 'src/sharedTest/java'
  test {
    java.srcDir sharedTestDir
  }
  androidTest {
    java.srcDir sharedTestDir
  }
}

You then need to make sure that any libraries you use in your tests are in your dependencies list with both androidTestImplementation and testImplementation. To save you some work, this is done for you. You’ll see the duplicates if you open app ‣ build.gradle. Just remember to do a Gradle Sync while you’re there.

The only thing left is learning how to run your shared tests. By default, you don’t have the local vs. device control you want. You can only run the shared tests as Android tests. There are two ways you can get this control:

  • Running the tests from the command line.
  • Creating a run configuration.

Add this small test to sharedTest/ in a newly created file named JokeTest.kt. This way you’ll have something to run:

class JokeTest {

  private val faker = Faker()

  @Test
  fun jokeReturnsJoke() {
    val title = faker.book().title()
    val joke = Joke(faker.code().isbn10(), title)

    assert(title == joke.joke)
  }
}

To be honest, that was a bit of a joke! :]

Running tests from the command line

Using gradle, running your tests from the command line is easy. Open a terminal and navigate to the root directory of your project, or use the terminal view in Android Studio.

Run the following command:

./gradlew test

This runs all of the tests you have in test/ and sharedTest/. You can use this even if you don’t have shared tests as it will run the tests you have in test/ only.

Now, try running this one:

./gradlew connectedAndroidTest

Likewise, this one will run the tests you have in androidTest/ and sharedTest/. This also works if you don’t have any shared tests.

Creating a run configuration

Android Studio also supports creating run configurations, which are presets you create and run that inform Android Studio of how you want things to run.

To start, select Edit Configurations… from the run drop-down.

Then, select ”+” to create a new run configuration, selecting Android JUnit for the type.

You now have a few things to fill out in the editor:

  • Name: Pick a name that makes sense to you. Something like Robolectric Shared Tests works well.
  • Test kind: Select “All in directory” to run all of your tests. You can change this to be more specific if you want even more control over which tests run.
  • Directory: Select the path to src/ if you want to run both test/ and sharedTest/, or sharedTest/ if you only want to run the ones in that directory.
  • Use classpath of module: Select app here.
  • Click OK.

As demonstrated below:

This option is now available in the drop-down if you want to run all of your shared tests with Robolectric from Android Studio.

Key points

  • UI tests allow you to test your app end-to-end without having to manually click-test your app.
  • Using the Espresso library, you’re able to write UI tests.
  • You can run Android tests on a device and locally using Roboelectric.

Where to go from here?

Now that you know the basics of UI testing with Espresso, you can explore and use everything else the library has to offer.

You can find out about Espresso’s helper libraries and more by reading their documentation: https://developer.android.com/training/testing/espresso/

If you want more practice, you can look at Espresso Testing and Screen Robots: Getting Started: https://www.raywenderlich.com/949489-espresso-testing-and-screen-robots-getting-started

To take your UI tests to the next level, you can learn how to use Kakao for even more elegant UI tests by reading UI Testing with Kakao Tutorial for Android: Getting Started: https://www.raywenderlich.com/1505688-ui-testing-with-kakao-tutorial-for-android-getting-started

This chapter uses Espresso and ActivityScenario, which are both a part of AndroidX Test. To learn more about this suite of testing libraries, you can watch Getting Started with AndroidX Test: https://vimeo.com/334519652

Finally, if you’ve adopted Jetpack Compose in your app, you can apply TDD there, too! Learn how to test Compose in this codelab, then practice applying your TDD skills. https://developer.android.com/codelabs/jetpack-compose-testing

This is the end of this section, and you’ve learned a lot about how to test new apps and features. But what if you’re working on an existing app? In the next section, you’ll learn some techniques for working with legacy code.

Have a technical question? Want to report a bug? You can ask questions and report bugs to the book authors in our official book forum here.
© 2026 Kodeco Inc.