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

13. High-Level Testing With Espresso
Written by Lance Gleason

Heads up... You're reading this book for free, with parts of this chapter shown beyond this point as scrambled text.

For many — arguably most — legacy projects, it is easier (and often provides quicker wins) by starting with UI tests using Espresso.

There are a number of reasons for this, including:

  1. Most non-TDD apps have not been decomposed for testability.
  2. If you are new to the project and it is fairly large, it can take you a while to get your head around the architecture of the system as a whole.
  3. UI tests test the large parts of the app working together.

A holistic approach allows you to get functionality of the app under test before making code changes. This gives you a number of quick wins, including:

  1. The ability to get a section of the app under test before adding features or refactoring the code.
  2. Quickly getting the benefits of automated testing, even if you do not have time to start to make architectural changes.
  3. Providing you with test coverage for when you do start to refactor your code for testability at a lower level.

Getting started

To explore UI tests, you are going to work on an app called Coding Companion Finder.

The story

This app was created by a developer who practices pair programming, in which two developers work side by side on a problem at the same time. This technique has a number of benefits, including helping to improve the quality of the software you are working on. Unfortunately, this person often works from home where it may not always be possible to have a human partner to pair up with.

Setting up the app

This app uses an API from a website called Petfinder, which requires a developer key.

val apiKey = "replace with your API key"

val apiSecret = "replace with your API secret"

A tour of the app

The app will briefly present you with a splash screen; then, if you pasted in the correct key, it will bring up a page showing you a Featured Companion.

Your first assignment

Users have really liked the app, but it is difficult to find the contact information for a companion in the details screen. As your first task, the shelter has asked you to add contact information to the companion details screen.

Understanding the app architecture

Before adding tests and features, you need to understand how the app is put together. Open up the starter project and open the app level build.gradle. In addition to the normal Kotlin and Android dependencies, you have the following:

// Glide
implementation("com.github.bumptech.glide:glide:4.12.0") {
  exclude group: "com.android.support"
}
kapt 'com.github.bumptech.glide:compiler:4.12.0'

// carouselview library
implementation "com.synnapps:carouselview:0.1.5"

// retrofit
implementation "com.squareup.okhttp3:logging-interceptor:3.14.9"
implementation 'com.squareup.retrofit2:retrofit:2.9.0'
implementation 'com.squareup.retrofit2:converter-gson:2.9.0'
if (petFinderService == null) {
    val logger = HttpLoggingInterceptor()
    logger.level = HttpLoggingInterceptor.Level.BODY
    val client = OkHttpClient.Builder()
        .addInterceptor(logger)
        .connectTimeout(60L, TimeUnit.SECONDS)
        .readTimeout(60L, TimeUnit.SECONDS)
        .addInterceptor(AuthorizationInterceptor(this))
        .build()

    petFinderService = Retrofit.Builder()
        .baseUrl("http://api.petfinder.com/v2/")
        .addConverterFactory(GsonConverterFactory.create())
        .client(client)
        .build().create(PetFinderService::class.java)
}
val navHostController = Navigation.findNavController(this,
  R.id.mainPetfinderFragment)

val bottomNavigation =
  findViewById<BottomNavigationView>(R.id.bottomNavigation)

NavigationUI.setupWithNavController(bottomNavigation, navHostController)
<androidx.fragment.app.FragmentContainerView
    android:id="@+id/mainPetfinderFragment"
    android:name="androidx.navigation.fragment.NavHostFragment"
    android:layout_width="match_parent"
    android:layout_height="0dp"
    app:defaultNavHost="true"
    app:layout_constraintBottom_toTopOf="@id/bottomNavigation"
    app:layout_constraintEnd_toEndOf="parent"
    app:layout_constraintStart_toStartOf="parent"
    app:layout_constraintTop_toTopOf="parent"
    app:navGraph="@navigation/nav_graph"
    />

<com.google.android.material.bottomnavigation.BottomNavigationView
    android:id="@+id/bottomNavigation"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    app:layout_constraintBottom_toBottomOf="parent"
    app:layout_constraintEnd_toEndOf="parent"
    app:layout_constraintStart_toStartOf="parent"
    app:layout_constraintTop_toTopOf="parent"
    app:layout_constraintVertical_bias="1"
    app:menu="@menu/bottom_navigation_menu"/>
