Chapters

Hide chapters

Android Test-Driven Development by Tutorials

First Edition · Android 10 · Kotlin 1.3 · AS 3.5

Before You Begin

Section 0: 3 chapters
Show chapters Hide chapters

Section II: Testing on a New Project

Section 2: 8 chapters
Show chapters Hide chapters

Section III: TDD on Legacy Projects

Section 3: 9 chapters
Show chapters Hide chapters

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

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.

Other developers have been in the same situation, and they had developed a technique called purr programming, in which a human development pair is substituted with a cat. The developer loved cats, adopted one, began to regularly purr program, and noticed that the quality of their software written at home dramatically improved. Beyond the benefits of pair programming, the developer also gained a loving companion, and soon realized that it could be used with dogs as well. Whenever the developer was at meetup groups or work, they’d tell friends about the benefits of purr programming and a related practice called canine coding.

One day, when the developer was at the pet store, they noticed that a local pet shelter was hosting an “Adopt a Pet” day and immediately thought: “What if there was an app to help match fellow programmers to these pets?” They decided to partner with the shelter to create the Coding Companion Finder.

The app has been successful at placing companions into loving homes, but many pets are still without homes and many developers have yet to discover this technique. After getting feedback from users, the shelter has some ideas for the app, but the original developer is too busy, so they have reached out to you!

Setting up the app

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

To get started, go to the Petfinder registration page at https://www.petfinder.com/user/register/ and create a new account.

If you are not in the US, Canada or Mexico, choose the United States for your location and 30354 as your zipcode. Once your account is created, go here https://www.petfinder.com/user/login/, log in, and then create a API key by entering a Application Name, Application URL, accept the Terms of Service and click the GET A KEY button.

Once you request a key, you will be redirected to a page that will show you an API Key and an API Secret. Copy the API key value.

Now, import the starter project and open up MainActivity.kt. At the top of this file you will see the following:

val apiKey = "replace with your API key"

val apiSecret = "replace with your API secret"

Replace the string with the key that you’ve just copied. Run the app.

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.

Tap on Find Companion. You will be taken to a search screen where you can search for companions in the United States, Canada or Mexico. Enter a location and tap the FIND button to find companions close to that location. Then, tap on one of them to see more information about a 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.9.0") {
  exclude group: "com.android.support"
}
kapt 'com.github.bumptech.glide:compiler:4.9.0'

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

// retrofit
implementation "com.squareup.okhttp3:logging-interceptor:3.11.0"
implementation 'com.squareup.retrofit2:retrofit:2.5.0'
implementation 'com.squareup.retrofit2:converter-gson:2.5.0'
implementation 'com.jakewharton.retrofit:retrofit2-kotlin-coroutines-adapter:0.9.2'

Our project depends on Glide, Carouselview and Retrofit. Now, open up MainActivity.kt. In your onCreate method, you will see the following:

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())
        .addCallAdapterFactory(CoroutineCallAdapterFactory())
        .client(client)
        .build().create(PetFinderService::class.java)
}

This sets up Retrofit with a hard-coded URL. Looking at onResume, you will see a few more hints about how things come together:

val navHostController = Navigation.findNavController(this,
  R.id.mainPetfinderFragment)

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

NavigationUI.setupWithNavController(bottomNavigation, navHostController)

This is using the Jetpack Navigation Library to set up your BottomNavigationView and hook it up to a fragment element in your activity_main.xml layout. Open up that file and you will see the following:

<fragment
    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"/>

Your BottomNavigationView has a menu that is set up in bottom_navigation_menu.xml and your fragment references a navGraph with:

app:navGraph="@navigation/nav_graph"

Now, open up bottom_navigation_menu.xml and you will see the following:

<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>

This is populating your bottom menu items. Finally open up nav_graph.xml and you will see the following navigation definition:

<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>

The call to NavigationUI.setupWithNavController matches the ID of your menu items with the IDs of your nav_graph and instantiates either your randomCompanionFragment or searchForCompanionFragment depending on which menu item you select.

When you have multiple screens in your app, there are three main ways that you might choose to do it:

  1. Multiple activities, one for each screen. When your user navigates to another screen, a new activity is shown.
  2. One activity with multiple fragments. When you user navigates to a new screen, you show a new fragment.
  3. A hybrid of #1 and #2.

Because this is using the Jetpack Navigation Component, it probably is going to be using a one-activity, multiple-fragment approach. To verify that, take a look at your project view.

Other than one additional activity that is used for the splash screen, that assumption appears to be correct. Since you’re going to be working on the search functionality, open up SearchForCompanionFragment.kt and have a look around.

In your onActivityCreated method, you are using findViewById to get a reference to your search button. This probably means that the app does not use data binding or a helper library such as Butterknife to get references to objects in your view.

