-
Chapter14: Hands-On Focused Refactoring카테고리 없음 2023. 4. 26. 17:56반응형
Android Test - Driven Development by Tutorial
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.
App flow
FindCompanionInstrumentedTest#searching_for_a_companion_and_tapping_on_it_takes_the_user_to_the_companion_details
1. It starts the app’s main activity, which takes the user to the Random Companion screen.
2. 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.
3. Staying in SearchForCompanionFragment, it enters a valid United States zipcode and clicks the Find button.
4. Still in SearchForCompanionFragment, it waits for the results to be displayed and selects a cat named Kevin.
5. It then waits for the app to navigate to the Companion Details screen and validates the city/state in which 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. the selected companion is located. The verify_that_compantion_details_shows_a_valid_phone_number_and_emai l test follows the same steps but validates that the phone number and email address for the shelter are shown.
ViewCompanionFragment is the simplest of the three fragments. Therefore, you’ll start by refactoring this test.
Refactoring ViewCompanionFragment
Adding supplemental coverage before refactoring
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:
// FindCompanionsInstrumentedTest.kt @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())) // Adds new codes to test detail views 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 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())) }
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.
The screen only displays “Domestic Short Hair” once. So, what’s happening here?
Click on R.id.viewCompanion to find it in your view, and you’ll see that it is 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. 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.
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.
in activity_main.xml:
<androidx.fragment.app.FragmentContainerView android:id="@+id/mainPetfinderFragment" android:name="androidx.navigation.fragment.NavHostFragment" android:layout_width="match_parent" android:layout_height="0dp" app:defaultNavHost="true" app:layout_constraintBottom_toTopOf="@id/bottomNavigation" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" app:navGraph="@navigation/nav_graph" />
Open nav_graph.xml inside res ‣ navigation and add the following inside the <navigation> element at the bottom:
<fragment android:id="@+id/viewCompanion" android:name="com.raywenderlich.codingcompanionfinder.searompanion.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" > <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:
To pass the arguments with Jetpack Navigation, you’ll use Safe Args.
// AS-IS 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-BE private fun setupClickEvent(animal: Animal) { view.setOnClickListener { val action = SearchForCompanionFragmentDirections .actionSearchForCompanionFragmentToViewCompanion(animal) view.findNavController().navigate(action) } } // build.gradle classpath "androidx.navigation:navigation-safe-args-gradle- plugin:2.3.5" // app/build.gradle apply plugin: "androidx.navigation.safeargs.kotlin"
Then replace onCreateView in ViewCompanionFragment 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) }
Finally, execute the searching_for_a_companion_and_tapping_on_it_takes_the_user_to_the_com panion_details test in FindCompanionInstrumentedTest.kt, and it’ll be green. Now that you have proper test coverage around ViewCompanionFragment, it’s time to refactor it.
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 }
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()
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>
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" /> ...
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 addthe following method to the ViewCompanionViewModel class:
// ViewCompanionViewModel.kt 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 }
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. It inflates the view via a data-binding-generated FragmentViewCompanionBinding object. val fragmentViewCompanionBinding = FragmentViewCompanionBinding.inflate(inflater, container, false) // 2. Creates an instance of ViewCompanionViewModel via the ViewModelProviders. val viewCompanionViewModel = ViewModelProvider(this).get(ViewCompanionViewModel::class.java) // 3. Populates the view model from an Animal. viewCompanionViewModel.populateFromAnimal(animal) // 4. Assigns the view model to your view. fragmentViewCompanionBinding.viewCompanionViewModel = viewCompanionViewModel // 5. Returns the root of the view. return fragmentViewCompanionBinding.root }
Run your test now, and it’ll be green.
Refactoring SearchForCompanionFragment
Adding supplemental coverage before refactoring
Your SearchForCompanionFragment has more going on, so it’s time to refactor that next.
// FindCompanionInstrumentedTest.kt @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())) }
Finally, run the test, and everything will be green.
Adding test coverage
There are two scenarios for which you need to add coverage:
- When the user enters a valid location, but there are no results.
- When the user enters an invalid location.
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("{}") } } }
Let's check 1. a valid location with no result:
@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")) // valid but no results 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))) }
And 2. enter an invalid location.
@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")) // an invalid zipcode 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 No.2 test, 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.kt, 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 }
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.
Focused 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.
<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:
// AS-IS <com.google.android.material.textfield.TextInputEditText android:id="@+id/searchFieldText" android:layout_width="match_parent" android:layout_height="wrap_content" android:hint="Enter US Location" android:textColor="@color/primaryTextColor" /> </com.google.android.material.textfield.TextInputLayout> // TO-BE android:text="@={searchForCompanionViewModel.companionLocation}" // AS-IS <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="invisible" ... /> // TO-BE android:visibility="@{searchForCompanionViewModel.noResultsViewVisibility}"
With that done, go back to SearchForCompanionFragment.kt, add the following two properties 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 = ViewModelProvider(this).get(SearchForCompanionViewModel::class.java) fragmentSearchForCompanionBinding.searchForCompanionViewModel = searchForCompanionViewModel fragmentSearchForCompanionBinding.lifecycleOwner = this return fragmentSearchForCompanionBinding.root }
Locate searchForCompanions() and replace it with the following:
private fun searchForCompanions() { val companionLocation = view?.findViewById<TextInputEditText>(R.id.searchFieldText)?.text.toString() .... val searchForPetResponse = petFinderService.getAnimals(accessToken, location = companionLocation) ... GlobalScope.launch(Dispatchers.Main) { if (searchForPetResponse.isSuccessful) { searchForPetResponse.body()?.let { if (it.animals.size > 0) { noResultsTextView?.visibility = INVISIBLE ... } else { noResultsTextView?.visibility = VISIBLE } } } else { noResultsTextView?.visibility = VISIBLE } } ... } // TO-BE private fun searchForCompanions() { val getAnimalsRequest = petFinderService.getAnimals( accessToken, location = searchForCompanionViewModel.companionLocation.value ) GlobalScope.launch(Dispatchers.Main) { if (searchForPetResponse.isSuccessful) { searchForPetResponse.body()?.let { if (it.animals.size > 0) { searchForCompanionViewModel .noResultsViewVisiblity .postValue(INVISIBLE) ... } else { searchForCompanionViewModel .noResultsViewVisiblity .postValue(VISIBLE) } } } else { searchForCompanionViewModel .noResultsViewVisiblity .postValue(VISIBLE) } } ... }
Run the tests in FindCompanionsInstrumentedTest.kt, and they’ll all still be green. Great refactor!
KeyPoints
- 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.
- Keep your tests green.
- Move slow to go fast.
반응형