app:navGraph="@navigation/nav_graph"
<menu xmlns:android="http://schemas.android.com/apk/res/android"
  xmlns:app="http://schemas.android.com/apk/res-auto">

  <item
    android:id="@id/randomCompanionFragment"
    android:enabled="true"
    android:icon="@drawable/ic_featured_pet_black_24dp"
    android:title="@string/featured_pet"
    app:showAsAction="ifRoom" />

  <item
    android:id="@id/searchForCompanionFragment"
    android:enabled="true"
    android:icon="@drawable/ic_search_black_24dp"
    android:title="@string/find_pet"
    app:showAsAction="ifRoom" />
</menu>
<navigation 
  xmlns:android="http://schemas.android.com/apk/res/android"
  xmlns:app="http://schemas.android.com/apk/res-auto"
  xmlns:tools="http://schemas.android.com/tools"
  android:id="@+id/nav_graph"
  app:startDestination="@id/randomCompanionFragment">

  <fragment
    android:id="@+id/randomCompanionFragment"
    android:name="com.raywenderlich.codingcompanionfinder.randomcompanion.RandomCompanionFragment"
    android:label="fragment_random_pet"
    tools:layout="@layout/fragment_random_companion"/>
  <fragment
    android:id="@+id/searchForCompanionFragment"
    android:name="com.raywenderlich.codingcompanionfinder.searchforcompanion.SearchForCompanionFragment"
    android:label="fragment_search_for_pet"
    tools:layout="@layout/fragment_search_for_companion"/>
</navigation>

Determining your system boundaries

When you are adding automated testing to an app using Espresso, you want to have tests that are repeatable and fast. Using Espresso, you are performing a form of integration testing, but you still need to have some system boundaries for your tests. The boundary determines what you are testing and allows you to control the inputs to your app.

Preparing your app for testing

To get started, open your app level build.gradle file and add the following:

androidTestImplementation "androidx.test:rules:1.3.0"
androidTestImplementation "androidx.test.ext:junit:1.1.2"
androidTestImplementation "android.arch.navigation:navigation-testing:1.0.0-alpha08"
androidTestImplementation 'com.squareup.okhttp3:mockwebserver:3.12.0'
androidTestImplementation "androidx.test.espresso:espresso-contrib:3.3.0"

  lateinit var testScenario: ActivityScenario<MainActivity>

  companion object {

    private lateinit var startIntent: Intent

    // 1
    val server = MockWebServer()

    // 2
    val dispatcher: Dispatcher = object : Dispatcher() {

      @Throws(InterruptedException::class)
      override fun dispatch(
        request: RecordedRequest
      ): MockResponse {
        return CommonTestDataUtil.dispatch(request) ?:
          MockResponse().setResponseCode(404)
      }
    }

    @BeforeClass
    @JvmStatic
    fun setup() {
      // 3
      server.setDispatcher(dispatcher)
      server.start()

      // 4
      startIntent =
        Intent(ApplicationProvider.getApplicationContext(),
          MainActivity::class.java)
      startIntent.putExtra(MainActivity.PETFINDER_URI,
        server.url("").toString())
    }
  }
object CommonTestDataUtil {
  fun dispatch(request: RecordedRequest): MockResponse? {
    when (request.path) {
      else -> {
        return MockResponse()
          .setResponseCode(404)
          .setBody("{}")
      }
    }
  }
}

Adding test hooks

MockWebServer spins up a local web server that runs on a random port on an Android device. In order to use it, your app will need to point your Retrofit instance at this local server instead of the one at petfinder.com. Since your app sets up Retrofit in your MainActivity, you are going to add some logic to allow this to be passed in.

      petFinderService = Retrofit.Builder()
          .baseUrl("http://api.petfinder.com/v2/")
          .addConverterFactory(GsonConverterFactory.create())
          .client(client)
          .build().create(PetFinderService::class.java)
    }
      val baseUrl = intent.getStringExtra(PETFINDER_URI) ?:  
        "http://api.petfinder.com/v2/"

      petFinderService = Retrofit.Builder()
          .baseUrl(baseUrl)
          .addConverterFactory(GsonConverterFactory.create())
          .client(client)
          .build().create(PetFinderService::class.java)
    }

