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
text.You can unlock the rest of this book, and our entire catalogue of books and videos, with a raywenderlich.com Professional subscription.
In the last chapter, you had a chance to:
- Get familiar with the Coding Companion app.
- Add tests around the search functionality.
- 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:
- It starts the app’s main activity, which takes the user to the Random Companion screen; this screen is backed by
RandomCompanionFragment
.
- 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
.
- Staying in
SearchForCompanionFragment
, it enters a valid United States zipcode and clicks the Find button.
- Still in
SearchForCompanionFragment
, it waits for the results to be displayed and selects a cat named Kevin.
- 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. Theverify_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.1.0-rc01"
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.0.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 = ViewModelProviders.of(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 = ViewModelProviders.of(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 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))
}
}
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()
})
}
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:1.0.1'
androidTestImplementation 'org.koin:koin-test:1.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 = 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(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()) }
}
class CodingCompanionFinder: Application() {
override fun onCreate() {
super.onCreate()
startKoin(this, 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(module(override = true) {
single(name = PETFINDER_URL){server.url("").toString()}
}, appModule)
}
@Before
fun beforeTestsRun() {
testScenario = ActivityScenario.launch(startIntent)
// Insert them here!!
stopKoin()
loadKoinTestModules()
EventBus.getDefault().register(this)
IdlingRegistry.getInstance().register(idlingResource)
}
@After
fun afterTestsRun() {
// eventbus and idling resources unregister.
IdlingRegistry.getInstance().unregister(idlingResource)
EventBus.getDefault().unregister(this)
stopKoin()
testScenario.close()
}
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.