ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 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 add

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

    1. When the user enters a valid location, but there are no results.
    2. 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.

     

    반응형
Designed by Tistory.