companion object {
  val PETFINDER_URI = "petfinder_uri"
  val PETFINDER_KEY = "petfinder_key"
}
intent.getStringExtra(PETFINDER_KEY)?.let{
  apiKey = it
}

Adding legacy tests

When adding tests to a legacy app with no test coverage, the first step is to add tests around the functionality where you are going to be adding a feature.

    <com.google.android.material.button.MaterialButton
      android:id="@+id/searchButton"
      android:layout_width="wrap_content"
      android:layout_height="wrap_content"
      android:text="Find"
      app:layout_constraintBottom_toBottomOf="@+id/searchField"
      app:layout_constraintEnd_toEndOf="parent"
      app:layout_constraintStart_toEndOf="@id/searchField"
      app:layout_constraintTop_toTopOf="@id/searchField" />
      <com.google.android.material.textfield.TextInputEditText
        android:id="@+id/searchFieldText"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:hint="Enter US Location"
        android:textColor="@color/primaryTextColor" />
@Test
fun pressing_the_find_bottom_menu_item_takes_the_user_to_the_find_page() {
  testScenario = ActivityScenario.launch(startIntent)
  onView(withId(R.id.searchForCompanionFragment))
    .perform(click())
  onView(withId(R.id.searchButton))
    .check(matches(isDisplayed()))
  onView(withId(R.id.searchFieldText))
    .check(matches(isDisplayed()))
  testScenario.close()
}

Understanding your API

Your SearchForCompanionFragment is making a call to your getAnimals function in your PetFinderService when you tap the Find button, which is implemented like this using Retrofit:

@GET("animals")
suspend fun getAnimals(
    @Header("Authorization")  accessToken: String,
    @Query("limit") limit: Int = 20,
    @Query("location") location: String? = null
) : Response<AnimalResult>

private fun setupClickEvent(animal: Animal){
  view.setOnClickListener {
    val viewCompanionFragment = ViewCompanionFragment()
    val bundle = Bundle()
    bundle.putSerializable(ViewCompanionFragment.ANIMAL, animal)
    viewCompanionFragment.arguments = bundle
    val transaction =
      fragment.childFragmentManager.beginTransaction()
    transaction.replace(R.id.viewCompanion,
      viewCompanionFragment).addToBackStack("companionView")
      .commit()
  }
}
animal = arguments?.getSerializable(ANIMAL) as Animal

Setting up your mock data

Now that you have the data from your API, you are going to need to tell your test Dispatcher how to retrieve it in order to mock out the API response. Open CommonTestDataUtil.kt and add in the following:

@Throws(IOException::class)
private fun readFile(jsonFileName: String): String {
  val inputStream = this::class.java
    .getResourceAsStream("/assets/$jsonFileName")
      ?: throw NullPointerException(
          "Have you added the local resource correctly?, "
              + "Hint: name it as: " + jsonFileName
      )
  val stringBuilder = StringBuilder()
  var inputStreamReader: InputStreamReader? = null
  try {
    inputStreamReader = InputStreamReader(inputStream)
    val bufferedReader = BufferedReader(inputStreamReader)
    var character: Int = bufferedReader.read()
    while (character != -1) {
      stringBuilder.append(character.toChar())
      character = bufferedReader.read()
    }
  } catch (exception: IOException) {
    exception.printStackTrace()
  } finally {
    inputStream.close()
    inputStreamReader?.close()
  }
  return stringBuilder.toString()
}
fun dispatch(request: RecordedRequest): MockResponse? {
  return when (request.path) {
    "/animals?limit=20&location=30318" -> {
      MockResponse().setResponseCode(200)
        .setBody(readFile("search_30318.json"))
    }
    else -> {
      MockResponse().setResponseCode(404).setBody("{}")
    }
  }
}

Writing your next test

For this test, you are going to perform a search, click on a result, and see details for the companion you tapped on.

