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

14. Hands-On Focused Refactoring
Written by Lance Gleason

In the last chapter, you had a chance to:

  1. Get familiar with the Coding Companion app.
  2. Add tests around the search functionality.
  3. Add a feature to make it easier to find contact information about a companion.

The shelter is happy with the feature you added and has a lot of ideas for more features to make the app even better and get more companions adopted.

Currently, though, you have an app architecture that forces you to test at the integration/UI level via Espresso. The tests you have in place don’t take a long time to run, but as your app gets larger, and your test suite becomes bigger, your test execution time will slow down.

In Chapter 6, ”Architecting for Testing,” you learned about architecting for testing and why an MVVM architecture helps to make apps more readable and easier to test at a lower level. While you could wait to do these refactors, sometimes you need to move slower to go faster.

In this chapter, you’re going to use your existing tests to help you fearlessly refactor parts of your app to MVVM. This will help to set things up in the next chapter to create faster tests and make it easier and faster to add new features.

Getting started

To get started, open the final app from the previous chapter or open the starter app for this chapter. Then, open FindCompanionInstrumentedTest.kt located inside the androidTest source set.

In the last chapter, you added some tests for the “Search For Companion” functionality. You can find this test inside FindCompanionInstrumentedTest.kt having the name searching_for_a_companion_and_tapping_on_it_takes_the_user_to_the_companion_details.

This test does the following:

  1. It starts the app’s main activity, which takes the user to the Random Companion screen; this screen is backed by RandomCompanionFragment.

  1. Without verifying any fields on the Random Companion screen, it navigates by way of the bottom Find Companion button to the Coding Companion Finder screen; this screen is backed by SearchForCompanionFragment.

  1. Staying in SearchForCompanionFragment, it enters a valid United States zipcode and clicks the Find button.

  1. Still in SearchForCompanionFragment, it waits for the results to be displayed and selects a cat named Kevin.

  1. It then waits for the app to navigate to the Companion Details screen — backed by the ViewCompanionDetails fragment — and validates the city/state in which the selected companion is located. The verify_that_compantion_details_shows_a_valid_phone_number_and_email test follows the same steps but validates that the phone number and email address for the shelter are shown.

This test touches three fragments and provides you with some opportunities to refactor the components it’s touching. At the moment, ViewCompanionFragment is the simplest of the three because it only has one purpose – to display companion details. Therefore, you’ll start by refactoring this test.

Adding supplemental coverage before refactoring

You already have some testing around the “Search For Companion” functionality, including ViewCompanionFragment. Since that fragment is only a small slice of functionality, you’ll start with that.

Before you start to refactor, you need to make sure you have tests around everything that you’re changing. This helps to ensure that your refactoring doesn’t accidentally break anything. Because you’re changing things to an MVVM architecture, you’re going to touch all of the data elements this fragment displays.

Looking at the two tests that test this screen, in FindCompanionsInstrumentedTest.kt, you’ll see the following:

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

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

This is testing some of the fields in the View Companion details, but not all of them. Because Espresso tests are slow, it’s better to add these checks to one of your existing tests.

In this case, you’re going to use searching_for_a_companion_and_tapping_on_it_takes_the_user_to_the_companion_details, so paste the following to the end of that test:

onView(withText("Domestic Short Hair")).check(matches(isDisplayed()))
onView(withText("Young")).check(matches(isDisplayed()))
onView(withText("Female")).check(matches(isDisplayed()))
onView(withText("Medium")).check(matches(isDisplayed()))
onView(withText("Meet KEVIN")).check(matches(isDisplayed()))

Your test will now look like this:

@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()))
  onView(withText("Domestic Short Hair")).check(matches(isDisplayed()))
  onView(withText("Young")).check(matches(isDisplayed()))
  onView(withText("Female")).check(matches(isDisplayed()))
  onView(withText("Medium")).check(matches(isDisplayed()))
  onView(withText("Meet KEVIN")).check(matches(isDisplayed()))
}

Run it, and you’ll see the following:

According to this message, the view hierarchy has more than one field with text containing “Domestic Short Hair”.

Refactoring for testability

With Espresso tests, you’ll often run into a scenario where you have a matcher for an element that ends up matching more than one element in the view hierarchy. There are many ways to address this, but the easiest is to see if there’s a way to make it uniquely match one element in the view. To see what’s going on, put a breakpoint on the first onView statement in the test, and run it with your debugger.

Looking at the app screen, you’ll see the following:

The screen only displays “Domestic Short Hair” once. So, what’s happening here?

The CompanionViewHolder holds some clues. Look at setupClickEvent in this View Holder, and you’ll see the following line:

transaction.replace(R.id.viewCompanion, viewCompanionFragment).addToBackStack("companionView").commit()

Click on R.id.viewCompanion to find it in your view, and you’ll see that it isn’t displaying viewCompanionFragment in a FrameLayout.

This FrameLayout is on the same level as a ConstraintLayout that has a RecyclerView, which ultimately displays the search results.

This FrameLayout also has a higher Z value, which makes it display over the ConstraintLayout.

Look at the setupClickEvent in CompanionViewHolder, and you’ll see that you’re doing a transaction to replace R.id.viewCompanion with a ViewCompanionFragment.

transaction.replace(R.id.viewCompanion, viewCompanionFragment)
  .addToBackStack("companionView")
  .commit()

The issue is most likely that two views show this information — but one is hiding below the other. One way to fix this problem might be to also match on the ID of the field.

