Chapters

Hide chapters

Android Test-Driven Development by Tutorials

Second Edition · Android 11 · Kotlin 1.5 · Android Studio 4.2.1

Section II: Testing on a New Project

Section 2: 8 chapters
Show chapters Hide chapters

Section III: TDD on Legacy Projects

Section 3: 8 chapters
Show chapters Hide chapters

16. Strategies for Handling Test Data
Written by Lance Gleason

In the previous three chapters, you learned how to move slow to go fast. Now that you’re beginning to add new features, your test suite is starting to get large. Lots of homeless coding companions are being placed with developers. But, as that’s happening, an inevitable problem is presenting itself. Namely, your test data is starting to get difficult to maintain. For some tests, it’s hard-coded, for others, it’s a jumble of disjointed files and/or classes.

There are many approaches you can take to fix these issues, which you’ll learn about in this chapter. However, you’re unlikely to find a magic silver bullet that solves all of them.

JSON data

In the past three chapters, you made heavy use of MockWebServer. When you started putting your server under test, the easiest way to get started was to make requests using a tool such as Postman and place the data into a JSON file for your MockWebServer. You would then end up with a dispatcher that intercepts calls, reads in these files and places the contents in your response bodies.

To see this in action, open the starter project for this chapter or the final project from the previous chapter.

Look at the CommonTestDataUtil.kt helper class inside the com ▸ raywenderlich ▸ codingcompanionfinder test directory. Looking at your dispatcher, you’ll see the following:

fun dispatch(request: RecordedRequest): MockResponse? {
  return when (request.path) {
    "/animals?limit=20&location=30318" -> {
      MockResponse().setResponseCode(200).setBody(
        readFile("search_30318.json")
      )
    }
    "/animals?limit=20&location=90210" -> {
      MockResponse().setResponseCode(200).setBody(
        readFile("search_90210.json")
      )
    }
    else -> {
      MockResponse().setResponseCode(404).setBody("{}")
    }
  }
}

In this example, you’re doing exact matches on the request path and specifying files based on the request. Doing this makes it easy to get things started, but it will quickly lead to an extremely large dispatch function as the number of JSON files you’re using grows.

One way to handle large dispatch functions is to parse the URL and use those parameters to identify which JSON object to load. For example, with the Coding Companion Finder app, you might have a dispatch method that looks like this:

fun dispatch(request: RecordedRequest): MockResponse? {
  val fileNameAndPath = getFileNameAndPath(request.path)
  return if (!fileNameAndPath.isEmpty()) {
      MockResponse().setResponseCode(200).setBody(
        readFile(fileNameAndPath)
      )
    else -> {
      MockResponse().setResponseCode(404).setBody("{}")
    }
  }
}

getFileNameAndPath(path: String) converts the URL into a file name by replacing characters.

For example, a path of /animals?limit=20&location=30318 becomes animals_limit_20_location_30318.json, and /animals?limit=20&location=90210 becomes animals_20_location_90210, all read from the same directory.

Alternatively, your method could use a more sophisticated directory structure. /big/animals?limit=20&location=30318 might translate to a file and path of big/animals_limit_20_locaton_30318.json, and small/animals?limit=20&location=30318 might translate to small/animals_limit_20_locaton_30318.json.

Post requests

Up to this point, your MockWebServer is only dealing with GET requests. It’s time to look at how to handle POST requests.

The PetFinder API used in your app requires OAuth credentials. POST requests are used to support this OAuth workflow during certain steps that — up to this point — you’ve short-circuited with your MockWebServer. It’s time to address this gap in your test coverage.

Since you need to understand how OAuth works to test it, let’s do a quick review of the OAuth flow as you’re currently using it:

Open AuthorizationInterceptor.kt inside your apps retrofit package, and you’ll see where these steps take place:

class AuthorizationInterceptor : Interceptor, KoinComponent {
  private val petFinderService: PetFinderService by inject()
  private var token = Token()
  @Throws(IOException::class)
  // 1
  override fun intercept(chain: Interceptor.Chain): Response {
    var mainResponse = chain.proceed(chain.request())
    val mainRequest = chain.request()
    // 2
    if ((mainResponse.code() == 401 ||
        mainResponse.code() == 403) &&
        !mainResponse.request().url().url()
          .toString().contains("oauth2/token")) {
      // 3
      val tokenRequest = petFinderService.getToken(
        clientId = MainActivity.API_KEY,
        clientSecret = MainActivity.API_SECRET)
      val tokenResponse = tokenRequest.execute()
      if (tokenResponse.isSuccessful) {
        // 4
        tokenResponse.body()?.let {
          token = it
          // 5
          val builder =
            mainRequest.newBuilder().header("Authorization",
                "Bearer " + it.accessToken)
              .method(mainRequest.method(), mainRequest.body())
          mainResponse = chain.proceed(builder.build())
        } }
    }
    // 6
    return mainResponse
  }
}