@Test
fun searching_for_a_companion_and_tapping_on_it_takes_the_user_to_the_companion_details() {
  testScenario = ActivityScenario.launch(startIntent)
  // 1
  onView(withId(R.id.searchForCompanionFragment))
    .perform(click())
  
  // 2
  onView(withId(R.id.searchFieldText))
    .perform(typeText("30318"))
  onView(withId(R.id.searchButton))
    .perform(click())

  // 3
  onView(withId(R.id.searchButton))
    .check(matches(isDisplayed()))

  // 4
  onView(withText("KEVIN")).perform(click())
  
  // 5
  onView(withText("Rome, GA")).check(matches(isDisplayed()))
  testScenario.close()
}

IdlingResources

When using Espresso, your test suite is running in a different thread from your app. While there are some things related to the lifecycle of your activity that Espresso will be aware of, there are other things that it is not. In your case, in order to make your tests and test data readable, you are putting your data in a separate JSON file that needs to be read in. While this is not a big hit on the performance of your tests, a file read is slower than the execution time of your Espresso statements.

class SimpleIdlingResource : IdlingResource {

  // 1
  @Nullable
  @Volatile
  private var callback: IdlingResource.ResourceCallback? = null

  // 2
  // Idleness is controlled with this boolean.
  var activeResources = AtomicInteger(0)

  override fun getName(): String {
    return this.javaClass.name
  }

  // 3
  override fun isIdleNow(): Boolean {
    return activeResources.toInt() < 1
  }

  override fun registerIdleTransitionCallback(
    callback: IdlingResource.ResourceCallback
  ) {
    this.callback = callback
  }

  // 4
  fun incrementBy(incrementValue: Int) {
    if (activeResources.addAndGet(incrementValue) < 1 &&
        callback != null) {
      callback!!.onTransitionToIdle()
    }
  }
}
implementation 'org.greenrobot:eventbus:3.1.1'
data class IdlingEntity(
  var incrementValue: Int = 0,
  var resetValue: Boolean = false
)
// 1
@Subscribe
fun onEvent(idlingEntity: IdlingEntity) {
  // noop
}

// 2
override fun onStart() {
  super.onStart()
  EventBus.getDefault().register(this)
}

// 3
override fun onStop() {
  super.onStop()
  EventBus.getDefault().unregister(this)
}
EventBus.getDefault().post(IdlingEntity(1))
EventBus.getDefault().post(IdlingEntity(-1))
private fun searchForCompanions() {
  val companionLocation = view?
    .findViewById<TextInputEditText>(R.id.searchFieldText)
    ?.text.toString()
  val noResultsTextView = view?
    .findViewById<TextView>(R.id.noResults)
  val searchForCompanionFragment = this

  GlobalScope.launch {
    accessToken = (activity as MainActivity).accessToken
    (activity as MainActivity).petFinderService
      ?.let { petFinderService ->
      // increment the IdlingResources
      EventBus.getDefault().post(IdlingEntity(1))
      val getAnimalsRequest = petFinderService
        .getAnimals(accessToken, location = companionLocation)

      val searchForPetResponse = getAnimalsRequest.await()

      if (searchForPetResponse.isSuccessful) {
        searchForPetResponse.body()?.let {
          GlobalScope.launch(Dispatchers.Main) {
            if (it.animals.size > 0) {
              noResultsTextView?.visibility = INVISIBLE
              viewManager = LinearLayoutManager(context)
              companionAdapter = CompanionAdapter(it.animals,
                searchForCompanionFragment)
              petRecyclerView = view?.let {
                it.findViewById<RecyclerView>(
                  R.id.petRecyclerView
                ).apply {
                  layoutManager = viewManager
                  adapter = companionAdapter
                }
              }
            } else {
                noResultsTextView?.visibility = VISIBLE
            }
          }
        }
      } else {
        noResultsTextView?.visibility = VISIBLE
      }
      // Decrement the idling resources.
      EventBus.getDefault().post(IdlingEntity(-1))
    }
  }
}
private val idlingResource = SimpleIdlingResource()
@Subscribe
fun onEvent(idlingEntity: IdlingEntity) {
  idlingResource.incrementBy(idlingEntity.incrementValue)
}
EventBus.getDefault().register(this)
IdlingRegistry.getInstance().register(idlingResource)
IdlingRegistry.getInstance().unregister(idlingResource)
EventBus.getDefault().unregister(this)
@Test
fun searching_for_a_companion_and_tapping_on_it_takes_the_user_to_the_companion_details() {
  testScenario = ActivityScenario.launch(startIntent)

  // eventbus and idling resources register
  EventBus.getDefault().register(this)
  IdlingRegistry.getInstance().register(idlingResource)
  onView(withId(R.id.searchForCompanionFragment))
    .perform(click())
  onView(withId(R.id.searchFieldText))
    .perform(typeText("30318"))
  onView(withId(R.id.searchButton)).perform(click())
  onView(withId(R.id.searchButton))
    .check(matches(isDisplayed()))
  onView(withText("KEVIN")).perform(click())
  onView(withText("Rome, GA")).check(matches(isDisplayed()))

  // eventbus and idling resources unregister.  
  IdlingRegistry.getInstance().unregister(idlingResource)
  EventBus.getDefault().unregister(this)
  testScenario.close()
}

