Chapters

Hide chapters

Android Test-Driven Development by Tutorials

First Edition · Android 10 · Kotlin 1.3 · AS 3.5

Before You Begin

Section 0: 3 chapters
Show chapters Hide chapters

Section II: Testing on a New Project

Section 2: 8 chapters
Show chapters Hide chapters

Section III: TDD on Legacy Projects

Section 3: 9 chapters
Show chapters Hide chapters

15. Refactoring Your Tests
Written by Lance Gleason

Heads up... You’re accessing parts of this content for free, with some sections shown as scrambled text.

Heads up... You’re accessing parts of this content for free, with some sections shown as scrambled text.

Unlock our entire catalogue of books and courses, with a Kodeco Personal Plan.

Unlock now

Sometimes you need to slow down to move fast. In development, that means taking the time to write and refactor your tests so that you can go fast with your testing. Right now your app is still fairly small, but the shelters have big plans for it. There are lots of homeless companions and pairless developers that need to be matched up! In the last chapter you started with end-to-end UI tests, added some missing coverage, and then refactored your code to made it easier to go fast.

End-to-end tests usually run in a simulator or on a device. Because of that, they take longer to build, deploy, and run. In Chapter 4, “The Testing Pyramid,” you learned about how you should aim to have a pyramid of tests, with your unit tests being the most numerous, followed by your integration tests, and finally your end-to-end tests. Right now you have an inverted pyramid where all of your tests are end-to-end.

As your app gets larger, this will slow down your development velocity because a number of things happen, including:

  • Your Espresso tests will take longer and longer for the test suite to run.
  • Tests that exercise one part of the app will often be exercising other parts of the app as well. A change to these other parts can (and will) break many tests that should not be related to what you are testing.

In this chapter you’re going to break down your tests into integration and unit-level. Along the way you will learn some tricks for mocking things out, breaking things down, and even sharing tests between Espresso and Robolectric. A lot of people are counting on you, so let’s get started!

Note: In a normal development setting, it may be considered premature optimization to refactor an app the size of your Coding Companion Finder until it gets larger. That is a trade-off we needed to make with this book. That said, there is an art to knowing when to break things down. When you are new to TDD, it is easy to slip into a rut of not testing enough and not breaking down your tests soon enough. This is because testing is hard and it is easy to say it is not worth the effort.

Until you get some experience with TDD, it is better to err on the side of over-testing and over-optimization. As you get more familiar with the tools and techniques you will be in a better place to make that determination. That said, there will always be gray areas that experienced TDDers will disagree on.

Source sets, Nitrogen and sharedTest

