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

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.

When you refactored this fragment in the last chapter, you modified it so that all of the data it needs to display, contained in an Animal object, is passed into it via navigation parameters. That data is then passed into a ViewModel which binds these attributes to the view.

Your end-to-end test is currently testing all of this, but the shelters have a lot of changes that they are going to want to make to this page, along with the SearchForCompanionFragment and other fragments in your end-to-end test chain. This will begin to make your end-to-end tests fragile, so now is a good time to move this to a more focused test.

To get started, open up your app level build.gradle and add the following to your dependencies:

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"

This is adding the AndroidX fragment testing dependencies.

Note: At the time of this writing there is a known issue with this module. This means that you need to include it as part of your implementation. To prevent this from going to production you are limiting this to a debug build.

It is also adding the Robolectric annotations to androidTestImplementation to ensure that your Looper annotation does not cause compile issues with the new test you are about to add.

Now, go to your androidTest folder and under the com.raywenderlich.codingcompanionfinder package add a class called ViewCompanionTest. Above the class add the following:

@RunWith(AndroidJUnit4::class)
@LooperMode(LooperMode.Mode.PAUSED)

This is telling it to run with AndroidJUnit4 and sets the LooperMode for the Robolectric runs. Inside of your class add the following:

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

This is doing the following:

  1. Creating a test Animal object.
  2. Creating a Bundle with your animal object.
  3. Launching your fragment with the bundle that you just created.

Two and three might seem like a bit of magic, so let’s break that down. The SafeArgs that are used to pass arguments to your fragment through the Jetpack Navigation components are doing some things under the hood for you (see the previous chapter for some description on SafeArgs). In your CompanionViewHolder, you have the following setupClickEvent method:

private fun setupClickEvent(animal: Animal){
  view.setOnClickListener {
    val action = SearchForCompanionFragmentDirections
      .actionSearchForCompanionFragmentToViewCompanion(animal)
    view.findNavController().navigate(action)
  }
}

If you trace into the generated actionSearchForCompanionFragmentToViewCompanion function you will see that it is part of the following:

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

This is doing the following things:

  1. Calling the private constructor for the class.
  2. Creating the new instance of the class.
  3. When the navigation call is made it calls the getArguments function which serializes the arguments, puts them in the bundle and returns the bundle.

Your ViewCompanionFragmentArgs generated class provides methods to deserialize and serialize your arguments as well which you can see if you trace into them. This is what is called behind the scenes by Jetpack Navigation when you add by navArgs() to an attribute definition in your fragment.

At the time of this writing, Jetpack Navigation does not have testing hooks for this scenario. Because of that we needed to understand what this was doing behind the scenes to create this test. While that created a bit of extra short term work, in the long term it gives you more understanding about the framework.

By having a better understanding, when issues pop up while you are wiring up your navigation safe arguments, you will know how it works and be able to better understand how to trace it. Ultimately, this will make it easier and faster for you to fix issues surrounding navigation.

Now that you have that out of the way, add the following test to your ViewCompanionTest class:

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

Even though this is a more focused test, it still will be run in Espresso. To reduce test execution time you are verifying all of the expected display fields in one test instead of breaking that up. Run the test in Espresso and it will pass.

Finally, following the instructions at the beginning of this chapter, create an Android JUnit configuration for your ViewCompanionTest and run it to execute this test in Robolectric. This will also pass.

Now that you have your ViewCompanionFragment test more focused, let’s refactor your SearchForCompanionFragment tests.

Reviewing from the previous chapter, this fragment does the following:

  1. It presents the user with a screen to search for a companion.

  1. It gets the user’s input and performs a search.

  1. Presents the search results and allows navigation to the ViewCompanionFragment.

To get started, create a new file in your test package called SearchForCompanionTest.kt. Next, create the following class definition:

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

  private val idlingResource = SimpleIdlingResource()
}

This is inheriting from KoinTest like you did with FindCompanionInstrumentedTest, adding in the LooperMode for Robolectric and setting up your IdlingResource. Now, add in the following to the body of your class:

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

This methods are the same as the ones with the same name in your FindCompanionInstrumentedTest.

