Chapters

Hide chapters

Android Test-Driven Development by Tutorials

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

Section II: Testing on a New Project

Section 2: 8 chapters
Show chapters Hide chapters

Section III: TDD on Legacy Projects

Section 3: 8 chapters
Show chapters Hide chapters

14. Hands-On Focused Refactoring
Written by Lance Gleason

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

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.

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

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.

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

transaction.replace(R.id.viewCompanion, viewCompanionFragment)
  .addToBackStack("companionView")
  .commit()
<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" />
<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" />
<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>
<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" />
<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>
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()
}
classpath "androidx.navigation:navigation-safe-args-gradle-plugin:2.3.5"
apply plugin: "androidx.navigation.safeargs.kotlin"
private fun setupClickEvent(animal: Animal) {
  view.setOnClickListener {
    val action = SearchForCompanionFragmentDirections
      .actionSearchForCompanionFragmentToViewCompanion(animal)
    view.findNavController().navigate(action)
  }
}
val args: ViewCompanionFragmentArgs by navArgs()
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)
}

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.2.0"
implementation "androidx.lifecycle:lifecycle-extensions:$lifecycle_version"
kapt "androidx.lifecycle:lifecycle-compiler:$lifecycle_version"
dataBinding {
  enabled = true
}
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()
<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>
<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>
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
}
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 = ViewModelProvider(this).get(ViewCompanionViewModel::class.java)
  // 3
  viewCompanionViewModel.populateFromAnimal(animal)
  // 4
  fragmentViewCompanionBinding.viewCompanionViewModel =
    viewCompanionViewModel
  // 5
  return fragmentViewCompanionBinding.root
}

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.

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

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

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
}

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

@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)))
}
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.'
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
}
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
  }
}

Refactoring SearchForCompanionFragment

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

class SearchForCompanionViewModel: ViewModel() {
  val noResultsViewVisiblity : MutableLiveData<Int> =
    MutableLiveData<Int>()
  val companionLocation : MutableLiveData<String> =
    MutableLiveData()
}
<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>
android:text="@={searchForCompanionViewModel.companionLocation}"
android:visibility="invisible"
android:visibility="@{searchForCompanionViewModel.noResultsViewVisiblity}"
<?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>
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 = ViewModelProvider(this)
      .get(SearchForCompanionViewModel::class.java)    
  fragmentSearchForCompanionBinding.searchForCompanionViewModel
    = searchForCompanionViewModel
  fragmentSearchForCompanionBinding.lifecycleOwner = this
  return fragmentSearchForCompanionBinding.root
}
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))
    }
  }
}

// 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 searchForPetResponse = petFinderService.getAnimals(
      accessToken,
      location = companionLocation.value
    )

    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))
  }
}
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(viewLifecycleOwner, {
    companionAdapter.animals = it ?: arrayListOf()
    companionAdapter.notifyDataSetChanged()
  })
}
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)
}

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

// Koin
implementation 'org.koin:koin-android-viewmodel:2.0.1'
androidTestImplementation 'org.koin:koin-test:2.0.1'
val apiKey = "replace with your API key"
val apiSecret = "replace with your API secret"
val API_KEY = "your api ket"
val API_SECRET = "your api secret"
val DEFAULT_PETFINDER_URL = "http://api.petfinder.com/v2/"
  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/"
  }
// remove these!!
intent.getStringExtra(PETFINDER_KEY)?.let {
  apiKey = it
}
var token: Token = Token()
// 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.close()  
            mainResponse = chain.proceed(builder.build())
          }
        }
    }

    return mainResponse
  }

}
.addInterceptor(AuthorizationInterceptor(this))
.addInterceptor(AuthorizationInterceptor())
class SearchForCompanionViewModel(
  val petFinderService: PetFinderService
): ViewModel() {
lateinit var petFinderService: PetFinderService
const val PETFINDER_URL = "PETFINDER_URL"

val urlsModule = module {
  single(named(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<String>(named(PETFINDER_URL)))
      .addConverterFactory(GsonConverterFactory.create())
      .client(client)
      .build().create(PetFinderService::class.java)
  }

  viewModel { ViewCompanionViewModel() }
  viewModel { SearchForCompanionViewModel(get()) }
}
class CodingCompanionFinder: Application() {
  override fun onCreate() {
    super.onCreate()
    startKoin {
      androidContext(this@CodingCompanionFinder)
      modules(listOf(appModule, urlsModule))
    }
  }
}
<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">
  .
  .
  .
private lateinit var searchForCompanionViewModel:
  SearchForCompanionViewModel
private val searchForCompanionViewModel:
  SearchForCompanionViewModel by viewModel()
searchForCompanionViewModel =
  ViewModelProviders.of(this)
    .get(SearchForCompanionViewModel::class.java)
searchForCompanionViewModel.petFinderService =
  (activity as MainActivity).petFinderService!!

class FindCompanionInstrumentedTest : KoinTest {
  private fun loadKoinTestModules() {
    loadKoinModules(listOf(module(override = true) {
      single(named(PETFINDER_URL)){server.url("").toString()}
    }))
  }
@Before
fun beforeTestsRun() {
  testScenario = ActivityScenario.launch(startIntent)
// Insert it here!!  
  loadKoinTestModules()
  EventBus.getDefault().register(this)
  IdlingRegistry.getInstance().register(idlingResource)
}

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.

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

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

Unlock now