DRYing up your tests

One term you will hear when someone speaks about software as a craft is writing DRY code. DRY stands for Do Not Repeat Yourself. In practical terms, this means that you should try to avoid multiple lines of duplicate code in your app.

testScenario = ActivityScenario.launch(startIntent)
testScenario.close()
@Before
fun beforeTestsRun() {
  testScenario = ActivityScenario.launch(startIntent)
}
@After
fun afterTestsRun() {
  testScenario.close()
}
testScenario = ActivityScenario.launch(startIntent)
testScenario.close()

EventBus.getDefault().register(this)
IdlingRegistry.getInstance().register(idlingResource)
IdlingRegistry.getInstance().unregister(idlingResource)
EventBus.getDefault().unregister(this)

onView(withId(R.id.searchForCompanionFragment))
  .perform(click())
onView(withId(R.id.searchFieldText))
  .perform(typeText("30318"))
onView(withId(R.id.searchButton)).perform(click())
onView(withId(R.id.searchButton))
  .check(matches(isDisplayed()))
onView(withText("KEVIN")).perform(click())
private fun find_and_select_kevin_in_30318(){
  onView(withId(R.id.searchForCompanionFragment))
    .perform(click())
  onView(withId(R.id.searchFieldText))
    .perform(typeText("30318"))
  onView(withId(R.id.searchButton)).perform(click())
  onView(withId(R.id.searchButton))
    .check(matches(isDisplayed()))
  onView(withText("KEVIN")).perform(click())
}
@Test
fun searching_for_a_companion_and_tapping_on_it_takes_the_user_to_the_companion_details() {
 find_and_select_kevin_in_30318()
 onView(withText("Rome, GA")).check(matches(isDisplayed()))
}

Writing your failing test

Now, it is time to write your failing test – which you will implement afterwards. For this new test, you are going to check to make sure that you can view the correct phone number and email address when you view the details for Rocket.

@Test
fun verify_that_companion_details_shows_a_valid_phone_number_and_email() {
 find_and_select_kevin_in_30318()
 onView(withText("(706) 236-4537"))
   .check(matches(isDisplayed()))
 onView(withText("adoptions@gahomelesspets.com"))
   .check(matches(isDisplayed()))
}

<androidx.appcompat.widget.AppCompatTextView
  android:id="@+id/breed"
  android:layout_width="wrap_content"
  android:layout_height="wrap_content"
  android:text="@string/breed_placeholder"
  app:layout_constraintBottom_toTopOf="@+id/age"
  app:layout_constraintEnd_toStartOf="@id/city"
  app:layout_constraintStart_toStartOf="parent"
  app:layout_constraintTop_toBottomOf="@+id/petCarouselView" />