Normally, at this point, you might want to consider refactoring this into a shared component (although bear in mind the Three Strikes Rule, which you can read about here: https://wiki.c2.com/?ThreeStrikesAndYouRefactor), but there are some things that may change so you are going to hold off on that. Following that, add in the following methods:

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

This is launching your fragment, passing in a FragmentFactory. In your ViewCompanionTest you did not need a FragmentFactory, because you were launching your MainActivity. The reason you are using a factory here has to do with Koin. Your ViewCompanionTest did not need to set Koin up and FindCompanionInstrumentedTest was able to stop Koin and inject your test modules after the app started. That only worked for those tests because you are not testing anything on the Featured Companion page rendered by your RandomCompanion fragment. Because you set up the Koin dependencies before you instantiated a SearchForCompanionFragment your test Koin modules were injected.

With your refactored tests you are directly loading your SearchForCompanionFragment in a test activity. When that starts up, it is loading up your app-level Koin dependencies. If you want to change them over to your test modules by stopping Koin and loading your test modules after things have been injected into your fragment, there is not an easy way to do that. To solve this problem, you are passing in a factory that stops Koin itself, and initializes it with your test dependencies before instantiating your fragment so that you hit your MockWebServer when making API requests.

Beyond this, your code is setting up your IdlingResource before your tests run and tearing them down afterwards. Now, add the following test:

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

This is the same test that you have in your FindCompanionInstrumentedTest except it doesn’t have to navigate to your SearchForCompanionFragment since it is being directly instantiated for this test.

Run the test and it will fail.

Looking at the error message you will see a message that reads java.lang.RuntimeException: java.lang.ClassCastException: androidx.fragment.app.testing.FragmentScenario$EmptyFragmentActivity cannot be cast to com.raywenderlich.codingcompanionfinder.MainActivity. Looking at your stack trace, the following line in SearchForCompanionFragment is your problem:

searchForCompanionViewModel.accessToken = (activity as
  MainActivity).accessToken

For now, remove this line. It is setting a stored access token that was being cached. For the eagle-eyed readers: this will result in extra requests without tokens being made. Later on there will be an exercise where you can fix this. Next, open up your SearchForCompanionViewModel and change the following line close to the top of the class from:

lateinit var accessToken: String

To:

var accessToken: String = ""

Run the test via Espresso and it will be green.

Now use your Edit Configurations… to allow all tests in this class to run in Robolectric and execute all of its tests. They will also be green.

Next, add the following two tests:

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

These are also the same as your similarly-named tests in FindCompanionInstrumentedTest minus the navigation to your SearchForCompanionFragment. Run all of your tests via Robolectric and they will be green.

Following the process you used for creating a configuration for Robolectric, create an Espresso one, but select Android Instrumented Tests instead of Android JUnit. Run all of the tests in your class again using Espresso and they will also be green.

Now, add the following test to assert that the values being displayed after doing a search are correct:

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

Run all of your tests in Espresso and everything will be green.

Now execute your tests in Robolectric and you might see that there is a problem.

Note: Like the tests earlier, these tests may not reliably fail, and may even consistently pass on your machine.

Your test that is supposed to return two results is not. Before reading further, add some debugging to your application to see what the problem might be. A hint: look at your IdlingResource to see if it is behaving as you would expect.

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.

Earlier you learned about the @LooperMode annotation and how setting it to PAUSED can make your Robolectric tests simulate how threads would run on an emulator or device. That does help with a lot of tests but there is a thing you are using in your tests that does not work with Robolectric. In your case IdlingResource is a problem. At the time of this writing the following issue talks about this https://github.com/robolectric/robolectric/issues/4807.

Back in Chapter 13, “High-Level Testing With Espresso,” you added IdlingResource to deal with the slight delays happening when your tests make calls to your MockWebServer.

The reason that MockWebServer causes some delays is because it runs in a separate thread and takes requests through the network stack. Since you can’t use Roblectric and IdlingResoruce together reliably you are going to need to refactor your test not to use the MockWebServer.

This is where your usage of Koin is going to start to pay some dividends. If you look at your KoinModule in the main app package you will see the following:

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

The following items are being injected when you run your test:

  1. The URL for the Petfinder service. You override this in your test.
  2. Your PetFinderService.
  3. Your SearchForCompanionViewModel.

Currently your test has the following:

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

In order to get away from the network calls you are going to mock out your PetFinderService. To get started replace that function with the following:

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

}

This gets rid of your PETFINDER_URL Koin Single object and overrides everything you need from appModule. The important thing here is the mock of your PetFinderService. There are three parts to this mock:

  1. First you create a mock of your PetFinderService.
  2. Then you use the Mockito when function to have it look for an event. In this scenario you are looking for a call to getAnimals on your mock with any string for the access token, any integer for the limit and a location string that contains the zipcode 30318.
  3. When your when conditions are met, it returns a async co-routine that will return a Response object containing your AnimalResults with the getMockResonseWithResults() function.

getMockResponseWithResults() isn’t yet implemented. Add that function with the following body:

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
}

Make sure to import retrofit2.Response, not the OkHttp one. Next, open CommonTestDataUtil.kt in your main test package and take the private modifier off the readFile function. Finally, run searching_for_a_companion_in_30318_returns_two_results test in Robolectric. It will be green.

Now run the same test using Espresso to make sure that you haven’t broken anything.