Currently, in find_pet_list_layout.xml, you’ve given the field that contains the breeds an ID of breed which is displayed by the CompanionViewHolder in the RecyclerView.

<androidx.appcompat.widget.AppCompatTextView
  android:id="@+id/breed"
  android:layout_width="0dp"
  android:layout_height="wrap_content"
  android:paddingEnd="10dp"
  android:paddingStart="10dp"
  android:text="Breed"
  app:layout_constraintBottom_toBottomOf="@+id/sex"
  app:layout_constraintEnd_toEndOf="parent"
  app:layout_constraintStart_toEndOf="@id/sex"
  app:layout_constraintTop_toTopOf="@id/sex" />

It’s also named breed in fragment_view_companion.xml that is displayed by your ViewCompanionFragment.

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

You could change the ID of the breed in the ViewCompanionFragment, but a better approach is to do a full replacement of the fragment, so you don’t have two simultaneous view hierarchies in the ViewCompanionFragment.

Since you’re already using the Jetpack Navigation Components, this is a good time to do a refactor to use this. If you’re new to Android Navigation Components, you can learn more about them at https://developer.android.com/guide/navigation.

Open nav_graph.xml inside res ‣ navigation and add the following inside the <navigation> element:

<fragment
  android:id="@+id/viewCompanion"
  android:name="com.raywenderlich.codingcompanionfinder.searchforcompanion.ViewCompanionFragment"
  android:label="fragment_view_companion"
  tools:layout="@layout/fragment_view_companion" >
  <argument
    android:name="animal"
    app:argType="com.raywenderlich.codingcompanionfinder.models.Animal" />
</fragment>

This adds the ViewCompanionFragment to the navigation graph.

Next, replace:

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

With the following:

<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" >
  <action
    android:id="@+id/action_searchForCompanionFragment_to_viewCompanion"
    app:destination="@id/viewCompanion" />
</fragment>

This adds an action to allow you to navigate between the SearchForCompanion and ViewCompanion fragment.

Looking at clickListener() in the CompanionViewHolder, you’re passing an animal object to that fragment:

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.searchForCompanion,
    viewCompanionFragment)
    .addToBackStack("companionView")
    .commit()
}

To pass the arguments with Jetpack Navigation, you’ll use Safe Args. If you’ve not used this before, you can learn more about it at https://developer.android.com/guide/navigation/navigation-pass-data#Safe-args.

Open CodingCompanionFinder build.gradle and add the following to the dependencies:

classpath "androidx.navigation:navigation-safe-args-gradle-plugin:2.1.0-rc01"

Next, open your app level build.gradle and add the following to the top of the file:

apply plugin: "androidx.navigation.safeargs.kotlin"

This adds in Safe Args support.

Now, open CompanionViewHolder.kt and replace setupClickEvent() with the following, fixing the imports as needed:

private fun setupClickEvent(animal: Animal) {
  view.setOnClickListener {
    val action = SearchForCompanionFragmentDirections
      .actionSearchForCompanionFragmentToViewCompanion(animal)
    view.findNavController().navigate(action)
  }
}

This is using SearchForCompanionFragmentDirections which is generated by Safe Args to create a navigation action with the animal as a parameter. You’re then passing the action to the navigate method on the navigation controller to perform the navigation to the ViewCompanionFragment.

Finally, open ViewCompanionFragment.kt in the same package and add the following property:

val args: ViewCompanionFragmentArgs by navArgs()

Then replace onCreateView with the following:

override fun onCreateView(
    inflater: LayoutInflater, container: ViewGroup?,
    savedInstanceState: Bundle?
): View? {
  // Inflate the layout for this fragment
  animal = args.animal
  viewCompanionFragment = this
  return inflater.inflate(R.layout.fragment_view_companion,
    container, false)
}

This retrieves the arguments passed to the fragment via ViewCompanionFragmentArgs which is generated by Safe Args.

Open fragment_search_for_companion.xml and remove the FrameLayout with an android:id of @+id/viewCompanion.

Finally, execute the searching_for_a_companion_and_tapping_on_it_takes_the_user_to_the_companion_details test in FindCompanionInstrumentedTest.kt, and it’ll be green.

Your first focused refactor

Now that you have proper test coverage around ViewCompanionFragment, it’s time to refactor it. To get started, open the app level build.gradle and add the following to the dependencies section:

// Architecture components
def lifecycle_version = "2.0.0"
implementation "androidx.lifecycle:lifecycle-extensions:$lifecycle_version"
kapt "androidx.lifecycle:lifecycle-compiler:$lifecycle_version"

This is adding the Jetpack Lifecycle components. Next, add the following Android section below buildTypes in the same file to enable data binding:

dataBinding {
  enabled = true
}

Following that, create a Kotlin file named ViewCompanionViewModel.kt in the searchforcompanion package and add the following:

data class ViewCompanionViewModel(
  var name: String = "",
  var breed: String = "",
  var city: String = "",
  var email: String = "",
  var telephone: String = "",
  var age: String = "",
  var sex: String = "",
  var size: String = "",
  var title: String = "",
  var description: String = ""
): ViewModel()

This creates a ViewModel for the data.

Next, open fragment_view_companion.xml and add a <layout> tag around the ConstraintLayout along with a <data> and <variable> tag for the view model, so it looks like this:

