Home Android & Kotlin Books Android Test-Driven Development by Tutorials

15
Refactoring Your Tests Written by Lance Gleason

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

You can unlock the rest of this book, and our entire catalogue of books and videos, with a raywenderlich.com Professional subscription.

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. There will always be gray areas that experienced TDDers will disagree on.

Source sets, UTP and sharedTest

With androidx.test, Robolectric 4.0 and the Unified Test Platform (UTP), which can be found here (https://www.youtube.com/watch?v=juEkViDyzF8), 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. Add a sharedTest directory, and copy all of the contents of androidTest to your new sharedTest directory, then delete the contents of androidTest ‣ assets and androidTest ‣ java ‣ com ‣ raywenderlich ‣ codingcompanionfinder. Note: you need to leave the directories in androidTest for Android Studio to be able to know that it has integration tests, even if all of them are currently in your sharedTest directory.

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 sharedTest ‣ java ‣ 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("/assets/$jsonFileName") ?:
  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.3.0'
testImplementation 'androidx.test.espresso:espresso-core:3.3.0'
testImplementation "androidx.test:rules:1.3.0"
testImplementation "androidx.test.ext:junit:1.1.2"
testImplementation "androidx.navigation:navigation-testing:2.3.5"
testImplementation 'com.squareup.okhttp3:mockwebserver:3.12.0'
testImplementation "androidx.test.espresso:espresso-contrib:3.3.0"
testImplementation 'org.robolectric:robolectric:4.5.1'
testImplementation 'org.koin:koin-test:2.0.1'
androidTestImplementation "org.robolectric:annotations:4.5.1"

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.

Arctic Fox, Bumble Bee and the New Test Runners

At the time of this writing. The current, released version of Android Studio is 4.2.1. In this version, along with older versions, the test runners for both unit and Espresso driven tests are different in Android Studio than they are when running the Gradle build from the command line. That can cause tests to behave slightly different when running in a CI environment. To reduce that uncertainty, Android Studio Arctic Fox migrated the built-in unit test runner to run through Gradle. Android Studio Bumble Bee moves the Espresso driven integration tests to use the same runner.

Running Your Source set in Android Studio 4.2.1 and Older

If you are using Android Studio Arctic Fox or newer you can skip ahead to the next section. Otherwise, go to your app component drop-down at the top of your IDE and select Edit Configurations.

Arctic Fox and Later

If you are using Arctic Fox or later, under your project side tab make sure that you have it in the Android view mode.

Your Running Tests

After following the steps for your version of Android Studio, your tests will run and you should see the following.

@Before
fun beforeTestsRun() {
  testScenario = ActivityScenario.launch(startIntent)
@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())
}
@BeforeClass
@JvmStatic
fun setup() {
  server.setDispatcher(dispatcher)
  server.start()
}
@Before
fun beforeTestsRun() {
  testScenario = ActivityScenario.launch(startIntent)
@Before
fun beforeTestsRun() {
  testScenario =
    ActivityScenario.launch(MainActivity::class.java)
private fun loadKoinTestModules() {
  loadKoinModules(module(override = true) {
    single(named(PETFINDER_URL)){server.url("").toString()}
  })
}
private fun loadKoinTestModules() {
  stopKoin()
  startKoin {  }
  loadKoinModules(listOf(module(override = true) {
    single(named(PETFINDER_URL)){server.url("").toString()}
  }, appModule))
}
class FindCompanionInstrumentedTest: KoinTest {
class FindCompanionInstrumentedTest: AutoCloseKoinTest() {

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()))
}
import org.robolectric.annotation.LooperMode

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

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.

// Once https://issuetracker.google.com/127986458 is fixed this can be testImplementation
// fragmentscenario testing
debugImplementation 'androidx.fragment:fragment-testing:1.3.4'
debugImplementation "androidx.test:core:1.3.0"
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.matcher.ViewMatchers.*
import androidx.test.espresso.assertion.ViewAssertions.matches

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

import org.robolectric.annotation.LooperMode
import org.junit.runner.RunWith
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.matcher.ViewMatchers.*
import androidx.test.espresso.assertion.ViewAssertions.matches
import org.junit.BeforeClass
import org.junit.Before
import org.junit.After
import org.junit.Test

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

  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(listOf(module(override = true) {
    single(named(PETFINDER_URL)) { serverUrl }
  }))
}

private fun loadKoinTestModulesTryCatch(serverUrl: String) {
  try {
    loadKoinTestModules(serverUrl)
  } catch (koinAlreadyStartedException: KoinAppAlreadyStartedException) {
    stopKoin()
    loadKoinTestModules(serverUrl)
    loadKoinModules(listOf(module(override = true) {
      single(named(PETFINDER_URL)) { serverUrl }
    }))
  } catch (illegalStateException: IllegalStateException){
    startKoin {
      modules(listOf(appModule))
    }
    loadKoinTestModules(serverUrl)
  }
}

@Subscribe
fun onEvent(idlingEntity: IdlingEntity) {
  idlingResource.incrementBy(idlingEntity.incrementValue)
}
@Before
fun beforeTestsRun() {
  val serverUrl = server.url("").toString()
  loadKoinTestModules(serverUrl)
  launchFragmentInContainer<SearchForCompanionFragment>(
    themeResId = R.style.AppTheme,
    factory = FragmentFactory())
  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())
}
override fun onCreate() {
  super.onCreate()
  try {
    startKoin {
      androidContext(this@CodingCompanionFinder)
      modules(listOf(appModule, urlsModule))
    }
  } catch (koinAlreadyStartedException: KoinAppAlreadyStartedException) {
    Log.i("CodingCompanionFinder", "KoinAppAlreadyStartedException, should only happen in tests")
  }
}

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

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.

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)
    .build()
  petFinderService = Retrofit.Builder()
    .baseUrl(server.url("").toString())
    .addConverterFactory(GsonConverterFactory.create())
    .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 {
    .
    .
    // getAnimals is a suspend function
    val searchForPetResponse = petFinderService.getAnimals(
          accessToken,
          location = companionLocation.value
    ) 
    .
    .
    GlobalScope.launch(Dispatchers.Main) {
      .
      .
      .

    }
  }
}

def coroutinesVersion = "1.5.0"

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.

Have feedback to share about the online reading experience? If you have feedback about the UI, UX, highlighting, or other features of our online readers, you can send them to the design team with the form below:

© 2021 Razeware LLC

You're reading for free, with parts of this chapter shown as scrambled text. Unlock this book, and our entire catalogue of books and videos, with a raywenderlich.com Professional subscription.

Unlock Now

To highlight or take notes, you’ll need to own this book in a subscription or purchased by itself.