Adam McNeilly

Leveling Up Your UI Tests With MockWebServer

Three times a year OkCupid developers get to put our daily responsibilities to the side and enjoy "Hack Week", an entire work week that lets us pursue problems we want to tackle with more freedom to explore and learn.

This spring, I took the week to finally dive further into Espresso testing. I've automated apps with this tool before, but there's one issue with Espresso testing that I've always struggled with: network mocking.

There are plenty of guides, including the official Espresso docs that help you get started automating your app, but if you want to write reliable UI tests you need to mock your networking layer. Without doing so, your tests may be inconsistent. Let's consider testing DoubleTake in OkCupid (this is the main screen where you slide user cards left/right to vote on them). Without mocking the network, I have no idea:

  • which user will appear first in the card stack.
  • if only user cards will appear (as opposed to ads or special announcements).
  • if liking a user will result in a mutual match even if my test doesn't expect it to.

For the OkCupid app, mocking the network layer seemed like a particularly challenging task. We don't use Dagger or other dependency injection frameworks right now, and a lot of our network code is tightly coupled to the Application class. It turns out, though, that's not a big problem at all!

What Tool Should I Use?

When I first started this endeavor, I tried WireMock. WireMock is an HTTP mocking tool that allows you to run an HTTP server right on your Android device, that you app can communicate with instead of talking to your own server. I learned about WireMock thanks to Sam Edwards at Droidcon NYC 2017, where he goes in depth to explain how WireMock can be used to mock HTTP APIs. If you're looking for an in depth guide on WireMock, I highly recommend that.

However, Sam also mentions another tool called MockWebServer. MockWebServer is a Square library that achieves the same goal - to run a mock service for your HTTP responses. MockWebServer is actually more light weight, and after trying both I found the setup for MockWebServer to be a lot easier (adding WireMock as a gradle dependency gave me several conflicts I had to resolve). Since MockWebServer has all of the capabilities I need, I decided to move forward with that.

Groundwork

Before we go on to implement MockWebServer, let's talk about some of the groundwork we can do to make this work. MockWebServer will start a webserver on local host, meaning all of our requests would go through http://localhost:8080 instead of http://myendpoint. Swapping the endpoint for your test application can be done in two steps:

  1. Create a TestApplication that extends your normal application class, and override your base URL.
  2. Create a custom JUnit runner that will use your test application.

Creating a TestApplication

First, let's consider we have our own Application class, which exposes our API url:

open class OkApp : Application() {
    open fun getApiUrl(): String {
        return "http://apiurl"
    }
}

Inside your AndroidTest directory, create an application class that extends the one you use in your app:

class TestApplication : OkApp() {
    override fun getApiUrl(): String {
        return "http://127.0.0.1:8080"
    }
}

Notice here I've just overridden one method which returns a localhost API url, instead of the one that the app uses. Does this mean all of our app's requests will hit local host? Not quite, Espresso is still using our normal application class. To fix that, we need a custom runner.

Custom JUnit Runner

Creating a custom JUnit runner just requires one quick update: override the newApplication method and have it point to your test application class, instead of the original:

class MockTestRunner : AndroidJUnitRunner() {
    override fun onCreate(arguments: Bundle?) {
        StrictMode.setThreadPolicy(StrictMode.ThreadPolicy.Builder().permitAll().build())
        super.onCreate(arguments)
    }

    override fun newApplication(cl: ClassLoader?, className: String?, context: Context?): Application {
        return super.newApplication(cl, TestApplication::class.java.name, context)
    }
}

Then, we need to go into our app's build.gradle file and configure it to point to this runner instead:

android {
    defaultConfig {
        testInstrumentationRunner 'com.my.package.MockTestRunner'
    }
}

Now that we have our groundwork setup, and our app is pointing to localhost, let's get something running there.

MockWebServer Setup

We can start with the copy/paste step of including the MockWebServer dependency:

androidTestImplementation "com.squareup.okhttp3:mockwebserver:${version.okhttpVersion}"

Next, we need to fire up our mock web server for each test. We can configure that in a setup and teardown method inside our test class:

@RunWith(AndroidJUnit4::class)
class MainActivityTest {
    // ...

    private var mockWebServer = MockWebServer()

    @Before
    fun setup() {
        mockWebServer.start(8080)
    }

    @After
    fun teardown() {
        mockWebServer.shutdown()
    }

    // ...
}

When we start our web server, we give it a port to run on. That's the same port we had in our test application class; It's important these align. If you want a little more safety here, you can move the port into a build config field.