When the search button is tapped, a call is made to a local method called searchForCompanions().

At the top of this method are additional findViewById calls.

This is calling your PetFinderService which is provided by Retrofit.

The results are then passed into a CompanionAdapter that is part of a RecyclerView.

In summary, your app has the following traits:

  1. It does not follow a MVC, MVP, MVVM, or MVI type of pattern.
  2. Looking at the app as a whole, it has one main external dependency on the Petfinder service.
  3. Like many legacy apps, it has some coding/architectural issues that you will need to work around to get it under test.

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.

A rule of thumb when writing Espresso tests is to not have tests that make network requests or access external resources. In the case of your app, your boundary is the Petfinder service. Pets are constantly being added and removed from the service, and some calls, such as the one for a featured pet, provide different data every time you call it. Beyond the network latency, those changes would make it very difficult to create meaningful repeatable tests. As you get the app under test, you will be adding a mock of this to address that.

Preparing your app for testing

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

androidTestImplementation "androidx.test:rules:1.2.0"
androidTestImplementation "androidx.test.ext:junit:1.1.1"
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.2.0"

This is adding libraries for rules, junit, navigation-testing, mockwebserver and espresso-contrib.

Next, rename ExampleInstrumentedTest in the androidTest source set to be FindCompanionInstrumentedTest.

Now, paste the following into your renamed test class:

  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())
    }
  }

This is doing the following:

  1. Creating an instance of your MockWebServer.
  2. Creating a dispatcher that will intercept requests to the MockWebServer and respond with a MockResponse that will be sent out to the server.
  3. Passing the dispatcher to your MockWebServer and starting it when the class is first instantiated.
  4. Creates an intent to pass in the URL for the MockWebServer.

Fix all the required imports, selecting okhttp3.mockwebserver.Dispatcher as the import for the Dispatcher if you get multiple options. There is an “unresolved reference” error with CommonTestDataUtil. To fix that, create a new file in your androidTest directory called CommonTestDataUtil.kt, and paste in the following:

object CommonTestDataUtil {
  fun dispatch(request: RecordedRequest): MockResponse? {
    when (request.path) {
      else -> {
        return MockResponse()
          .setResponseCode(404)
          .setBody("{}")
      }
    }
  }
}

This is the beginning of a helper method that will look at the request coming in, and respond based on the request parameters. Don’t worry about the warning that the when clause can be simplified for now.

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.

Open open MainActivity.kt and look for the following:

      petFinderService = Retrofit.Builder()
          .baseUrl("http://api.petfinder.com/v2/")
          .addConverterFactory(GsonConverterFactory.create())
          .addCallAdapterFactory(CoroutineCallAdapterFactory())
          .client(client)
          .build().create(PetFinderService::class.java)
    }

Replace it with the following:

      val baseUrl = intent.getStringExtra(PETFINDER_URI) ?:  
        "http://api.petfinder.com/v2/"

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

This checks the intent for a value stored under PETFINDER_URI, and if one is present uses it instead of your hard-coded URI.

Now, add the following to the top of the class:

companion object {
  val PETFINDER_URI = "petfinder_uri"
  val PETFINDER_KEY = "petfinder_key"
}

This creates two constants for your Intent keys. Finally, paste the following into the top part of your onCreate function:

intent.getStringExtra(PETFINDER_KEY)?.let{
  apiKey = it
}

This looks for an API key being passed into your MainActivity via an Intent, and if there is one, sets your key to that value instead of your hard-coded one.

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.

To get started, you are going to add tests around the “search for companion” section of your app. When you first start the app, you are taken to the Featured Companion page.

Pressing the Find Companion button takes you to the find page.

For your first test, you are going to open up the app, press the Find Companion bottom item and verify that you are on the “Find Companion” page.

To get started, make sure that the app is running on your device and use the Layout Inspector in your Android Studio Tools menu to get a snapshot of the screen. Now, highlight that menu item to find the ID of that menu item.

Your SearchForCompanionFragment is the entry point for your search page. It has a view called fragment_search_for_companion. Open it up and get the ID of your Find button:

    <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" />

Next, look for the ID of your text input field:

      <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" />

Putting this all together, when you enter the app, you are going to click on a button with an ID of searchForCompanionFragment. Then, you will check that items with the ID of searchButton and searchFieldText are visible, in order to verify that your app does indeed go to the next screen.

Open up your FindCompanionInstrumentedTest, remove the function called useAppContext, and paste in the following function:

@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()
}

Add all the imports, picking androidx.test.espresso.assertion.ViewAssertions.matches when presented with choices for the matches() method. Next, run the test by clicking the green arrow on the left of the code — and you should have green (passing) test!