<layout>

  <data>
    <variable
      name="viewCompanionViewModel"
      type="com.raywenderlich.codingcompanionfinder.searchforcompanion.ViewCompanionViewModel" />
  </data>

 <androidx.constraintlayout.widget.ConstraintLayout 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:layout_width="match_parent"
   android:layout_height="match_parent"
   android:background="@color/secondaryTextColor"
   android:translationZ="5dp"
   tools:context=".randomcompanion.RandomCompanionFragment">
   .
   .
   .
  </androidx.constraintlayout.widget.ConstraintLayout>
</layout>

This adds the ability to bind data from the ViewCompanionViewModel to this view.

Now, bind each attribute of the view model to each element with a corresponding ID by replacing the text with the binding. For example, for the element with an ID of “name”, you’ll replace the text with @{viewCompanionViewModel.name}.

Your final fragment_view_companion.xml will look like this:

<layout>

  <data>
    <variable
      name="viewCompanionViewModel"
      type="com.raywenderlich.codingcompanionfinder.searchforcompanion.ViewCompanionViewModel" />
  </data>

  <androidx.constraintlayout.widget.ConstraintLayout 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:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="@color/secondaryTextColor"
    android:translationZ="5dp"
    tools:context=".randomcompanion.RandomCompanionFragment">

    <androidx.appcompat.widget.AppCompatTextView
      android:id="@+id/petName"
      android:layout_width="wrap_content"
      android:layout_height="wrap_content"
      android:layout_marginTop="10dp"
      android:layout_marginBottom="5dp"
      android:text="@{viewCompanionViewModel.name}"
      android:textSize="24sp"
      android:textStyle="bold"
      app:layout_constraintBottom_toTopOf="@id/petCarouselView"
      app:layout_constraintEnd_toEndOf="parent"
      app:layout_constraintStart_toStartOf="parent"
      app:layout_constraintTop_toTopOf="parent" />

    <com.synnapps.carouselview.CarouselView
      android:id="@+id/petCarouselView"
      android:layout_width="0dp"
      android:layout_height="200dp"
      android:layout_marginBottom="5dp"
      app:fillColor="#FFFFFFFF"
      app:layout_constraintBottom_toTopOf="@id/breed"
      app:layout_constraintEnd_toEndOf="parent"
      app:layout_constraintStart_toStartOf="parent"
      app:layout_constraintTop_toBottomOf="@id/petName"
      app:layout_constraintWidth_percent=".6"
      app:pageColor="#00000000"
      app:radius="6dp"
      app:slideInterval="3000"
      app:strokeColor="#FF777777"
      app:strokeWidth="1dp" />

    <androidx.appcompat.widget.AppCompatTextView
      android:id="@+id/breed"
      android:layout_width="wrap_content"
      android:layout_height="wrap_content"
      android:text="@{viewCompanionViewModel.breed}"
      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="@{viewCompanionViewModel.city}"
      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="@{viewCompanionViewModel.email}"
      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="@{viewCompanionViewModel.telephone}"
      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="@{viewCompanionViewModel.age}"
      app:layout_constraintBottom_toTopOf="@id/meetTitlePlaceholder"
      app:layout_constraintEnd_toStartOf="@id/sex"
      app:layout_constraintStart_toStartOf="parent"
      app:layout_constraintTop_toBottomOf="@id/email" />

    <androidx.appcompat.widget.AppCompatTextView
      android:id="@+id/sex"
      android:layout_width="wrap_content"
      android:layout_height="wrap_content"
      android:text="@{viewCompanionViewModel.sex}"
      app:layout_constraintBottom_toBottomOf="@id/age"
      app:layout_constraintEnd_toStartOf="@id/size"
      app:layout_constraintStart_toEndOf="@id/age"
      app:layout_constraintTop_toTopOf="@id/age" />

    <androidx.appcompat.widget.AppCompatTextView
      android:id="@+id/size"
      android:layout_width="wrap_content"
      android:layout_height="wrap_content"
      android:text="@{viewCompanionViewModel.size}"
      app:layout_constraintBottom_toBottomOf="@id/age"
      app:layout_constraintEnd_toEndOf="parent"
      app:layout_constraintStart_toEndOf="@id/sex"
      app:layout_constraintTop_toTopOf="@id/age" />

    <androidx.appcompat.widget.AppCompatTextView
      android:id="@+id/meetTitlePlaceholder"
      android:layout_width="wrap_content"
      android:layout_height="wrap_content"
      android:text="@{viewCompanionViewModel.title}"
      android:textStyle="bold"
      app:layout_constraintBottom_toTopOf="@+id/descriptionScroll"
      app:layout_constraintEnd_toEndOf="parent"
      app:layout_constraintStart_toStartOf="parent"
      app:layout_constraintTop_toBottomOf="@+id/age" />

    <androidx.core.widget.NestedScrollView
      android:id="@+id/descriptionScroll"
      android:layout_width="match_parent"
      android:layout_height="0dp"
      android:paddingStart="30dp"
      android:paddingEnd="30dp"
      app:layout_constraintBottom_toBottomOf="parent"
      app:layout_constraintEnd_toEndOf="parent"
      app:layout_constraintHeight_percent=".25"
      app:layout_constraintHorizontal_bias="0.0"
      app:layout_constraintStart_toStartOf="parent"
      app:layout_constraintVertical_bias="1.0">

      <androidx.appcompat.widget.AppCompatTextView
        android:id="@+id/description"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="@{viewCompanionViewModel.description}" />
    </androidx.core.widget.NestedScrollView>

  </androidx.constraintlayout.widget.ConstraintLayout>
</layout>

Build and run to make sure the app compiles and that there are no errors.

