How to test REST API clients in Android apps

Table of contents

    How to test REST API clients in Android apps
    TL;DR
    • Use OkHttp’s MockWebServer to create integration tests for REST API clients
    • Build domain-specific abstractions over low-level mock server primitives
    • Encapsulate mocked documents and endpoint handlers for cleaner test code
    • Register custom endpoint handlers to test complex scenarios like blocking behavior

    Nutrient Instant is a library built on top of Nutrient that provides real-time collaboration features, allowing users to share, edit, and annotate PDF documents across multiple platforms. This library acts as a client for the REST(opens in a new tab) API provided by Document Engine.

    While developing our Instant client for Android, we wrote unit tests using TDD(opens in a new tab) to ensure our code units worked as expected. To add reliability to our production code, we created integration tests for the library. These tests verify correct client behavior when communicating with the server API. We used a local mock server that implements our real server API and behavior.

    MockWebServer

    The Nutrient Instant client library uses OkHttp(opens in a new tab) for handling all HTTP calls. OkHttp provides a ready-to-use MockWebServer(opens in a new tab) that allows scripting HTTP servers in tests so we can run our client code against it and verify that it behaves correctly.

    MockWebServer is an additional dependency that needs to be added to the build.gradle file:

    dependencies {
    // Dependency for unit/Robolectric tests.
    testImplementation "com.squareup.okhttp3:mockwebserver:4.12.0"
    // Dependency for Android instrumentation tests.
    androidTestImplementation "com.squareup.okhttp3:mockwebserver:4.12.0"
    }

    MockWebServer usage is similar to the usage of mocking frameworks like Mockito. First, prepare the server with responses that should be returned. Then run the application code, and finally, verify responses received by the mock server.

    A real example

    We’ll illustrate usage of MockWebServer on a simple test that downloads a PDF document through Instant.

    This requires two separate requests:

    1. An authentication request made with a JSON Web Token (JWT). A JWT encapsulates all data required for identifying the document on the server and for authenticating access to this document. For the sake of this example, we’ll just use TestInstantJwtFactory, which already generates the correct tokens.

      TestInstantJwtFactory is built around the JWT generation library JJWT(opens in a new tab). Its implementation isn’t interesting in the context of this example. Just think about it as a simple black box that generates authentication tokens usable in our Instant client integration tests.

    2. A request for downloading the data of the PDF document.

    As a first step, we’ll enqueue mocked responses in the mock server for these two requests:

    val server = MockWebServer()
    // Schedule the authentication response.
    server.enqueue(MockResponse().apply {
    addHeader("Content-Type", "application/json")
    // Generate the authentication response body.
    body = getAuthorizationResponseJson().toString()
    })
    // Schedule the response for the document download.
    server.enqueue(MockResponse().apply {
    addHeader("Content-Type", "application/pdf")
    // Serve PDF document data from a local file.
    body = Buffer().readFrom(File(testDocumentPath).inputStream())
    })

    Then we’ll start the mocked server and create InstantClient with its URL:

    // Start the server.
    server.start()
    // Build a URL for the mock web server.
    val serverUrl = server.url("/").toString()
    // Create the Instant client.
    val instantClient = InstantClient.create(context, serverUrl)

    Now we can open the document using the InstantClient API:

    // Each document on the Instant server is identified by its ID.
    // The document ID doesn't matter for this test case as long as the token format is correct.
    val jwt = TestInstantJwtFactory.generateJwt("document_id")
    // Open the document from the mocked server.
    val document = instantClient.openDocument(jwt)
    assertNotNull(document)

    Finally, we can verify that the mocked server received the correct requests:

    val authRequest = server.takeRequest()
    assertEquals("/i/d/document_id/auth", authRequest.getPath())
    assertEquals(jwt, authRequest.getHeader("x-pspdfkit-token"))
    val pdfDownloadRequest = server.takeRequest()
    assertEquals("/i/d/document_id/pdf", pdfDownloadRequest.getPath())

    Introducing abstractions

    We could follow the same approach as above for serving responses for all of our test cases. We could even copy and paste all HTTP responses from our real web server and replay them in our tests. This is the simplest way of approaching REST client integration tests, but it requires a fair amount of work and duplicated code, making it hard to scale.

    Tests deserve careful design. Proper test architecture results in more reliable, easier to write, and more understandable tests. So we introduced abstractions over the low-level Document Engine API to work with high-level objects instead.

    Encapsulating the mocked Instant server

    The first step is to encapsulate low-level objects with a domain-specific API object. For this very reason, we introduced the InstantMockServer class, which provides a high-level API on top of MockWebServer:

    class MockInstantServer(val context: Context) {
    private val mockWebServer = MockWebServer()
    private var isStarted: Boolean = false
    fun start(): String {
    if (!isStarted) {
    mockWebServer.start()
    isStarted = true
    }
    return url()
    }
    fun stop() {
    if (isStarted) {
    mockWebServer.shutdown()
    isStarted = false
    }
    }
    fun url(): String {
    return mockWebServer.url("/").toString()
    }
    }

    Intercepting requests

    MockWebServer uses a queue of mocked responses by default. We need some way to intercept requests and to respond in a meaningful way to achieve higher flexibility when writing our tests. MockWebServer allows the use of custom Dispatchers for handling responses at the time requests are received:

    class MockInstantServer(val context: Context) : Dispatcher() {
    ...
    init {
    // Register this class as a custom dispatcher.
    mockWebServer.setDispatcher(this)
    }
    override fun dispatch(request: RecordedRequest): MockResponse {
    // Parse the request's path. Example: `/i/d/document_id/endpoint_name`
    val path = Uri.parse(request.path)
    val pathSegments = path.pathSegments
    if (pathSegments.size < 4 || pathSegments[0] != "i" || pathSegments[1] != "d") {
    return MockResponse().setResponseCode(404)
    }
    val documentId = pathSegments[2] ?: return MockResponse().setResponseCode(404)
    val endpointName = pathSegments[3]
    if (endpointName == "auth") {
    // Handle the authentication request.
    return MockResponse().apply {
    addHeader("Content-Type", "application/json")
    body = getAuthorizationResponseJson().toString()
    }
    } else if (endpointName == "pdf") {
    // Handle the PDF download request.
    return MockResponse().apply {
    addHeader("Content-Type", "application/pdf")
    body = Buffer().readFrom(File(testDocumentPath).inputStream())
    }
    }
    return MockResponse().setResponseCode(404)
    }
    }

    Mocking server documents

    The key improvement in test expressiveness came from mocking documents on the Instant server instead of mocking all responses. We introduced MockedServerDocument, which encapsulates all the data required for the Instant server to mimic the real Document Engine:

    class MockedServerDocument(val documentPath: String, var jwt: String) {
    // Parse the documentId from the JWT.
    val documentId = InstnatJwt.parse(jwt)
    // Example of properties used internally to construct responses in Instant's communication protocol.
    // Token used in all requests after authentication.
    val authenticatedToken = generateAuthenticationToken()
    // Current revision of the document on the server.
    var recordRevision = 0
    fun incrementRecordRevision(): Int {
    return ++recordRevision
    }
    ...
    }

    InstantMockServer keeps a registry of all mocked documents:

    private val mockedDocuments: MutableMap<String, MockedServerDocument> = HashMap()
    fun addDocument(documentPath: String): MockedServerDocument {
    val documentId = UUID.randomUUID().toString()
    val jwt = InstantJwtFactory.generateJwt(documentId)
    mockedDocuments[jwt] = MockedServerDocument(documentPath, jwt)
    }

    API endpoints

    Finally, we separated handling for our API endpoints into different classes, all of which implement the interface:

    interface ServerEndpointHandler {
    fun handleRequest(request: RecordedRequest, document: MockedServerDocument): MockResponse
    }

    InstantMockServer keeps a registry of endpoint handlers together with default implementations of all basic endpoints:

    companion object {
    const val ENDPOINT_AUTH = "auth"
    const val ENDPOINT_PDF = "pdf"
    const val ENDPOINT_SYNC = "sync"
    }
    private val endpoints: MutableMap<String, ServerEndpointHandler> = HashMap()
    private val defaultEndpoints: MutableMap<String, ServerEndpointHandler> = HashMap()
    init {
    defaultEndpoints[ENDPOINT_AUTH] = AuthEndpointHandler(this)
    defaultEndpoints[ENDPOINT_PDF] = PdfEndpointHandler(this)
    defaultEndpoints[ENDPOINT_SYNC] = SyncEndpointHandler(this)
    }
    fun registerEndpoint(endpointName: String, endpointHandler: ServerEndpointHandler) {
    endpoints[endpointName.toLowerCase()] = endpointHandler
    }
    fun unregisterEndpoint(endpointName: String) {
    endpoints.remove(endpointName.toLowerCase())
    }
    fun getEndpoint(endpointName: String): ServerEndpointHandler? {
    return when {
    // First try the registered endpoint.
    endpoints.containsKey(endpointName) -> endpoints[endpointName]
    // Fall back to the default endpoint.
    defaultEndpoints.containsKey(endpointName) -> defaultEndpoints[endpointName]
    else -> null
    }
    }
    override fun dispatch(request: RecordedRequest): MockResponse {
    ...
    return getEndpoint(endpointName)?.handleRequest(serverRequest, mockedServerDocument)
    ?: MockResponse().setResponseCode(404)
    }

    Endpoint handler implementation is fairly simple, and for the most part, just consists of validating inputs and composing a mocked response. For example, this is the handler for our authentication endpoint:

    open class AuthEndpointHandler(val serverContext: MockInstantServer) : ServerEndpointHandler {
    override fun handleRequest(request: RecordedRequest, document: MockedServerDocument): MockResponse {
    // Validate the JWT in the request against the JWT of the mocked document.
    val requestJson = request.bodyJson()
    if (!requestJson.has("jwt") || document.jwt != requestJson.get("jwt")) {
    return MockResponse().setResponseCode(400).setBody("Invalid signature")
    }
    return MockResponse().apply {
    mockResponse.addHeader("content-type", "application/json")
    mockResponse.setBody(getAuthorizationResponseJson(document).toString())
    }
    }
    protected fun getAuthorizationResponseJson(document: MockedServerDocument): JSONObject {
    ...
    }
    }

    We can even implement rather complex use cases by registering a custom endpoint handler in InstantMockServer. For example, we can achieve blocking behavior of our endpoints by wrapping them in a simple decorator:

    open class BlockingEndpointHandler(val delegate: ServerEndpointHandler) : ServerEndpointHandler {
    private var blockingLatch = CountDownLatch(1)
    override fun handleRequest(request: MockedServerRequest, document: MockedServerDocument): MockResponse {
    // Blocks dispatcher thread until the `unblock()` method is called.
    blockingLatch.await()
    return delegate.handleRequest(request, document)
    }
    fun unblock() {
    blockingLatch.countDown()
    }
    }

    Using our abstractions

    We can now rewrite our test from before in a more concise way:

    val server = InstantMockServer()
    // Add a document to the server.
    val mockedDocument = server.addDocument(testDocumentPath)
    // Start the server.
    val serverUrl = server.start
    // Create an Instant client.
    val instantClient = InstantClient.create(context, serverUrl)
    // We'll use the correct JWT for the document generated by InstantMockServer.
    val jwt = mockedDocument.jwt
    // Open the document from the mocked server.
    val document = instantClient.openDocument(jwt)
    assertNotNull(document)

    Notice that we no longer need to verify received requests because we are already verifying them in InstantMockServer’s custom Dispatcher implementation and endpoint handlers. More complex endpoints can also provide specific assertions for the received requests.

    Summary

    In addition to unit tests, your REST clients should be properly tested with integration tests. You can use the MockWebServer(opens in a new tab) library to mock your real server. Time pressure while developing often leads to cutting corners in tests because they’re not part of the production code. This results in bad test design, which then leads to unmaintainable tests. Building domain-specific vocabulary in your tests, rather than relying on low-level primitives, makes tests easier to write and maintain.

    FAQ

    What is MockWebServer and why use it for testing?

    MockWebServer is a library from Square that lets you script HTTP servers in tests. It allows you to run your REST client code against a local mock server and verify that it behaves correctly, without needing a real server running.

    Should I use unit tests or integration tests for REST clients?

    Both. Unit tests verify individual code units work as expected. Integration tests verify correct behavior when your client communicates with a server API. Using both provides comprehensive test coverage.

    How do I add MockWebServer to my Android project?

    Add the dependency to your build.gradle file: testImplementation "com.squareup.okhttp3:mockwebserver:4.12.0" for unit tests, or use androidTestImplementation for instrumentation tests.

    What’s the benefit of creating abstractions over MockWebServer?

    Abstractions like custom server classes and endpoint handlers make tests more readable, reduce code duplication, and allow you to work with domain-specific objects instead of low-level HTTP primitives.

    Tomáš Šurín

    Tomáš Šurín

    Server and Services Senior Staff Software Engineer

    Tomáš has a deep interest in building (and breaking) stuff both in the digital and physical world. In his spare time, you’ll find him relaxing off the grid, cooking good food, playing board games, and discussing science and philosophy.

    Explore related topics

    Try for free Ready to get started?