Here’s how this code works:

  1. First, a GET request is made to the server to retrieve some data on the /animals endpoint. An accessToken is passed into it. When it’s the first time a call has been made after opening the app, this accessToken will be blank.

  1. If the token is either blank, invalid or expired, the server responds back with a 401, indicating that the current credentials are unauthorized.

  2. This 401 is caught by your apps AuthorizationInterceptor, which posts an /oauth2/token request with your API Key and API Secret.

  3. A token is returned to your AuthorizationInterceptor.

  4. Your AuthorizationInterceptor then retries the original request to /animals.

  5. The response with animal records is passed to the original caller of #1.

You’re going to add some non-focused test coverage. This non-focused test coverage will cause your tests to fail if you break something.

Go back to CommonTestDataUtil.kt and replace dispatch(request: RecordedRequest) with the following:

fun dispatch(request: RecordedRequest): MockResponse? {
  val headers = request.headers
  // 1
  if(request.method.equals("POST")){
    if(request.path.equals("/oauth2/token")){
      return MockResponse().setResponseCode(200).setBody(
        "{\"access_token\":\"valid_token\"}")
    }
  }
  // 2
  val authorization = headers.values("Authorization")
  if (!authorization.isEmpty() &&
      authorization.get(0).equals("Bearer valid_token")) {
    return when (request.path) {
      "/animals?limit=20&location=30318" -> {
        MockResponse().setResponseCode(200).setBody(
          CommonTestDataUtil.readFile("search_30318.json")
        )
      }
      "/animals?limit=20&location=90210" -> {
        MockResponse().setResponseCode(200)
          .setBody("{\"animals\": []}")
      }
      else -> {
        MockResponse().setResponseCode(404).setBody("{}")
      }
    }
  } else {
    // 3
    return MockResponse().setResponseCode(401).setBody("{}")
  }
}

This code adds the following things to dispatch():

  1. Logic to look for a post request to /oauth2/token and to return a token value of valid_token.
  2. Logic to check if a valid token is on your GET request before processing it.
  3. A return statement to return a 401 if the token is not valid_token.

Now, run your tests by right clicking on the second of the two (test) packages in your Android project view.

Then select run ’Tests in ‘com.raywen…’’.

Your tests will run.

Oh, no! Most of the tests are successful except for those in SearchForCompanionViewModelTest. Before you changed your dispatcher, they were green. If you trace into the stack trace for these, you’ll see the following error:

lateinit property koinContext has not been initialized

This ends up tracing back to the following line in AuthorizationInterceptor.kt (the one you looked at earlier):

val tokenRequest = petFinderService.getToken(
  clientId = MainActivity.API_KEY,
  clientSecret = MainActivity.API_SECRET)

If you look at where petFinderService is declared, around the beginning of the class, you’ll see the following:

private val petFinderService: PetFinderService by inject()

This is using Koin to inject a PetFinderService into this class.

Open SearchForCompanionViewModelTest.kt. You might remember from the last chapter that you didn’t use Koin in this test. There are two different ways you can fix this. One way is to configure this test to use Koin. The other is to create a second dispatcher that doesn’t hit the authorization interceptor in this test. Since the first option was exercised in FindCompanionsInstrumentedTest.kt, you’re going to use option two.

Go to CommonTestDataUtil.kt and add the following function:

fun nonInterceptedDispatch(
  request: RecordedRequest
): MockResponse? {
  val headers = request.headers
  return when (request.path) {
    "/animals?limit=20&location=30318" -> {
      MockResponse().setResponseCode(200).setBody(
      readFile("search_30318.json")
      )
    }
    "/animals?limit=20&location=90210" -> {
      MockResponse().setResponseCode(200)
        .setBody("{\"animals\": []}")
    }
    else -> {
      MockResponse().setResponseCode(404).setBody("{}")
    }
  }
}

This code adds a version of your dispatcher that does not end up causing your AuthorizationInterceptor in your app to be called.

Go back to SearchForCompanionViewModelTest.kt, and replace the dispatcher declaration with the following:

val dispatcher: Dispatcher = object : Dispatcher() {
  @Throws(InterruptedException::class)
  override fun dispatch(
    request: RecordedRequest
  ): MockResponse {
    return CommonTestDataUtil.nonInterceptedDispatch(request) ?:
      MockResponse().setResponseCode(404)
  }
}

This code calls your new dispatcher.

Run all of your tests again, and everything is green.

Using JSON data has the following benefits:

  • It makes it easier to quickly get data into your app to test.
  • It exercises a larger portion of your web request stack.
  • By taking snapshots from requests you make from tools like Postman, you can get data that most closely resembles production data.