With your view in good shape, go back to ViewCompanionViewModel.kt and add the following method to the ViewCompanionViewModel class:

fun populateFromAnimal(animal: Animal) {
  name = animal.name
  breed = animal.breeds.primary
  city = animal.contact.address.city + ", " +
    animal.contact.address.state
  email = animal.contact.email
  telephone = animal.contact.phone
  age = animal.age
  sex = animal.gender
  size = animal.size
  title = "Meet " + animal.name
  description = animal.description
}

This is a helper method for converting an Animal object to your ViewModel.

Now go to ViewCompanionFragment.kt and replace onCreateView with the following:

override fun onCreateView(
  inflater: LayoutInflater, container: ViewGroup?,
  savedInstanceState: Bundle?
): View? {
  animal = args.animal
  viewCompanionFragment = this
  // 1
  val fragmentViewCompanionBinding =
    FragmentViewCompanionBinding
      .inflate(inflater, container, false)
  // 2
  val viewCompanionViewModel = ViewModelProviders.of(this)
    .get(ViewCompanionViewModel::class.java)
  // 3
  viewCompanionViewModel.populateFromAnimal(animal)
  // 4
  fragmentViewCompanionBinding.viewCompanionViewModel =
    viewCompanionViewModel
  // 5
  return fragmentViewCompanionBinding.root
}

If you find that the import for FragmentViewCompanionBinding is not behaving, do a build and then try again.

The code you just added does the following:

  1. It inflates the view via a data-binding-generated FragmentViewCompanionBinding object.
  2. Creates an instance of ViewCompanionViewModel via the ViewModelProviders.
  3. Populates the view model from an Animal.
  4. Assigns the view model to your view.
  5. Returns the root of the view.

Finally, in onResume, replace the call to populatePet() with populatePhotos()

Run your test now, and it’ll be green.

There’s still one other piece of this refactor that you’ll need to do to wrap things up. With your data binding, you no longer need populatePet() or populateTextField(...), so delete them.

Your next refactor

Swapping manual view binding for data binding in the ViewCompanionFragment was a relatively simple refactor. Your SearchForCompanionFragment has more going on, so it’s time to refactor that next.

Adding test coverage

Just like you did with the ViewCompanionFragment test, you want to make ensure that you have enough test coverage for the SearchForCompanionFragment.

Three things happen in this fragment:

  1. It presents the user with a screen to search for a companion.

  1. It gets the user’s input and performs a search.

  1. It presents the search results and allows navigation to the ViewCompanionFragment.

Open FindCompanionInstrumentedTest.kt located inside androidTest source set.

Looking through the tests, two of the three tests are referencing the following method:

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

This does a good job of testing most of the three scenarios with one exception: It does not verify all of the data when you list the results of a search. To fix that, you’ll write a test, but there’s one small change you need to make to your test data first.

If you look at your search results data, you have two animals that are both females. But earlier, you learned about it being difficult to match multiple elements with the same value/ID. To make things easier to test, you’ll change the sex of one of the companions.

Start by opening search_30318.json, which is located inside assets in the androidTest source set. Then, find the first instance of the gender attribute, which is associated with the Shih Tzu named Joy.

Next, change the gender to Male.

Following that, open FindCompanionInstrumentedTest.kt and add the following test:

@Test
fun searching_for_a_companion_in_30318_returns_two_results() {
  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("Joy")).check(matches(isDisplayed()))
  onView(withText("Male")).check(matches(isDisplayed()))
  onView(withText("Shih Tzu")).check(matches(isDisplayed()))
  onView(withText("KEVIN")).check(matches(isDisplayed()))
  onView(withText("Female")).check(matches(isDisplayed()))
  onView(withText("Domestic Short Hair"))
    .check(matches(isDisplayed()))
}

This verifies all of the data elements for the search results without clicking on one like the other tests are doing.

Finally, run the test, and everything will be green.

Note: For the sake of brevity, you’re not breaking these test conditions before making them pass. Before you move on, however, a good exercise is to try changing various data elements to ensure that each assertion breaks before setting the data back to a state that makes the test pass.

There are two other scenarios that you need to address.

Looking at searchForCompanions() in SearchForCompanionFragment.kt, there are two instances that can lead to a text view with a message indicating that no results are available:

if (searchForPetResponse.isSuccessful) {
  searchForPetResponse.body()?.let {
    GlobalScope.launch(Dispatchers.Main) {
      if (it.animals.size > 0) {
// No Results Text View is invisible when results are available.
        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 {
// No Results Text View is visible when results are not
// available.
        noResultsTextView?.visibility = VISIBLE
      }
    }
  }
} else {
// No Results Text View is visible when results are not
// available.
  noResultsTextView?.visibility = VISIBLE
}

This displays by going to the app and searching for companions under an invalid location.

There are two scenarios for which you need to add coverage:

  1. When the user enters a valid location, but there are no results.
  2. When the user enters an invalid location.

You’ll start with the first scenario.

Open CommonTestDataUtil.kt inside androidTest and replace dispatch 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"))
    }
// test data for no response
    "/animals?limit=20&location=90210" -> {
      MockResponse()
        .setResponseCode(200)
        .setBody("{\"animals\": []}")
    }
    else -> {
      MockResponse().setResponseCode(404).setBody("{}")
    }
  }
}

This adds a mock for a zip code location of 90210 that returns no results.

Next, add the following test to FindCompanionsInstrumentedTest.kt:

@Test
fun searching_for_a_companion_in_90210_returns_no_results() {
  onView(withId(R.id.searchForCompanionFragment))
    .perform(click())
  onView(withId(R.id.searchFieldText))
    .perform(typeText("90210"))
  onView(withId(R.id.searchButton)).perform(click())
  onView(withId(R.id.searchButton))
    .check(matches(isDisplayed()))
  onView(withId(R.id.noResults))
    .check(matches(withEffectiveVisibility(Visibility.VISIBLE)))
}

Since it’s a good idea to have a failing test first, go into SearchForCompanionFragment.kt and comment out the line that sets the visibility for noResultsTextView:

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 {
// Comment out this line
//noResultsTextView?.visibility = VISIBLE
}

Now, run your new test, and it’ll fail.

Finally, uncomment that line, run the test again — and this time, it passes.

For the second no results scenario, open FindCompanionInstrumentedTest.kt and add the following test:

@Test
fun searching_for_a_companion_in_a_call_returns_an_error_displays_no_results() {
  onView(withId(R.id.searchForCompanionFragment))
    .perform(click())
  onView(withId(R.id.searchFieldText)).perform(typeText("dddd"))
  onView(withId(R.id.searchButton)).perform(click())
  onView(withId(R.id.searchButton))
    .check(matches(isDisplayed()))
  onView(withId(R.id.noResults))
    .check(matches(withEffectiveVisibility(Visibility.VISIBLE)))
}

Run this test without commenting out the implementation, and you’ll see a failure message that reads:

Test failed to run to completion. Reason: 'Instrumentation run failed due to 'Process crashed.'. Check device logcat for details
Test running failed: Instrumentation run failed due to 'Process crashed.'

Looking at the code in SearchForCompanions in the SearchForCompanionFragment, you’ll see the following:

if (searchForPetResponse.isSuccessful) {
  searchForPetResponse.body()?.let {
// This is a bug, the scope should be at a higher level.
    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 {
// This is running in the wrong thread
  noResultsTextView?.visibility = VISIBLE
}

There’s a bug in the app where a “no results” scenario causes the app to crash. This happens because it’s trying to set a value in the view outside of the main thread.

To fix this error, move the GlobalScope.launch(Dispatchers.Main) line to the outside of your code block below val searchForPetResponse = getAnimalsRequest.await(). When you’re done, it should look like this:

GlobalScope.launch(Dispatchers.Main) {
  if (searchForPetResponse.isSuccessful) {
    searchForPetResponse.body()?.let {
      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
  }
}

Now, run your test and it’ll be green.

Refactoring SearchForCompanionFragment

Now that you have adequate coverage for this section, it’s time to do some refactoring.

To get started, create a new file named SearchForCompanionViewModel.kt in the searchforcompanion package. Give it the following content:

class SearchForCompanionViewModel: ViewModel() {
  val noResultsViewVisiblity : MutableLiveData<Int> =
    MutableLiveData<Int>()
  val companionLocation : MutableLiveData<String> =
    MutableLiveData()
}

This creates a ViewModel for the fragment with LiveData elements for the noResults View and companionLocation.

Next, open fragment_search_for_companion.xml and add a <layout> tag around the ConstaintLayout. Also, add a <data> and <variable> tag for the ViewModel:

<layout>

  <data>
    <variable
      name="searchForCompanionViewModel"
      type="com.raywenderlich.codingcompanionfinder.searchforcompanion.SearchForCompanionViewModel" />
  </data>

  <androidx.constraintlayout.widget.ConstraintLayout 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:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".searchforcompanion.SearchForCompanionFragment">

    .
    .
    .

  </androidx.constraintlayout.widget.ConstraintLayout>
</layout>

Now bind the SearchForCompanion ViewModel’s companionLocation to the searchField text attribute of the <TextInputEditText> with the ID of @+id/searchFieldText by adding:

android:text="@={searchForCompanionViewModel.companionLocation}"

Also, bind that ViewModel’s noResultsViewVisibility to the visibility attribute of the <TextView> with the ID of @+id/noResults by replacing:

android:visibility="invisible"

With the following:

android:visibility="@{searchForCompanionViewModel.noResultsViewVisiblity}"

The final fragment_search_for_companion.xml will look like this:

<?xml version="1.0" encoding="utf-8"?>
<layout>

  <data>
    <variable
      name="searchForCompanionViewModel"
      type="com.raywenderlich.codingcompanionfinder.searchforcompanion.SearchForCompanionViewModel" />
  </data>

  <androidx.constraintlayout.widget.ConstraintLayout 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:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".searchforcompanion.SearchForCompanionFragment">

    <androidx.constraintlayout.widget.ConstraintLayout
      android:id="@+id/searchForCompanion"
      android:layout_width="0dp"
      android:layout_height="0dp"
      app:layout_constraintBottom_toBottomOf="parent"
      app:layout_constraintEnd_toEndOf="parent"
      app:layout_constraintStart_toStartOf="parent"
      app:layout_constraintTop_toTopOf="parent">

      <com.google.android.material.textfield.TextInputLayout
        android:id="@+id/searchField"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        app:layout_constraintBottom_toTopOf="@id/petRecyclerView"
        app:layout_constraintEnd_toStartOf="@id/searchButton"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintWidth_percent=".7">

        <com.google.android.material.textfield.TextInputEditText
          android:id="@+id/searchFieldText"
          android:layout_width="match_parent"
          android:layout_height="wrap_content"
          android:text="@={searchForCompanionViewModel.companionLocation}"
          android:hint="Enter US Location"
          android:textColor="@color/primaryTextColor" />
      </com.google.android.material.textfield.TextInputLayout>

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

      <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/petRecyclerView"
        android:layout_width="match_parent"
        android:layout_height="0dp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintHeight_percent=".8"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@id/searchField" />

      <TextView
        android:id="@+id/noResults"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="No Results"
        android:textSize="36sp"
        android:textStyle="bold"
        android:visibility="@{searchForCompanionViewModel.noResultsViewVisiblity}"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintHeight_percent=".8"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@id/searchField" />
    </androidx.constraintlayout.widget.ConstraintLayout>
  </androidx.constraintlayout.widget.ConstraintLayout>
</layout>

With that done, go back to SearchForCompanionFragment.kt and replace onCreateView with the following:

private lateinit var fragmentSearchForCompanionBinding:
  FragmentSearchForCompanionBinding
private lateinit var searchForCompanionViewModel:
  SearchForCompanionViewModel

override fun onCreateView(
    inflater: LayoutInflater, container: ViewGroup?,
    savedInstanceState: Bundle?
): View? {
  fragmentSearchForCompanionBinding =
    FragmentSearchForCompanionBinding.inflate(inflater,
      container, false)
  searchForCompanionViewModel = ViewModelProviders.of(this)
    .get(SearchForCompanionViewModel::class.java)
  fragmentSearchForCompanionBinding.searchForCompanionViewModel
    = searchForCompanionViewModel
  fragmentSearchForCompanionBinding.lifecycleOwner = this
  return fragmentSearchForCompanionBinding.root
}

Note: If you find the FragmentSearchForCompanionBinding import not resolving, perform a build.

Locate searchForCompanions() and replace it with the following:

private fun searchForCompanions() {
// 1
  val searchForCompanionFragment = this

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

      val searchForPetResponse = getAnimalsRequest.await()

      GlobalScope.launch(Dispatchers.Main) {
        if (searchForPetResponse.isSuccessful) {
          searchForPetResponse.body()?.let {
            if (it.animals.size > 0) {
              // 3
              searchForCompanionViewModel
                .noResultsViewVisiblity
                .postValue(INVISIBLE)
              viewManager = LinearLayoutManager(context)
              companionAdapter = CompanionAdapter(it.animals,
                searchForCompanionFragment)
              petRecyclerView = view?.let {
                it.findViewById<RecyclerView>(
                  R.id.petRecyclerView
                ).apply {
                  layoutManager = viewManager
                  adapter = companionAdapter
                }
              }
            } else {
// 3
              searchForCompanionViewModel
                .noResultsViewVisiblity
                .postValue(VISIBLE)
            }
          }
        } else {
// 3
          searchForCompanionViewModel
            .noResultsViewVisiblity
            .postValue(VISIBLE)
        }
      }
      EventBus.getDefault().post(IdlingEntity(-1))
    }
  }
}

This does the following:

  1. Removes the findViewById references for the two text elements.
  2. Uses the bound value from the ViewModel to pass the location that a user is searching for into the web API call.
  3. Uses the bound value for noResultsViewVisibility to set the visibility of the No Results text whether or not results are found.

Run the tests in FindCompanionsInstrumentedTest.kt, and they’ll all still be green. Great refactor!

This is a good first step at refactoring SearchForCompanionFragment, but there’s still a lot of logic in your controller.

searchForCompanions() has a lot of stuff going on with its calls to Retrofit; these can be moved to the ViewModel. This allows you to bring the testing of this component down to a unit level, which you’ll do in the next chapter.

To get started, open SearchForCompanionViewModel.kt and add the following:

// 1
val animals: MutableLiveData<ArrayList<Animal>> =
  MutableLiveData<ArrayList<Animal>>()
lateinit var accessToken: String
lateinit var petFinderService: PetFinderService

fun searchForCompanions() {

  GlobalScope.launch {

    EventBus.getDefault().post(IdlingEntity(1))
// 2
    val getAnimalsRequest = petFinderService.getAnimals(
      accessToken,
      location = companionLocation.value
    )

    val searchForPetResponse = getAnimalsRequest.await()

    GlobalScope.launch(Dispatchers.Main) {
      if (searchForPetResponse.isSuccessful) {
        searchForPetResponse.body()?.let {
// 3
          animals.postValue(it.animals)
          if (it.animals.size > 0) {
// 3            
            noResultsViewVisiblity.postValue(INVISIBLE)
          } else {
// 3            
            noResultsViewVisiblity.postValue(View.VISIBLE)
          }
        }
      } else {
// 3        
        noResultsViewVisiblity.postValue(View.VISIBLE)
      }
    }
    EventBus.getDefault().post(IdlingEntity(-1))
  }
}

This is a refactored version of the controller’s searchForCompanions method; it does three things:

  1. Creates some variables used to pass data between your ViewModel, View layout and Fragment.
  2. Calls petFinderService to make calls to the API.
  3. Sets appropriate values in the ViewModel used in your View layout.

Next, open the SearchForCompanionFragment and add the following method:

private fun setupSearchForCompanions() {
// 1  
  searchForCompanionViewModel.accessToken =
    (activity as MainActivity).accessToken
  searchForCompanionViewModel.petFinderService =
    (activity as MainActivity).petFinderService!!
// 2
  viewManager = LinearLayoutManager(context)
  companionAdapter = CompanionAdapter(
    searchForCompanionViewModel.animals.value ?: arrayListOf(),
    this
  )
  petRecyclerView = fragmentSearchForCompanionBinding
    .petRecyclerView.apply {
      layoutManager = viewManager
      adapter = companionAdapter
    }
// 3  
  searchForCompanionViewModel.animals.observe(this, Observer<ArrayList<Animal>?> {
    companionAdapter.animals = it ?: arrayListOf()
    companionAdapter.notifyDataSetChanged()
  })
}

This does the following:

  1. Passes the Retrofit service and access token into your ViewModel.
  2. Sets up the RecyclerView for the list of companions.
  3. Observes changes to the list of animals which occurs when results come back from a search. It also updates the RecyclerView with the new data when that happens.

Following that, in the same fragment, replace onActivityCreated with the following:

override fun onActivityCreated(savedInstanceState: Bundle?) {
// 1
  fragmentSearchForCompanionBinding.searchButton
  .setOnClickListener {
    try {
      val inputMethodManager = activity?.getSystemService(
        Context.INPUT_METHOD_SERVICE) as InputMethodManager?
      inputMethodManager!!.hideSoftInputFromWindow(
        activity?.getCurrentFocus()?.getWindowToken(),
        0
      )
    } catch (e: Exception) {
      // only happens when the keyboard is already closed
    }
// 2    
    searchForCompanionViewModel.searchForCompanions()
  }
// 3
  setupSearchForCompanions()
  super.onActivityCreated(savedInstanceState)
}

This code:

  1. Migrates the findViewById call to using the data binding reference to get your search button.
  2. Replaces the call to the fragment’s local searchForCompanions with a call to the same method name on the searchForCompanionViewModel.
  3. Adds a call to the new setupSearchForCompanions in this fragment.

Now that you made those changes, you can remove searchForCompanions() in the SearchForCompanionFragment.

Finally, call run on all of your tests in FindCompanionsIntrumentedTest.kt and they’ll remain green.

Insert Koin

Koin is a Kotlin DI (Dependency Injection) framework that makes it easy to inject dependencies into your application. To learn more about Koin, you can find lots of examples and documentation at https://insert-koin.io/.

In the next chapter, you’ll make use of Koin when you refactor some of your tests. But since you’re refactoring your code now, you can add Koin now.

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

// Koin
implementation 'org.koin:koin-android-viewmodel:1.0.1'
androidTestImplementation 'org.koin:koin-test:1.0.1'

This brings the Koin dependencies into your project.

Next, open MainActivity.kt. You need to move this code:

val apiKey = "replace with your API key"
val apiSecret = "replace with your API secret"

And place it into a companion object. You also need to rename them to API_KEY and API_SECRET:

val API_KEY = "your api ket"
val API_SECRET = "your api secret"

Now, add another value in the companion object named DEFAULT_PETFINDER_URL:

val DEFAULT_PETFINDER_URL = "http://api.petfinder.com/v2/"

The final companion object should look like:

  companion object {
    val PETFINDER_URI = "petfinder_uri"
    val PETFINDER_KEY = "petfinder_key"
    val API_KEY = "your client id"
    val API_SECRET = "your client secret"
    val DEFAULT_PETFINDER_URL = "https://api.petfinder.com/v2/"
  }

Next, remove the Intent String fetch on line 3 from onCreate:

// remove these!!
intent.getStringExtra(PETFINDER_KEY)?.let {
  apiKey = it
}

Following that, remove the following line since it’s no longer needed:

var token: Token = Token()

Open AuthorizationInterceptor.kt located inside the retrofit package in your project, and replace it with the following:

// 1
class AuthorizationInterceptor : Interceptor, KoinComponent {

// 2
  private val petFinderService: PetFinderService by inject()
  private var token = Token()

  @Throws(IOException::class)
  override fun intercept(chain: Interceptor.Chain): Response {
    var mainResponse = chain.proceed(chain.request())
    val mainRequest = chain.request()

    if ((mainResponse.code() == 401 ||
      mainResponse.code() == 403) &&
      !mainResponse.request().url().url().toString()
        .contains("oauth2/token")) {
// 3    
        val tokenRequest = petFinderService.getToken(
          clientId = MainActivity.API_KEY,
          clientSecret = MainActivity.API_SECRET)
        val tokenResponse = tokenRequest.execute()

        if (tokenResponse.isSuccessful()) {
          tokenResponse.body()?.let {
            token = it
            val builder = mainRequest.newBuilder()
              .header("Authorization", "Bearer " +
                it.accessToken)
              .method(mainRequest.method(), mainRequest.body())
            mainResponse = chain.proceed(builder.build())
          }
        }
    }

    return mainResponse
  }

}

This changes the following things:

  1. It removes the dependencies that needed to be passed into this class when it’s created. It also adds a dependency on KoinComponent that allows you to inject dependencies into the class.
  2. It injects PetFinderService and brings the token it needs to periodically refresh into the interceptor class.
  3. It uses the companion object parameters from MainActivity for CLIENT_ID and CLIENT_SECRET.

Go to MainActivity.kt and remove the this parameter from the following line in onCreate:

.addInterceptor(AuthorizationInterceptor(this))

Change it to:

.addInterceptor(AuthorizationInterceptor())

Next, open SearchForCompanionViewModel, and add val petfinderService: PetfinderService as a constructor parameter, like this:

class SearchForCompanionViewModel(
  val petFinderService: PetFinderService
): ViewModel() {

Now, remove:

lateinit var petFinderService: PetFinderService

This makes it easier to inject the PetFinderService into SearchForCompanionViewModel.

Koin also requires a KoinModule that tells it what to inject.

Create a new file in the main project package named KoinModule.kt and add the following:

const val PETFINDER_URL = "PETFINDER_URL"

val urlsModule = module {
  single(name = PETFINDER_URL) {
    MainActivity.DEFAULT_PETFINDER_URL
  }
}

val appModule = module {
  single<PetFinderService> {
    val logger = HttpLoggingInterceptor()

    val client = OkHttpClient.Builder()
      .addInterceptor(logger)
      .connectTimeout(60L, TimeUnit.SECONDS)
      .readTimeout(60L, TimeUnit.SECONDS)
      .addInterceptor(AuthorizationInterceptor())
      .build()

    Retrofit.Builder()
      .baseUrl(get(PETFINDER_URL) as String)
      .addConverterFactory(GsonConverterFactory.create())
      .addCallAdapterFactory(CoroutineCallAdapterFactory())
      .client(client)
      .build().create(PetFinderService::class.java)
  }

  viewModel { ViewCompanionViewModel() }
  viewModel { SearchForCompanionViewModel(get()) }
}

The appModule is creating a single instance of PetFinderService and allows it to be injected as needed. It’s also creating instances of the ViewModels, which under the hood uses the Jetpack ViewModelFactory that binds to the lifecycle of the Fragment. The urlsModule creates a string that references the Petfinder URL and is used in appModule.

In the main project package, com.raywenderlich.codingcompanionfinder create a file named CodingCompanionFinder.kt and add the following content:

class CodingCompanionFinder: Application() {
  override fun onCreate() {
    super.onCreate()
    startKoin(this, listOf(appModule, urlsModule))
  }
}

This adds some code to initialize Koin when your app is started.

Now, open AndroidManifest.xml and add android:name=".CodingCompanionFinder" to the application tag so that it looks like this:

<application
  android:name=".CodingCompanionFinder"
  android:allowBackup="true"
  android:icon="@mipmap/ic_coding_companion"
  android:label="@string/app_name"
  android:roundIcon="@mipmap/ic_coding_companion_round"
  android:supportsRtl="true"
  android:usesCleartextTraffic="true"
  android:theme="@style/AppTheme">
  .
  .
  .

This tells the app to use the new Application object when starting the app.

Following that, open SearchForCompanionFragment.kt under the searchforcompanion package and change:

private lateinit var searchForCompanionViewModel:
  SearchForCompanionViewModel

To the following:

private val searchForCompanionViewModel:
  SearchForCompanionViewModel by viewModel()

This uses Koin to inject the lifecycle-aware ViewModel.

Finally, in the same Fragment, remove the following line from the onCreateView since you no longer need it:

searchForCompanionViewModel =
  ViewModelProviders.of(this)
    .get(SearchForCompanionViewModel::class.java)

Now, remove this from setupSearchForCompanions:

searchForCompanionViewModel.petFinderService =
  (activity as MainActivity).petFinderService!!

Run your app, and it’ll still work as it did before.

While your app is working correctly, run the tests. You’ll notice that most of them are broken.

To fix them, open the FindCompanionInstrumentedTest.kt inside androidTest and make the test class inherit from KoinTest. It’ll look like this:

class FindCompanionInstrumentedTest : KoinTest {

Next, add the following method:

  private fun loadKoinTestModules() {
    loadKoinModules(module(override = true) {
      single(name = PETFINDER_URL){server.url("").toString()}
    }, appModule)
  }

This is creating a function that loads the appModule you defined earlier and an inline module that replaces urlsModule to reference the URL for your MockWebServer.

In beforeTestRun(), add a call to stopKoin(), followed by loadKoinTestModules(), after you launch the ActivityScenario. Your changes will look like this:

@Before
fun beforeTestsRun() {
  testScenario = ActivityScenario.launch(startIntent)
// Insert them here!!  
  stopKoin()
  loadKoinTestModules()
  EventBus.getDefault().register(this)
  IdlingRegistry.getInstance().register(idlingResource)
}

Since Koin starts as part of the app, this stops that instance of Koin, so you can inject the test Koin modules, which is done in loadKoinTestModules().

To finish up, add a call to stopKoin() as the second to last line of afterTestRun:

@After
fun afterTestsRun() {
  // eventbus and idling resources unregister.
  IdlingRegistry.getInstance().unregister(idlingResource)
  EventBus.getDefault().unregister(this)
  stopKoin()
  testScenario.close()
}

Run your tests, and they’ll be green again.

Challenge

Challenge: Refactor and addition

  • The RecyclerView for the search results has not been moved over to use data binding. Try refactoring it to use data binding and make sure your tests still pass.
  • Try adding a new feature with an Espresso test and then refactor it.

Key points

  • Make sure your tests cover everything that you’re changing.
  • Sometimes, you’ll need to refactor your code to make it more testable.
  • Some refactors require changes to your tests.
  • Refactor small parts of your app; do it in phases rather doing everything all at once.
  • DI provides a cleaner way to add test dependencies.
  • Keep your tests green.
  • Move slow to go fast.

Where to go from here?

You’ve done a lot of work in this chapter to set yourself up to go fast. Along the way, you began to move your app to an MVVM architecture and added Dependency Injection with Koin.

TDD is a journey, but there are a lot of homeless coding companions and pair-less developers counting on you. So, stay tuned for the next chapter, where you’ll learn how to refactor your tests to start to go fast.

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.