With androidx.test, Robolectric 4.0 and Project Nitrogen, which can be found here (https://medium.com/androiddevelopers/write-once-run-everywhere-tests-on-android-88adb2ba20c5), you have the ability to write tests in Espresso and run them in either Robolectric on the JVM or in an emulator/real device. One common use case is to run integration and some end to end tests using the faster Robolectric while working on your local machine. Then running the same tests using slower, but closer to real life, Espresso during less frequent Continuous Integration cycles to find potential issues on specific versions of Android.

Up to this point with your refactoring, you have been focusing on running your tests in Espresso and putting them in androidTest. This is how an Android project is configured out of the box. If you want to run the same test in Robolectric you would need to move that test to the test source set or create a new test.

This limitation negates that benefit of being able to run the same test in Espresso and Robolectric (other than the shared syntax). This is a shortcoming with the current default Android project setup. Luckily, there is a way to get around this by using a shared source set.

To get started, open the starter project for this chapter or your final project from the last one. Go to the app ‣ src directory. You will see three directories there. androidTest, main and test. Delete test, and rename androidTest to be sharedTest.

Next, open your app level build.gradle and add the following under your android section:

android {
  sourceSets {
    String sharedTestDir = 'src/sharedTest/java'
    String sharedResources = 'src/sharedTest/assets'
    test {
      java.srcDir sharedTestDir
      resources.srcDirs += sharedResources
    }
    androidTest {
      java.srcDir sharedTestDir
      resources.srcDirs += sharedResources
    }
  }
}

This is creating a new source set that maps both your test and androidTest to your sharedTest directory. It is also nesting an Android directive under an Android directive so yours should look like this:

android {
  .
  .
  .
  android {
    sourceSets {
      .
      .
      .
    }
  }
  .
  .
  .
}

Note: This may look familiar from the sharedTest set up you did in Chapter 11, “User Interface.”

Now, in your main androidTest com.raywenderlich.codingcompanionfinder package open CommonTestDataUtil.kt. In the first line of your readFile function get rid of the /assets in this line:

val inputStream = this::class.java
  .getResourceAsStream("/assets/$jsonFileName")

so that it looks like this:

val inputStream = this::class.java
  .getResourceAsStream("/$jsonFileName")

Run your tests in Espresso (you might need to sync Gradle first) and they will be green.

Note: If you find some of the tests are failing, check that MainActivity.accessToken is set to your token you retrieved in Chapter 13.

Now that you have your tests moved to a sharedTest source set, there are a few things you need to do in order to get them working with Robolectric.

First, open your app level build.gradle and add the following to the dependencies section:

testImplementation 'androidx.test:runner:1.2.0'
testImplementation 'androidx.test.espresso:espresso-core:3.2.0'
testImplementation "androidx.test:rules:1.2.0"
testImplementation "androidx.test.ext:junit:1.1.1"
testImplementation "android.arch.navigation:navigation-testing:1.0.0-alpha08"
testImplementation 'com.squareup.okhttp3:mockwebserver:3.12.0'
testImplementation "androidx.test.espresso:espresso-contrib:3.2.0"
testImplementation 'org.koin:koin-test:1.0.1'
testImplementation 'org.robolectric:robolectric:4.3'

This is adding all of the dependencies that you had for your Espresso tests at the unit level. It is also including the Robolectric dependencies that you will need. Next, add the following to the top level android section of the same file:

testOptions {
  unitTests.includeAndroidResources = true
  unitTests.returnDefaultValues = true
}

These are telling Robolectric to include Android resources. Because Robolectric is not an actual emulator or device, many Android system calls do not actually do anything. The unitTests.returnDefaultValues makes them return a dummy default value in those instances, instead of throwing an exception.

Now, go to your app component drop-down at the top of your IDE and select Edit Configurations.

Select the + button.

Then, Android Junit.

You will be taken to a screen with a fresh configuration.

Under Use classpath or module select your app module.

Then under Test kind select Class.

Now, under the class select the ellipsis . The following window will pop up:

Select FindCompanionInstrumentedTest and press OK. Finally, it will take you to the previous screen. Press OK on that to continue.

Your new test configuration will be highlighted. Go ahead and run it.

Oh no! Something is not right. If you look at the error messages you will see the following (you may need to scroll down beyond the first couple of errors):

Looking at your code, your ActivityScenario.launch is being called from here with an Intent that is being passed in:

@Before
fun beforeTestsRun() {
  testScenario = ActivityScenario.launch(startIntent)

That Intent is set up in your companion object:

@BeforeClass
@JvmStatic
fun setup() {
  server.setDispatcher(dispatcher)
  server.start()
// It is being set right here!
  startIntent = Intent(
    ApplicationProvider.getApplicationContext(),
    MainActivity::class.java)
  startIntent.putExtra(MainActivity.PETFINDER_URI,
    server.url("").toString())
}

When running Robolectric this doesn’t get called before the @Before setup function. More importantly, this Intent was initially set up to pass in your mockwebserver URL when running your tests. In the last chapter you refactored things so that this is not needed anymore, so let’s get rid of it.

To do that, get rid of the last two lines in that function so that it looks like this:

@BeforeClass
@JvmStatic
fun setup() {
  server.setDispatcher(dispatcher)
  server.start()
}

Then, change the call on the first line of beforeTestRun from:

@Before
fun beforeTestsRun() {
  testScenario = ActivityScenario.launch(startIntent)

To:

@Before
fun beforeTestsRun() {
  testScenario =
    ActivityScenario.launch(MainActivity::class.java)

Now run your tests again.

Things are looking better but you still have some failing tests (or perhaps not!).

Note: depending on the speed of your machine or resources, you may end up with none, two, or three failing tests. But even if they all pass for you, there’s something wrong here that you should fix.

These are failing with the same error message. At this point, before reading further, a good exercise is to trace through things to see if you can figure out what is going wrong here.

If you trace through this you will see that there are two tests that fail when they try to click on an element with text that contains KEVIN, which is the last line of the following function:

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

It would appear that data from your mockWebServer is not being loaded. The odd thing is that if you look at this test…

@Test
fun searching_for_a_companion_in_30318_returns_two_results() {
  onView(withId(R.id.searchForCompanionFragment))
    .perform(click())
  onView(withId(R.id.searchFieldText))
    .perform(typeText("30318"))
  onView(withId(R.id.searchButton)).perform(click())
  onView(withId(R.id.searchButton))
    .check(matches(isDisplayed()))
  onView(withText("Joy")).check(matches(isDisplayed()))
  onView(withText("Male")).check(matches(isDisplayed()))
  onView(withText("Shih Tzu")).check(matches(isDisplayed()))
  onView(withText("KEVIN")).check(matches(isDisplayed()))
  onView(withText("Female")).check(matches(isDisplayed()))
  onView(withText("Domestic Short Hair"))
    .check(matches(isDisplayed()))
}

It is able to load up the data and works correctly on some machines but fails on others. You may experience either of these scenarios. This is something that can cause a lot of frustration. Some tests are working correctly, other similar ones that should are not — despite the tests running correctly on Espresso. The problem has to do with how Robolectric handles threads. Unlike when you are running tests on an emulator or device, Robolectric shares a single thread for UI operations and test code.

More importantly, by default, operations run synchronously using this looper which means that many operations will not happen in the same order that they would occur on a live device. This has been an issue with Robolectric for a while, but luckily they’ve created a fix for it by adding a @LooperMode(LooperMode.Mode.PAUSED) annotation before your test class. Add it to the beginning of our test class so that it looks like following:

import org.robolectric.annotation.LooperMode

@RunWith(AndroidJUnit4::class)
@LooperMode(LooperMode.Mode.PAUSED)
class FindCompanionInstrumentedTest: KoinTest {

Now run your tests again and all of them will pass.

Note: Your can find out more about this PAUSED Robolectric LooperMode at http://robolectric.org/blog/2019/06/04/paused-looper/.

Testing fragments in isolation

Until now your tests have been large end-to-end UI tests. That said, some of your test cases are actually testing one component that could be tested in isolation. A good example of that is your ViewCompanionFragment. This fragment is called via your SearchForCompanionFragment. This happens after you have searched for a companion and select one to see more details.

androidTestImplementation "org.robolectric:annotations:4.3"
// Once https://issuetracker.google.com/127986458 is fixed this can be testImplementation
// fragmentscenario testing
debugImplementation 'androidx.fragment:fragment-testing:1.1.0-beta01'
debugImplementation "androidx.test:core:1.2.0"
@RunWith(AndroidJUnit4::class)
@LooperMode(LooperMode.Mode.PAUSED)
@Before
fun beforeTestsRun() {
// 1
  val animal = Animal(
    22,
    Contact(
      phone = "404-867-5309",
      email = "coding.companion@razware.com",
      address = Address(
        "",
        "",
        "Atlanta",
        "GA",
        "30303",
        "USA"
      )
    ),
    "5",
    "small",
    arrayListOf(),
    Breeds("shih tzu", "", false, false),
    "Spike",
    "male",
    "A sweet little guy with spikey teeth!"
  )
// 2
  val bundle = ViewCompanionFragmentArgs(animal).toBundle()
// 3
  launchFragmentInContainer<ViewCompanionFragment>(bundle,
    R.style.AppTheme)
}
private fun setupClickEvent(animal: Animal){
  view.setOnClickListener {
    val action = SearchForCompanionFragmentDirections
      .actionSearchForCompanionFragmentToViewCompanion(animal)
    view.findNavController().navigate(action)
  }
}
class SearchForCompanionFragmentDirections
  private constructor() {
// 2
  private data class
  ActionSearchForCompanionFragmentToViewCompanion(
    val animal: Animal
  ) : NavDirections {
    override fun getActionId(): Int =
      R.id.action_searchForCompanionFragment_to_viewCompanion

// 3
    @Suppress("CAST_NEVER_SUCCEEDS")
    override fun getArguments(): Bundle {
      val result = Bundle()
      if (Parcelable::class.java
          .isAssignableFrom(Animal::class.java)) {
          result.putParcelable("animal",
            this.animal as Parcelable)
      } else if (Serializable::class.java
          .isAssignableFrom(Animal::class.java)) {
          result.putSerializable("animal",
            this.animal as Serializable)
      } else {
          throw UnsupportedOperationException(
            Animal::class.java.name +
            " must implement Parcelable or Serializable or" +
            " must be an Enum.")
      }
      return result
    }
  }

  companion object {
// 1    
    fun actionSearchForCompanionFragmentToViewCompanion(
      animal: Animal
    ): NavDirections =
        ActionSearchForCompanionFragmentToViewCompanion(animal)
  }
}
@Test
fun check_that_all_values_display_correctly() {
  onView(withText("Spike")).check(matches(isDisplayed()))
  onView(withText("Atlanta, GA")).check(matches(isDisplayed()))
  onView(withText("shih tzu")).check(matches(isDisplayed()))
  onView(withText("5")).check(matches(isDisplayed()))
  onView(withText("male")).check(matches(isDisplayed()))
  onView(withText("small")).check(matches(isDisplayed()))
  onView(withText("A sweet little guy with spikey teeth!"))
    .check(matches(isDisplayed()))
  onView(withText("404-867-5309")).check(matches(isDisplayed()))
  onView(withText("coding.companion@razware.com"))
    .check(matches(isDisplayed()))
}

@RunWith(AndroidJUnit4::class)
@LooperMode(LooperMode.Mode.PAUSED)
class SearchForCompanionTest : KoinTest {

  private val idlingResource = SimpleIdlingResource()
}
companion object {
  val server = MockWebServer()
  val dispatcher: Dispatcher = object : Dispatcher() {
    @Throws(InterruptedException::class)
    override fun dispatch(
      request: RecordedRequest
    ): MockResponse {
      return CommonTestDataUtil.dispatch(request) ?:
        MockResponse().setResponseCode(404)
    }
  }

  @BeforeClass
  @JvmStatic
  fun setup() {
    server.setDispatcher(dispatcher)
    server.start()
  }
}

private fun loadKoinTestModules(serverUrl: String) {
  loadKoinModules(module(override = true) {
    single<String>(name = PETFINDER_URL) { serverUrl }
  }, appModule)
}

@Subscribe
fun onEvent(idlingEntity: IdlingEntity) {
  idlingResource.incrementBy(idlingEntity.incrementValue)
}
@Before
fun beforeTestsRun() {
  launchFragmentInContainer<SearchForCompanionFragment>(
    themeResId = R.style.AppTheme,
    factory = object : FragmentFactory() {
      override fun instantiate(
        classLoader: ClassLoader,
        className: String
      ): Fragment {
        stopKoin()
        GlobalScope.async {
          val serverUrl = server.url("").toString()
          loadKoinTestModules(serverUrl)
        }.start()

        return super.instantiate(classLoader, className)
      }
  })
  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()
}
@Test
fun pressing_the_find_bottom_menu_item_takes_the_user_to_the_find_page() {
  onView(withId(R.id.searchButton))
    .check(matches(isDisplayed()))
  onView(withId(R.id.searchFieldText))
    .check(matches(isDisplayed()))
  onView(withId(R.id.searchFieldText))
    .perform(typeText("30318"))
  onView(withId(R.id.searchButton)).perform(click())
}

searchForCompanionViewModel.accessToken = (activity as
  MainActivity).accessToken
lateinit var accessToken: String
var accessToken: String = ""

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

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

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

Robolectric and IdlingResource limitations

In theory, you should be able to run any Espresso tests on Robolectric and have them run. The Google testing code lab at https://codelabs.developers.google.com/codelabs/android-testing/#10 suggests this as does this talk at Google I/O 2019 https://www.youtube.com/watch?v=VJi2vmaQe6w&feature=youtu.be. The reality is a lot more nuanced.

// 1
val urlsModule = module {
  single(name = PETFINDER_URL) {
    MainActivity.DEFAULT_PETFINDER_URL
  }
}
val appModule = module {
// 2
  single<PetFinderService> {
    val logger = HttpLoggingInterceptor()
    logger.level = HttpLoggingInterceptor.Level.BODY
    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() }
  // 3
  viewModel { SearchForCompanionViewModel(get()) }
}

private fun loadKoinTestModules(serverUrl: String) {
  loadKoinModules(module(override = true) {
    single<String>(name = PETFINDER_URL) { serverUrl }
  }, appModule)
}
private fun loadKoinTestModules(serverUrl: String) {
  loadKoinModules(module(override = true) {
    single<PetFinderService> {
// 1
      val petFinderService =
        Mockito.mock(PetFinderService::class.java)
// 2      
      Mockito.`when`(
        petFinderService.getAnimals(
          ArgumentMatchers.anyString(),
          ArgumentMatchers.anyInt(),
          ArgumentMatchers.contains("30318")
        )
// 3        
      ).thenReturn(GlobalScope.async {
        getMockResponseWithResults()
      })
      petFinderService
    }
    viewModel { ViewCompanionViewModel() }
    viewModel { SearchForCompanionViewModel(get()) }
  })

}
private fun getMockResponseWithResults(): Response<AnimalResult> {
  val gson = Gson()
  val animalResult =
    gson.fromJson<AnimalResult>(readFile("search_30318.json"),
      AnimalResult::class.java)
  val responseMock =
    Mockito.mock(Response::class.java) as Response<AnimalResult>
  Mockito.`when`(responseMock.isSuccessful).thenReturn(true)
  Mockito.`when`(responseMock.body()).thenReturn(animalResult)

  return responseMock
}

Mocking final classes with Espresso

Mockito has a limitation when running on an Android device or emulator that prevents it from being able to mock classes that are final. When the error above happens, though the message is not very descriptive, it can mean that you are trying to mock a final class. In the function you defined above you are mocking the Response class. Trace through to its definition and you will see the following:

/** An HTTP response. */
public final class Response<T> {
private fun getMockResponseWithResults(): Response<AnimalResult> {
  val gson = Gson()
  val animalResult =
    gson.fromJson<AnimalResult>(readFile("search_30318.json"),
      AnimalResult::class.java)
  return Response.success(animalResult)
}

androidTestImplementation 'org.koin:koin-test:1.0.1'
androidTestImplementation("org.koin:koin-test:1.0.1")
  { exclude(group: "org.mockito") }
androidTestImplementation "org.mockito:mockito-android:2.28.2"

private fun getMockResponseWithNoResults(): Response<AnimalResult> {
  val gson = Gson()
  val animalResult =
    gson.fromJson<AnimalResult>("{\"animals\": []}",
      AnimalResult::class.java)
  return Response.success(animalResult)
}

private fun getMockResponseFailed(): Response<AnimalResult> {
  val gson = Gson()
  return Response.error(401,
    Mockito.mock(ResponseBody::class.java))
}
private fun loadKoinTestModules(serverUrl: String) {
  loadKoinModules(module(override = true) {
    single<PetFinderService> {
      val petFinderService =
        Mockito.mock(PetFinderService::class.java)
      Mockito.`when`(
        petFinderService.getAnimals(
          ArgumentMatchers.anyString(),
          ArgumentMatchers.anyInt(),
          ArgumentMatchers.contains("30318")
        )
      ).thenReturn(GlobalScope.async {
        getMockResponseWithResults()
      })
// 1      
      Mockito.`when`(
        petFinderService.getAnimals(
          ArgumentMatchers.anyString(),
          ArgumentMatchers.anyInt(),
          ArgumentMatchers.contains("90210")
        )
      ).thenReturn(GlobalScope.async {
        getMockResponseWithNoResults()
      })
// 2      
      Mockito.`when`(
        petFinderService.getAnimals(
          ArgumentMatchers.anyString(),
          ArgumentMatchers.anyInt(),
          ArgumentMatchers.contains("dddd")
        )
      ).thenReturn(GlobalScope.async {
        getMockResponseFailed()
      })
      petFinderService
    }
    viewModel { ViewCompanionViewModel() }
    viewModel { SearchForCompanionViewModel(get()) }
  })
}

Breaking out unit tests

Up to this point your tests have had dependencies on Android. But, as we discussed in Chapter 4, “The Testing Pyramid,” you should strive to have unit tests. Ideally, you will have more unit tests than integration tests and more integration tests than end-to-end/UI tests.

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

    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
    }
}
class ViewCompanionViewModelTest {
// 1
  val animal = Animal(
    22,
    Contact(
      phone = "404-867-5309",
      email = "coding.companion@razware.com",
      address = Address(
        "",
        "",
        "Atlanta",
        "GA",
        "30303",
        "USA"
      ) ),
    "5",
    "small",
    arrayListOf(),
    Breeds("shih tzu", "", false, false),
    "Spike",
    "male",
    "A sweet little guy with spikey teeth!"
  )
//2
  @Test
  fun populateFromAnimal_sets_the_animals_name_to_the_view_model(){
    val viewCompanionViewModel = ViewCompanionViewModel()
    viewCompanionViewModel.populateFromAnimal(animal)
// 3    
    assert(viewCompanionViewModel.name.equals("foo"))
  }
}

    assert(viewCompanionViewModel.name.equals("Spike"))

Unit testing Retrofit calls

Now that you have your ViewCompanionViewModel under test, let’s do the same for your SearchForCompanionViewModel. To get started, create a new Kotlin file in your test package called SearchForCompanionViewModelTest.kt and add the following content to it:

class SearchForCompanionViewModelTest {

}
class SearchForCompanionViewModel(
  val petFinderService: PetFinderService
): ViewModel() {
// 1
  val noResultsViewVisiblity : MutableLiveData<Int> =
    MutableLiveData<Int>()
// 2  
  val companionLocation : MutableLiveData<String> =
    MutableLiveData()
// 3
  val animals: MutableLiveData<ArrayList<Animal>> =
    MutableLiveData<ArrayList<Animal>>()
  var accessToken: String = ""
// 4
  fun searchForCompanions() {

    GlobalScope.launch {

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

      val searchForPetResponse = getAnimalsRequest.await()

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

}
// 1
val server = MockWebServer()

lateinit var petFinderService: PetFinderService
// 2
val dispatcher: Dispatcher = object : Dispatcher() {
  @Throws(InterruptedException::class)
  override fun dispatch(
    request: RecordedRequest
  ): MockResponse {
    return CommonTestDataUtil.dispatch(request) ?:
      MockResponse().setResponseCode(404)
  }
}

// 3
@Before
fun setup() {
  server.setDispatcher(dispatcher)
  server.start()
  val logger = HttpLoggingInterceptor()
  val client = OkHttpClient.Builder()
    .addInterceptor(logger)
    .connectTimeout(60L, TimeUnit.SECONDS)
    .readTimeout(60L, TimeUnit.SECONDS)
    .addInterceptor(AuthorizationInterceptor())
    .build()
  petFinderService = Retrofit.Builder()
    .baseUrl(server.url("").toString())
    .addConverterFactory(GsonConverterFactory.create())
    .addCallAdapterFactory(CoroutineCallAdapterFactory())
    .client(client)
    .build().create(PetFinderService::class.java)
}

// 4
@Test
fun call_to_searchForCompanions_gets_results() {
  val searchForCompanionViewModel = 
    SearchForCompanionViewModel(petFinderService)
  searchForCompanionViewModel.companionLocation.value = "30318"
  searchForCompanionViewModel.searchForCompanions()

  Assert.assertEquals(2,
    searchForCompanionViewModel.animals.value!!.size)
}

testImplementation "androidx.arch.core:core-testing:2.0.1"
androidTestImplementation "androidx.arch.core:core-testing:2.0.1"
@get:Rule
val instantTaskExecutorRule = InstantTaskExecutorRule()
import androidx.arch.core.executor.testing.InstantTaskExecutorRule

fun searchForCompanions() {

  GlobalScope.launch {
    .
    .
    val searchForPetResponse = getAnimalsRequest.await()  
    .
    .
    GlobalScope.launch(Dispatchers.Main) {
      .
      .
      .

    }
  }
}

def coroutinesVersion = "1.3.0-M2"

testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutinesVersion"
testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutinesVersion"

androidTestImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutinesVersion"
androidTestImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutinesVersion"
private val mainThreadSurrogate =
  newSingleThreadContext("Mocked UI thread")
Dispatchers.setMain(mainThreadSurrogate)

@Test
fun call_to_searchForCompanions_gets_results() {
  val searchForCompanionViewModel =
    SearchForCompanionViewModel(petFinderService)
  searchForCompanionViewModel.companionLocation.value = "30318"
// 1
  val countDownLatch = CountDownLatch(1)
  searchForCompanionViewModel.searchForCompanions()
// 2
  searchForCompanionViewModel.animals.observeForever {
    countDownLatch.countDown()
  }
// 3
  countDownLatch.await(2, TimeUnit.SECONDS)
  Assert.assertEquals(2,
    searchForCompanionViewModel.animals.value!!.size)
}

@Test
fun call_to_searchForCompanions_with_results_sets_the_visibility_of_no_results_to_INVISIBLE() {
  val searchForCompanionViewModel =
    SearchForCompanionViewModel(petFinderService)
  searchForCompanionViewModel.companionLocation.value = "30318"
  val countDownLatch = CountDownLatch(1)
  searchForCompanionViewModel.searchForCompanions()
  searchForCompanionViewModel.noResultsViewVisiblity
    .observeForever {
      countDownLatch.countDown()
    }

  countDownLatch.await(2, TimeUnit.SECONDS)
  Assert.assertEquals(INVISIBLE,
    searchForCompanionViewModel.noResultsViewVisiblity.value)
}
noResultsViewVisiblity.postValue(INVISIBLE)
noResultsViewVisiblity.postValue(VISIBLE)

noResultsViewVisiblity.postValue(INVISIBLE)

DRYing up your tests

Tests are code that you need to maintain, so let’s write some more tests for your SearchForCompanionViewModel and DRY (Do not repeat yourself) them up along the way. To get started, add the following test:

@Test
fun call_to_searchForCompanions_with_no_results_sets_the_visibility_of_no_results_to_VISIBLE() {
  val searchForCompanionViewModel =
    SearchForCompanionViewModel(petFinderService)
  searchForCompanionViewModel.companionLocation.value = "90210"
  val countDownLatch = CountDownLatch(1)
  searchForCompanionViewModel.searchForCompanions()
  searchForCompanionViewModel.noResultsViewVisiblity
    .observeForever {
      countDownLatch.countDown()
    }

  countDownLatch.await(2, TimeUnit.SECONDS)
  Assert.assertEquals(INVISIBLE,
    searchForCompanionViewModel.noResultsViewVisiblity.value)
}

Assert.assertEquals(VISIBLE,
  searchForCompanionViewModel.noResultsViewVisiblity.value)

@Test
fun call_to_searchForCompanions_with_results_sets_the_visibility_of_no_results_to_INVISIBLE() {
  val searchForCompanionViewModel =
    SearchForCompanionViewModel(petFinderService)
  searchForCompanionViewModel.companionLocation.value = "30318"
  val countDownLatch = CountDownLatch(1)
  searchForCompanionViewModel.searchForCompanions()
  searchForCompanionViewModel.noResultsViewVisiblity
    .observeForever {
      countDownLatch.countDown()
    }

  countDownLatch.await(2, TimeUnit.SECONDS)
  Assert.assertEquals(INVISIBLE,
    searchForCompanionViewModel.noResultsViewVisiblity.value)
}

@Test
fun call_to_searchForCompanions_with_no_results_sets_the_visibility_of_no_results_to_VISIBLE() {
  val searchForCompanionViewModel =
    SearchForCompanionViewModel(petFinderService)
  searchForCompanionViewModel.companionLocation.value = "90210"
  val countDownLatch = CountDownLatch(1)
  searchForCompanionViewModel.searchForCompanions()
  searchForCompanionViewModel.noResultsViewVisiblity
    .observeForever {
      countDownLatch.countDown()
    }

  countDownLatch.await(2, TimeUnit.SECONDS)
  Assert.assertEquals(VISIBLE,
    searchForCompanionViewModel.noResultsViewVisiblity.value)
}
fun callSearchForCompanionWithALocationAndWaitForVisibilityResult(location: String): SearchForCompanionViewModel{
  val searchForCompanionViewModel =
    SearchForCompanionViewModel(petFinderService)
  searchForCompanionViewModel.companionLocation.value = location
  val countDownLatch = CountDownLatch(1)
  searchForCompanionViewModel.searchForCompanions()
  searchForCompanionViewModel.noResultsViewVisiblity
    .observeForever {
      countDownLatch.countDown()
    }

  countDownLatch.await(2, TimeUnit.SECONDS)
  return searchForCompanionViewModel
}

@Test
fun call_to_searchForCompanions_with_results_sets_the_visibility_of_no_results_to_INVISIBLE() {
  val searchForCompanionViewModel = callSearchForCompanionWithALocationAndWaitForVisibilityResult("30318")
  Assert.assertEquals(INVISIBLE,
    searchForCompanionViewModel.noResultsViewVisiblity.value)
}

@Test
fun call_to_searchForCompanions_with_no_results_sets_the_visibility_of_no_results_to_VISIBLE() {
  val searchForCompanionViewModel = callSearchForCompanionWithALocationAndWaitForVisibilityResult("90210")
  Assert.assertEquals(VISIBLE,
    searchForCompanionViewModel.noResultsViewVisiblity.value)
}

Challenge

Challenge: Test and edge cases

  • If you didn’t finish out your test cases for your ViewCompanionViewModel to test the other data elements, add tests following a red, green, refactor pattern.
  • The tests you did for your SearchForCompanionViewModel missed a lot of data validation and edge cases. Follow a red, green, refactor pattern and try to cover all of these cases with very focused assertions.

Key points

  • Source sets help you to run Espresso tests in either Espresso or Robolectric.
  • Not all Espresso tests will run in Robolectric, especially if you are using Idling resources.
  • As you get your legacy app under test, start to isolate tests around Fragments and other components.
  • ViewModels make it possible to move tests to a unit level.
  • Be mindful of mocking final classes.
  • It is possible to unit test Retrofit with MockWebServer.
  • Strive to practice Red, Green, Refactor.
  • As your tests get smaller, the number of assertions in each test should as well.
  • Strive towards a balanced pyramid, but balance that against the value that your tests are bringing to the project.
  • Test code is code to maintain, so don’t forget to refactor it as well.
  • Move slow to go fast.

Where to go from here?

With this refactoring you have set your project up to go fast. It will help many homeless companions, and companion-less developers get paired up. That said, there are other tips and tricks to learn in future chapters. For example, how do you deal with test data as your suite gets bigger? How do you handle permissions? Stay tuned as we cover this in later chapters!

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 accessing parts of this content for free, with some sections shown as scrambled text. Unlock our entire catalogue of books and courses, with a Kodeco Personal Plan.

Unlock now