Note: You may have noticed that you are not really mocking anything required for the Featured Companion screen. This is because you are not testing this screen, and it is not necessary to populate data for it to test your Find Companion screen.

Now that you have a passing test, you are going to want to have a basic test to exercise performing a search and tapping on a result.

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")
fun getAnimals(
    @Header("Authorization")  accessToken: String,
    @Query("limit") limit: Int = 20,
    @Query("location") location: String? = null
) : Deferred<Response<AnimalResult>>

That call passes in a accessToken and location. The accessToken is retrieved via a OAUTH call to a oauth2/token endpoint passing in the apiKey and apiSecret you received when you registered for the Petfinder API. In order to mock out this test data, you are going to need to figure out what real data might look like! The calls to getAnimals happen to be GET requests, and the data returned is in a JSON format. But in order to test out the calls, you will need to make a POST call to get your accessToken and then pass that in the header of your GET request.

To get an idea of how the data looks, you can use a tool such as Postman, found here https://www.getpostman.com/, to explore the Petfinder documents located here https://www.petfinder.com/developers/v2/docs/.

Looking at the output from Postman with the list of pets contracted, you will see a list with 20 animals.

For your test, you will only need to have one or two pets. Each pet record will also have several photos.

These photos reference URLs on the web. For the time being, you are not going to test the photo loading and are going to want to have records without photos. A relatively straight-forward way to do this is to copy the fully expanded formatted text into a text file, edit out the data you don’t want, and save it. To save you some time, we have included a file called search_30318.json in app/src/androidTest/assets.

SearchForCompanionFragment uses a RecyclerView to display the list of results. Open the CompanionViewHolder.kt. At the bottom of this file, you will see:

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()
  }
}

When you tap on a companion in that list, a ViewCompanionFragment is created, and the Animal object for that record is passed into it via Bundle arguments. Now open your ViewCompanionFragment and you will see that the only data inputs for this fragment are via the arguments Bundle. So you do not have to mock out any other calls!

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()
}

This is opening up your file, reading it, and returning it as a string. Next, replace your dispatch function with the following:

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("{}")
    }
  }
}

This is adding a when condition for the request that is made when you do a search for the zipcode 30318.

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.

Open up FindCompanionInstrumentedTest.kt and add the following:

@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()
}

Here is what this is doing:

  1. Clicks the bottom menu item to find a companion.
  2. Enters 30318 in searchTextField and clicks the “Find” button.
  3. It makes sure that you are still on the Find Companion screen.
  4. Clicks on the record for a cat named KEVIN.
  5. Verifies that you are on the new page by looking for a data item that was not in the list of items. In this case, the city of Rome, GA.

Now run the test.

Unfortunately, something is not right. It is not finding results for your search.

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.

Because of this, Espresso is evaluating your statement to press the button for Rocket the cat before your app has finished reading in the data and populating your RecyclerView. One clumsy-but-effective way to work around this is to put a Thread.sleep(5000) before the command for the button press.

There are, however, a number of problems with that idea, including:

  • Your tests may not need the amount of time you specified on a device, and will thus run slower than needed.
  • Your tests may need more time than you specified on other devices which makes them unreliable.

This is where IdlingResource comes in. The idea behind using IdlingResource is to create a mechanism that allows you to send a message to Espresso telling it that the app is busy doing something and another message to tell it when it is done. That way your test is only waiting for a longer running command when it actually should.

To get started, create a new Kotlin file in your test directory called SimpleIdlingResource.kt and enter the following:

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()
    }
  }
}

This class is an implementation of the IdlingResource interface. There is a lot going on here, so let’s break it down:

  1. Setting up a ResourceCallback reference to tell Espresso when it is transitioning to idle.
  2. Creating a counter to keep track of the current active resources.
  3. Returns the idling status based on the number of active resources.
  4. Increments the active resources count by the number passed in and transitions to idle if this new value is less than 1.

Now that you have your SimpleIdlingResource, you are going to need a way to trigger it when something happens. You could move this to your app code, call it from there, and access it from your test. But, there is a way that is a little bit cleaner using EventBus.

EventBus is a library that makes it easy to subscribe to and publish messages. If you haven’t used it before you can learn all about it at https://github.com/greenrobot/EventBus.

To get started, add the following to your app level build.gradle:

implementation 'org.greenrobot:eventbus:3.1.1'

EventBus posts and receives messages as data objects (often called Plain Old Java Objects, or POJOs in Java). These objects need to be in your app. Under com.raywenderlich.codingcompanionfinder in the main source set, create a new package called testhooks. In that package, create a Kotlin file called IdlingEntity.kt, and add the following content:

data class IdlingEntity(
  var incrementValue: Int = 0,
  var resetValue: Boolean = false
)

Next, open your MainActivity and add the following:

// 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)
}

This is doing three things:

  1. Adding a subscription, which is required for the EventBus library to work.
  2. Registering this class with EventBus when the activity starts.
  3. Unregistering this class with Eventbus when the activity stops.

Now, open your SearchForCompanion fragment in the searchforcompanion package and go to your searchForCompanions function. Add a post command to increment your Idling resources before you call your petfinder service:

EventBus.getDefault().post(IdlingEntity(1))

And another to decrement it once it is done with the call:

EventBus.getDefault().post(IdlingEntity(-1))

Your final method should look like this:

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))
    }
  }
}

You’re almost there! Open up your FindCompanionInstrumentedTest.kt and add a line to create a SimpleIdlingResource as a property at the class level:

private val idlingResource = SimpleIdlingResource()

Now add a subscribe function to receive increment/decrement calls:

@Subscribe
fun onEvent(idlingEntity: IdlingEntity) {
  idlingResource.incrementBy(idlingEntity.incrementValue)
}

Next, in searching_for_a_companion_and_tapping_on_it_takes_the_user_to_the_companion_details(), add these two lines after you launch your activity:

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

This registers your test class with EventBus and registers the idling resources. Finally, add these two lines at the end of that function before testScenario.close():

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

Your final function should look like 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()
}

Run your tests and everything will be green.

Now that you have all of this in place, it’s time to add the shelter information to your companion details page. The test for this will be very similar to the last test you added. You are going to go to your Find Companion page, search by a location, select a companion, and then verify that the contact information is correct. The only difference will be what you are checking for.

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.

This is also a good thing to do with tests. That said, your tests, if you are doing TDD well, provide a form of documentation for the code. If drying up your tests makes it easier to maintain your tests and makes them more readable, by all means do it, but if a particular effort to dry out the tests doesn’t add a significant maintainability benefit, or makes them more difficult to read, it may be better not to do that refactor.

Looking at your current tests, you have the following code at the beginning of both:

testScenario = ActivityScenario.launch(startIntent)

You also have the following at the end of both:

testScenario.close()

The common code at the beginning of both tests can be moved to a common @Before method and will make both tests more readable by moving common set-up information out of the individual tests. To get started, add the following method to your test class:

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

The @Before annotation tells the test suite to run that function before every test. Now, add the following method:

@After
fun afterTestsRun() {
  testScenario.close()
}

Next, remove the following from your pressing_the_find_bottom_menu_item_takes_the_user_to_the_find_page() and searching_for_a_companion_and_tapping_on_it_takes_the_user_to_the_companion_details() tests:

testScenario = ActivityScenario.launch(startIntent)

And:

testScenario.close()

Finally, run the tests and everything will still be green.

Your next test is going to use the IdlingRegistry. Having that run before your pressing_the_find_bottom_menu_item_takes_the_user_to_the_find_page will not affect that test. It will also make searching_for_a_companion_and_tapping_on_it_takes_the_user_to_the_companion_details and your next test more readable. Move the following two lines from searching_for_a_companion_and_tapping_on_it_takes_the_user_to_the_companion_details to the end of the beforeTestRun() function:

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

Next, cut the following two lines from the end of searching_for_a_companion_and_tapping_on_it_takes_the_user_to_the_companion_details:

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

And add them to the beginning of your afterTestRun() function.

Finally, run the tests and make sure everything is green.

Your next test is going to share a lot of steps with searching_for_a_companion_and_tapping_on_it_takes_the_user_to_the_companion_details. So lets refactor some common functionality.

First, cut the following lines from searching_for_a_companion_and_tapping_on_it_takes_the_user_to_the_companion_details:

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())

Next, paste them into a new function:

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())
}

Now, add a call to your new function in searching_for_a_companion_and_tapping_on_it_takes_the_user_to_the_companion_details:

@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()))
}

Finally, run the tests and make sure everything is still green — it is important to check your refactors haven’t accidentally broken anything.

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.

To get started, add the following test:

@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()))
}

Now, run the test and make sure that it fails.

Next, open fragment_view_companion.xml and replace this:

<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" />

With:

<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" />

Next, open ViewCompanionFragment.kt and add the following to the populatePet() function:

populateTextField(R.id.email, animal.contact.email)
populateTextField(R.id.telephone, animal.contact.phone)

Finally, run your tests and everything should be green.

Congratulations! Your app is under test and the shelter has a new feature that will help them to place more coding companions!

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.

Digging deeper, in the next chapter, Chapter 14, “Hands-On Focused Refactoring,” you will begin to explore how you can use TDD and refactoring side-by-side.

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.