Some drawbacks to using JSON include:

  • If your requests return large objects, the files can be cumbersome.
  • Modifying data in the files usually has to be done by hand or requires a non-trivial effort to write code to read and modify it.
  • As your dataset grows, managing the files and the corresponding logic can get complex.
  • Asserts generally need to be hard-coded values since you don’t have a reference value from an object to perform an assert against.

Hard-coded data

Another approach you’ve used is hard-coded data. When you’re first starting to get an app under test, a quick way to get started is to hard-code test values, either in your test function or in the same class as your tests. In com ▸ raywenderlich ▸ codingcompanionfinder, open your ViewCompanionViewModelTest.kt and you’ll see the following:

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

  @Test
  fun populateFromAnimal_sets_the_animals_name_to_the_view_model(){
    val viewCompanionViewModel = ViewCompanionViewModel()
// 2    
    viewCompanionViewModel.populateFromAnimal(animal)
// 3    
    assert(viewCompanionViewModel.name.equals("Spike"))
  }
}

This is a classic example of hard-coded test data. Here’s how it works:

  1. Create an Animal using a series of hard-coded values.
  2. Pass the object into populateFromAnimal(animal: Animal).
  3. Perform an assertion based on the outcome of the function call.

Advantages to using hard-coded data include:

  • Test data is contained within the same class — sometimes the same functions as your tests — making it easier to see what the values should be.
  • It‘s faster to get started writing tests.

Disadvantages include:

  • The initialization calls need hard-coded values that create larger test files, which reduces the readability of a test class as it gets larger.
  • A lot of effort is duplicated across test files creating the data.
  • Large data sets become too verbose.

Test object libraries

One way to address the hard-coded data issues is to create a test object library. This is best explained with code.

To get started, create a new package inside the com ▸ raywenderlich ▸ codingcompanionfinder test package, and name it data.

Inside the newly created data package, create a file named AnimalData.kt, and add the following content to it:

object AnimalData {
  val atlantaShihTzuNamedSpike = Animal(
    22,
    atlantaCodingShelter,
    "5",
    "small",
    arrayListOf(),
    shihTzu,
    "Spike",
    "male",
    "A sweet little guy with spikey teeth!"
  )
}

object AddressData {
  val atlantaAddress = Address(
    "",
    "",
    "Atlanta",
    "GA",
    "30303",
    "USA"
  )
}

object BreedsData {
  val shihTzu = Breeds("shih tzu", "", false, false)
}

object ContactsData {
  val atlantaCodingShelter = Contact(
    phone = "404-867-5309",
    email = "coding.companion@razware.com",
    address = atlantaAddress
  )
}

When you import the required classes, make sure you import the correct one for Address — the one in your project, not the one from the framework packages. This creates test objects with the same data you hard-coded in ViewCompanionViewModelTest.kt.

Next, open ViewCompanionViewModelTest.kt and replace its contents with the following:

class ViewCompanionViewModelTest {

  @Test
  fun populateFromAnimal_sets_the_animals_name_to_the_view_model() {
    val viewCompanionViewModel = ViewCompanionViewModel()
    viewCompanionViewModel
      .populateFromAnimal(atlantaShihTzuNamedSpike)
    assert(viewCompanionViewModel.name
      .equals(atlantaShihTzuNamedSpike.name))
  }
}

This code completely removes the hard-coded object creation, allowing you to focus on the test and specific data it’s testing. As you test other functions that need an Animal, you’ll have much more concise code and shared objects that you can reuse.

Run your test, and you’ll notice it’s still green.

Green is good; green is wonderful in TDD, so take a moment to enjoy this view!

Pros of using test object libraries include:

  • Test files that are more readable with shorter data set up code.
  • Test data that can easily be reused across test classes.

Cons of using them include:

  • It takes a little extra effort to set them up initially.
  • You have to look in another file to see details about the test data.

Faker

Up to this point, your actual test values have been hard-coded strings that are the same every time you use them in a test. Over time, you may run out of ideas for names. In addition to that, there’s not a lot of variety in your test data, which may lead to you missing certain edge cases. But don’t worry, there’s a tool to help you address this: Faker. You first saw this in Chapter 10, “Testing the Network Layer.”

Faker has data generators for various kinds of data, such as names and addresses. To see the full power of it, you need to add it to your app. To get started, open the app level build.gradle, and add the following to the dependencies section:

testImplementation 'com.github.javafaker:javafaker:0.18'
androidTestImplementation 'com.github.javafaker:javafaker:0.18'

This is adding the dependencies to your project. Next, open up your AnimalData.kt file from above and add the following at the top level of the file:

val faker = Faker()

This code gives you an instance of a Faker.

Next, add the following val to AnimalData.kt at the top of the file:

val fakerAnimal = Animal(
  faker.number().digits(3).toInt(),
  fakerShelter,
  faker.number().digit(),
  faker.commerce().productName(),
  arrayListOf(),
  fakerBreed,
  faker.name().name(),
  faker.dog().gender(),
  faker.chuckNorris().fact()
)

This code adds another animal object with Faker-generated data for things like the companion’s name and gender. (Yes, we added the Chuck Norris fact for the description for fun. Don’t worry about the code not compiling just yet. We need to add more to this file.)

Next, add the following above fakerAnimal in AnimalData.kt.

val fakerAddress = Address(
  faker.address().streetAddress(),
  faker.address().secondaryAddress(),
  faker.address().city(),
  faker.address().state(),
  faker.address().zipCode(),
  faker.address().country()
)

This code adds Faker elements for the address.

Add the following above fakerAddress in AnimalData.kt:

val fakerBreed = Breeds(faker.cat().breed(),
  faker.dog().breed(), faker.bool().bool(), faker.bool().bool())

This code fakes out the breed.

Add this above fakerBreed in AnimalData.kt:

val fakerShelter = Contact(
  faker.phoneNumber().cellPhone(),
  faker.internet().emailAddress(),
  fakerAddress
)

This code fakes out the contact information.

Now, go back to ViewCompanionViewModelTest.kt and add the following test:

@Test
fun populateFromAnimal_sets_the_animals_description_to_the_view_model(){
  val viewCompanionViewModel = ViewCompanionViewModel()
  System.out.println(fakerAnimal.toString())
  viewCompanionViewModel.populateFromAnimal(fakerAnimal)
  assertEquals("faker", viewCompanionViewModel.description)
}

This code creates a test that does an assert on the description. It also prints the contents of your object so that you can see what kind of data Faker creates.

Run this test and you’ll see a failed test:

Your object output is on one line; however, breaking it down, so it’s more readable, you’ll end up with something that looks like this:

Animal(
  id=798,
  contact=Contact(
    phone=1-256-143-0873,
    email=malinda.hoppe@hotmail.com,
    address=Address(
      address1=09548 Wayne Dale,
      address2=Suite 523,
      city=Charitybury,
      state=West Virginia,
      postcode=30725-9938,
      country=Northern Mariana Islands
      )
    ),
    age=0,
    size=Synergistic Wool Bottle,
    photos=[],
    breeds=Breeds(
      primary=Khao Manee,
      secondary=Sealyham Terrier,
      mixed=false,
      unknown=false),
    name=Miss Linnea Hills,
    gender=female,
    description=For Chuck Norris, NP-Hard = O(1).)

Of course, since you’re now using Faker, the information will be different each time. At the moment, your assertion is not the right value, which is reflected in the error message.

To fix this incorrect value, change the assert in your test on the last line to:

assertEquals(fakerAnimal.description,
  viewCompanionViewModel.description)

You’re now making sure that your assertion tests using the data generated by Faker. You want to ensure that viewCompanionViewModel contains the same description as the fakeAnimal, regardless of what Faker generated there.

Run your tests again, and notice they’re all green.

The pros of using Faker include:

  • Random data that’s more representative of actual app data.
  • You don’t need to come up with test data yourself.

The cons of using Faker include:

  • While Faker has test data many scenarios, it doesn’t include all possibilities.
  • Your assertions become assertions against values in Faker objects rather than text you can read in your tests.
  • Sometimes, the data generated by Faker is outside of the scope of your business logic, and you need to limit it.

Locally persisted data

If you have an app that locally persists data, you may have some tests that need to have the datastore in a certain state before you run your test.

If you’ve had experience doing server-side TDD, your first thought might be to:

  1. Get your datastore into the state you want it to be.
  2. Dump it to a file.
  3. Load up that data before running your tests.

On Android, at the time of this writing, the only way to do something like this is for your app to have root access or to use adb, which is not practical for Robolectric tests. This leaves you with only one option: To load test data using code.

To load test data using code, you could, for example, use Faker with a series of test objects you load using a helper function before running the tests.

Because datastores tend to be slower, especially if you need to test with a significant number of test records, you should consider executing that set up using a @BeforeClass annotated function.

Key points

  • There are no magic silver bullets with test data.
  • JSON data is great for quickly getting started with end-to-end tests but can become difficult to maintain as your test suites get larger.
  • Hard-coded data works well when your test suite is small, but it lacks variety in test data as your test suite grows.
  • Faker makes it easier to generate a variety of test data for object libraries.
  • Tests that need to get data stores into a certain state can be expensive because you need to insert data into the data store programmatically.

Where to go from here?

Wrangling your test data is another way that you’re able to move slow to go fast. In your case, going fast means more companions in homes and programmers with higher quality code. To learn more about Faker, check out the project page at https://github.com/DiUS/java-faker.

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.