<androidx.appcompat.widget.AppCompatTextView
  android:id="@+id/city"
  android:layout_width="wrap_content"
  android:layout_height="wrap_content"
  android:text="@string/city_placeholder"
  app:layout_constraintBottom_toBottomOf="@id/breed"
  app:layout_constraintEnd_toEndOf="parent"
  app:layout_constraintStart_toEndOf="@+id/breed"
  app:layout_constraintTop_toTopOf="@+id/breed" />

<androidx.appcompat.widget.AppCompatTextView
  android:id="@+id/age"
  android:layout_width="wrap_content"
  android:layout_height="wrap_content"
  android:text="@string/age_placeholder"
  app:layout_constraintBottom_toTopOf="@id/meetTitlePlaceholder"
  app:layout_constraintEnd_toStartOf="@id/sex"
  app:layout_constraintStart_toStartOf="parent"
  app:layout_constraintTop_toBottomOf="@id/breed" />
<androidx.appcompat.widget.AppCompatTextView
  android:id="@+id/breed"
  android:layout_width="wrap_content"
  android:layout_height="wrap_content"
  android:text="@string/breed_placeholder"
  app:layout_constraintBottom_toTopOf="@+id/email"
  app:layout_constraintEnd_toStartOf="@id/city"
  app:layout_constraintStart_toStartOf="parent"
  app:layout_constraintTop_toBottomOf="@+id/petCarouselView" />

<androidx.appcompat.widget.AppCompatTextView
  android:id="@+id/city"
  android:layout_width="wrap_content"
  android:layout_height="wrap_content"
  android:text="@string/city_placeholder"
  app:layout_constraintBottom_toBottomOf="@id/breed"
  app:layout_constraintEnd_toEndOf="parent"
  app:layout_constraintStart_toEndOf="@+id/breed"
  app:layout_constraintTop_toTopOf="@+id/breed" />

<androidx.appcompat.widget.AppCompatTextView
  android:id="@+id/email"
  android:layout_width="wrap_content"
  android:layout_height="wrap_content"
  android:text="email placeholder"
  android:textStyle="bold"
  app:layout_constraintBottom_toTopOf="@+id/age"
  app:layout_constraintEnd_toStartOf="@id/telephone"
  app:layout_constraintStart_toStartOf="parent"
  app:layout_constraintTop_toBottomOf="@+id/breed" />

<androidx.appcompat.widget.AppCompatTextView
  android:id="@+id/telephone"
  android:layout_width="wrap_content"
  android:layout_height="wrap_content"
  android:text="telephone placeholder"
  android:textStyle="bold"
  app:layout_constraintBottom_toBottomOf="@id/email"
  app:layout_constraintEnd_toEndOf="parent"
  app:layout_constraintStart_toEndOf="@+id/email"
  app:layout_constraintTop_toTopOf="@+id/email" />

<androidx.appcompat.widget.AppCompatTextView
  android:id="@+id/age"
  android:layout_width="wrap_content"
  android:layout_height="wrap_content"
  android:text="@string/age_placeholder"
  app:layout_constraintBottom_toTopOf="@id/meetTitlePlaceholder"
  app:layout_constraintEnd_toStartOf="@id/sex"
  app:layout_constraintStart_toStartOf="parent"
  app:layout_constraintTop_toBottomOf="@id/email" />
populateTextField(R.id.email, animal.contact.email)
populateTextField(R.id.telephone, animal.contact.phone)

Key points

  • When working with a legacy app, start by abstracting away external dependencies.
  • Don’t try to get everything under test at one time.
  • Focus your testing efforts around a section you are changing.
  • MockWebServer is a great way to mock data for Retrofit.
  • When getting a legacy app under test you will probably end up needing to use IdlingResources.
  • DRY out your tests when it makes them more readable.
  • Don’t try to refactor a section of your legacy app until it is under test.

Where to go from here?

You’ve done a lot of work in this chapter! If you want to take some of these techniques further, try writing some tests around more scenarios in the app. You can also try your hand at adding additional features to the app. To see what is available with the API check out the Petfinder API documentation at https://www.petfinder.com/developers/api-docs.

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.
© 2024 Kodeco Inc.

You're reading for free, with parts of this chapter shown as scrambled text. Unlock this book, and our entire catalogue of books and videos, with a Kodeco Personal Plan.

Unlock now