Response Mocking

Congrats on making it this far! I'm sure that was a lot to follow. Let's look at where we are now:

  • We've created a TestApplication class, which allows us to override anything that our normal application would use.
  • We've learned how to implement our own custom JUnit runner to use this TestApplication class.
  • We've made it so that when our app runs connected tests, it will talk to localhost instead of going out to your company's server.

All great things! Unfortunately, we're not mocking any responses yet. If you run your tests as they are, you'll see a lot of 404 errors. So let's talk about how we can fix that.

File Reading Boilerplate

In broad terms, we have two approaches here: make our mock responses from model objects programmatically, or read our mock responses from JSON files (if you have thoughts on why one is better than the other, call me out on Twitter because I don't really know what's best here). I chose to use files, and I'll just briefly talk about the boilerplate code added to make that happen.

You want to save all files in the following location:

app/src/debug/assets/network_files/endpoint_success.json

I've chosen to call the folder network_files, but you can call it whatever you like. Now that we have our JSON files, we need a way to read them. I've created the following AssetReaderUtil.kt file and stored it in my androidTest directory:

object AssetReaderUtil {
    fun asset(context: Context, assetPath: String): String {
        try {
            val inputStream = context.assets.open("network_files/$assetPath")
            return inputStreamToString(inputStream, "UTF-8")
        } catch (e: IOException) {
            throw RuntimeException(e)
        }

    }

    private fun inputStreamToString(inputStream: InputStream, charsetName: String): String {
        val builder = StringBuilder()
        val reader = InputStreamReader(inputStream, charsetName)
        reader.readLines().forEach {
            builder.append(it)
        }
        return builder.toString()
    }
}

Now that we have this, let's walk through using these mocks.

Dispatcher

MockWebServer uses something called a Dispatcher to handle the mocked server responses. It includes one method for us to override which tells us what the request is and allows us to return a mock response specific to that.

Here is how my dispatcher works:

  • It accepts a context, which is used to read from the files we made in the last step.
  • It contains a map property which maps an endpoint to the filename for its mock response.
  • If the dispatch method finds a filename for the requested endpoint, it will return that mocked response.
  • If no mock file is found, we'll return a 404.

Here is the code for all that:

class SuccessDispatcher(
        private val context: Context = InstrumentationRegistry.getInstrumentation().context
) : Dispatcher() {
    private val responseFilesByPath: Map<String, String> = mapOf(
            APIPaths.ENDPOINT_ONE to MockFiles.ONE_SUCCESS_FILE,
            APIPaths.ENDPOINT_TWO to MockFiles.TWO_SUCCESS_FILE
    )

    override fun dispatch(request: RecordedRequest?): MockResponse {
        val errorResponse = MockResponse().setResponseCode(404)

        val pathWithoutQueryParams = Uri.parse(request?.path).path ?: return errorResponse
        val responseFile = responseFilesByPath[pathWithoutQueryParams]

        return if (responseFile != null) {
            val responseBody = asset(context, responseFile)
            MockResponse().setResponseCode(200).setBody(responseBody)
        } else {
            errorResponse
        }
    }
}

I've named this dispatcher SuccessDispatcher because it only returns success responses. If you want to override a response, I recommend steering away from the static map of endpoints to file names and instead expose a method which allows you to define what should be mocked for each endpoint.

Mock Before Starting The Test

The last thing you need to be aware of, is that you need to make sure you start MockWebServer before running your application. It's possible that your application makes a network request when it initializes, and so if you start the app first (which is the Espresso default) things may not work as expected. You can tweak your ActivityTestRule to launch inside a setup method, like this:

@RunWith(AndroidJUnit4::class)
class MainActivityTest {
    @JvmField
    @Rule
    var activityTestRule = ActivityTestRule(MainActivity::class.java, true, false)

    private var mockWebServer = MockWebServer()

    @Before
    fun setup() {
        mockWebServer.start(BuildConfig.PORT)
        mockWebServer.dispatcher = SuccessDispatcher()

        activityTestRule.launchActivity(null)
    }

    @After
    fun teardown() {
        mockWebServer.shutdown()
    }

    // ...
}

Sample

Now that we've completed all of that, we can write an automated test like this, that's using all mock data for myself and not interacting with real users:

DTAutomation

If you want to see a super basic app that uses these tools in action, feel free to fork this sample project.

I hope this was helpful! If you have any questions, or other unique ways of writing MockWebServer dispatchers, find me on Twitter.

Want to join me at OkCupid? We're hiring!