Oh no! Something is not right! Looking at the third line of your stack trace you will see the following:

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

As luck would have it, you can actually get rid of the mock and in the process make the code a little simpler. To do that, replace your getMockedResponseWithResults() function with the following:

private fun getMockResponseWithResults(): Response<AnimalResult> {
  val gson = Gson()
  val animalResult =
    gson.fromJson<AnimalResult>(readFile("search_30318.json"),
      AnimalResult::class.java)
  return Response.success(animalResult)
}

Note: In general, if a class contains only data like this one, it is generally as easy to make a real one that a mock. The benefit of a real one is that you know there’s no danger of you having mocked it incorrectly!

This returns an actual Response object instead of a mock, and reduces the size of your function by three lines. Run your test again and it will fail with the same stack trace.

At this point you are only mocking out PetFinderService, which is an interface. The problem here is that Koin includes a version of Mockito which is old, and missing features. To do that, find this line in your app level build.gradle:

androidTestImplementation 'org.koin:koin-test:1.0.1'

and replace it with:

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

This excludes Mockito from koin-test and instead defines it as its own deepndency. Run your test again and it will be green.

For now you are going to stick with mocking open classes in Espresso. At some point you may find that you will need to mock classes that are not open. One option is to make them open. But, if you would prefer to not have to do that, this post on Medium shows a great alternative: https://proandroiddev.com/mocking-androidtest-in-kotlin-51f0a603d500.

If you try running all of your tests in Espresso you still have some that are broken. Let’s fix the mocks for them. To get started, add the following functions:

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

The first one is returning a successful response with no results and the second is returning a call with a 401 response. Because ResponseBody in the second function is an abstract class you are able to mock it.

Next, add Mockito when clauses that use your new methods in your loadKoinTestModules function so that it looks like the following:

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

The following conditions were added:

  1. When “90210” is entered as a location it returns an empty set of results.
  2. When “dddd” is entered as a location a 401 is returned.

Now run all of your tests in Espresso and Robolectric and all of them will pass.

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.

Some scenarios where unit tests might make sense include testing:

  • Classes that focus on business logic.
  • ViewModels that have logic to present, retrieve, or store/post data.
  • Services that do things.
  • Classes that can be tested without needing to depend on Android.

Alternatively, in the following scenarios unit testing may not make sense. These include tests that:

  • Focus on Activities and Fragments.
  • Depending on Android components to run.
  • Cover boiler plate getters and setters, for example in data objects.
  • Cover basic data marshaling that are covered at other levels of the pyramid.
  • Require a significant amount of mocking.

Looking in your searchforcompanion package, there are only two classes that are candidates for unit testing. They are your ViewCompanionViewModel and SearchForCompanionViewModel. They are view models that can be tested in isolation.

To get started open ViewCompanonViewModel.kt and you will see 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() {

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

The variable definitions that are part of your data class are not good candidates for tests, but your populateFromAnimal() function could be tested. To get started, create a new file called ViewCompanionViewModelTest.kt in your test package. Next, add the following content to it:

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

This has the following parts:

  1. An Animal object. This is the same data that you had in your ViewCompanionTest animal object.
  2. A test to make sure that the animals name is set when the user calls the populateFromAnimal() function.
  3. A focused failing assertion to start out with to ensure that we have a valid test.

The test will default to run in Espresso. Following the steps from earlier in this chapter, create a configuration to run this test class with Android JUnit and then use it to run your test.

Now that you have a failing assertion, correct it to check for the name of your animal:

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

Run the test again and it will pass.

You may have noticed that your test here is much more focused with only one assertion. This is intentional for a number of reasons including:

  • Unit tests are intended to be more focused.
  • They run faster and as such do not take as much time to spin up dependencies.
  • The focused assertions lead to individual tests that are not as brittle.

Just like dancing, driving a car, or learning to speak a language, repetition and practice help to make you a better TDD’er. We could spend pages and pages writing focused tests for all of these fields. But, this is a great opportunity for you to practice.

Before you continue on, take some time to do the following:

  1. Write a test function for the next field in your ViewModel with one assert that should fail.
  2. Run the test to make sure that it fails.
  3. Fix the assert expectation to ensure that it passes.
  4. Go back to step one and repeat this until you have done this for all fields.

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 {

}

Now open up SearchForCompanionViewModel.kt and you will see the following:

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

}

At a high level, this has the following testable elements:

  1. The visibility for your noResults view.
  2. The location that you want to search on.
  3. The animals that are returned.
  4. A Retrofit call that uses #2 to return data for #3 and either displays or hides #1.

To start off, do a focused test that enters 30318 as your location and checks to be sure that two results are returned.

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

This test is doing the following:

  1. Setting up your MockWebServer.
  2. Setting up your MockWebServer dispatcher.
  3. Initializing your PetfinderService pointing it to your MockWebServer.
  4. Executes a test, which creates a SearchForCompanionViewModel with your PetFinderService, sets your location value, runs the search, and checks that the result has only two results.

This is not an Espresso test, but it may try to run as one due to your sharedTest setup, so use the Edit Configuration option to set it up to run as an Android Junit test. Then try running your test.

Oh no! Your test is failing with a dreaded NullPointerException when it tries to set a value on your companionLocation LiveData object. I thought Kotlin was supposed to help prevent null pointers! The actual null pointer is thrown when it tries to determine if this task is running on the main thread.

This is because your test is trying to access the “main” thread — which does not exist in the unit test. To fix that you are going to need to add an InstantTaskExecutorRule. This swaps the default executor for your ViewModel with one that executes everything synchronously on your current thread. To add this add the following in your app level dependencies:

testImplementation "androidx.arch.core:core-testing:2.0.1"
androidTestImplementation "androidx.arch.core:core-testing:2.0.1"

Then add this to your test class:

@get:Rule
val instantTaskExecutorRule = InstantTaskExecutorRule()

You will also need to add this import:

import androidx.arch.core.executor.testing.InstantTaskExecutorRule

Now, run your test again.

Oops! It’s still failing, but this time for a different reason! Let’s track this down.

Your error message is because your searchForCompanionViewModel.animals.value is null. Looking at your method body of the searchForCompanions method in your ViewModel, there are two co-routines that you are using.

fun searchForCompanions() {

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

    }
  }
}

If you debug through the call you will see that the test exits before your call completes with your getAnimalsRequest. You are going to need to do something to allow this to execute your threads and wait for it until execution is done.

To get started, add the following dependencies to the dependencies section of your app level build.gradle file:

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"

This is adding coroutine testing support, which you can read more about here: https://github.com/Kotlin/kotlinx.coroutines. Next, add the following at the class level of your test:

private val mainThreadSurrogate =
  newSingleThreadContext("Mocked UI thread")

This is setting up a thread context, owned by your test thread that will make all of your tests run under one thread.

Then add this to your setup method:

Dispatchers.setMain(mainThreadSurrogate)

This tells the system to use this new thread context that you just created. Now run your tests.

Another problem?! The issue is that the LiveData result is coming back after you did your assert. To fix that, replace your test with the following:

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

This is adding a CountDownLatch that waits until your result comes back. There are three parts to using it:

  1. Setting up your latch with an initial latch value; in this case it is one. The number is how many times countDown needs to be called on it before it continues after await.

  2. Using an observeForever on your LiveData object, and, when a result is received, incrementing the value of the latch down.

  3. A call to await with a timeout of 2 seconds to wait for the result to be returned. The timeout is important so that the test does not hang indefinitely if there is a problem that causes the latch to not fire.

Run your test again and it will be green!

Note: CountDownLatches are useful but can make tests slow and brittle. An easy way to get around them often is to make the scheduling/threading a dependency of the class you’re testing, so that you can put in “fake” synchronous scheduling within tests.

Since you want to verify that this is a valid test, change the expectation on your assert to another value, such as 1 and re-run your test.

It fails, which is what we wanted. Now change the value back to 2 and make it green.

When this ViewModel fetches data it sets values for your view, it also sets the value of noResultsViewVisibility to INVISIBLE if there are results or VISIBLE if there are none. Let’s add some tests for that. To get started add the following test:

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

Since you want to have a failing test first, go to your SearchForCompanionViewModel and change the following line in your searchForCompanion function:

noResultsViewVisiblity.postValue(INVISIBLE)

to:

noResultsViewVisiblity.postValue(VISIBLE)

Now run your test and it will fail.

Undo the last change in searchForCompanion so that the line is back to:

noResultsViewVisiblity.postValue(INVISIBLE)

Run your test again and it will pass.

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

Because you want to have a failing test first, your assert is currently not correct. Run the test and it will fail.

Now change the assert to be correct:

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

Run the test again and it will pass.

Looking at this test and the previous one you did around visibility they are very similar:

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

The only real difference is your companionLocation value and your visibility assert. Let’s refactor this by replacing these two tests with the following:

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

What you are doing here is using a common function for setting up your call, CountDownLatch, but keeping your assert in the test. Now, technically, you could have the assert in your common method and just pass in the expected value to this common method. This is a matter of style. Since part of the purpose of unit tests is to provide documentation about how the code works, in the authors’ opinion, not having the assert in the common method makes it a little bit more readable. That said, if you find it to be more readable by putting the assert in the common method, that can be valid as well. The key takeaway is that tests are a form of documentation and the goal is to structure them to make it easier for a new person looking at the code base to understand it.

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.
© 2026 Kodeco Inc.