How to Test REST API Clients in Android Apps

Share

PSPDFKit Instant is a library built on top of PSPDFKit that provides real-time collaboration features, allowing users to seamlessly share, edit, and annotate PDF documents across multiple platforms. For the most part, this library stands as a client for the REST API provided by PSPDFKit Server.

Each quality piece of software needs extensive text coverage, so while developing our Instant client for Android, we provided unit tests as a result of using TDD. This ensured that our code units worked as expected. To add an additional layer of reliability to our production code, we decided to provide a set of integration tests for the library. The goal for these tests was to ensure the correct client behavior when communicating with a previously specified server API. For that, we used a local mock server that implemented our real server API and behavior.

MockWebServer

The PSPDFKit Instant client library uses OkHttp for handling all HTTP calls. OkHttp provides a ready-to-use MockWebServer 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 be added to the build.gradle file:

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:

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.

Note:TestInstantJwtFactory is built around the JWT generation library JJWT. Its implementation is not 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.

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:

valserver=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:

// 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.valjwt=TestInstantJwtFactory.generateJwt("document_id")// Open the document from the mocked server.valdocument=instantClient.openDocument(jwt)assertNotNull(document)

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

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. As you can see, this requires a fair amount of work and duplicated code, and as such is not very scalable.

Tests should be treated like first-class citizens, and we should spend time properly designing their architecture. Providing proper architecture for tests results in more reliable, easier to write, and generally more understandable tests. Thus we decided to introduce proper abstractions over the low-level PSPDFKit Server 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:

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:

Mocking Server Documents

We achieved the biggest step in expressiveness of our tests by 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 PSPDFKit Server:

classMockedServerDocument(valdocumentPath:String,varjwt:String){// Parse the documentId from the JWT.valdocumentId=InstnatJwt.parse(jwt)// Example of properties used internally to construct responses in Instant's communication protocol.// Token used in all requests after authentication.valauthenticatedToken=generateAuthenticationToken()// Current revision of the document on the server.varrecordRevision=0funincrementRecordRevision():Int{return++recordRevision}...}

companionobject{constvalENDPOINT_AUTH="auth"constvalENDPOINT_PDF="pdf"constvalENDPOINT_SYNC="sync"}privatevalendpoints:MutableMap<String,ServerEndpointHandler>=HashMap()privatevaldefaultEndpoints:MutableMap<String,ServerEndpointHandler>=HashMap()init{defaultEndpoints[ENDPOINT_AUTH]=AuthEndpointHandler(this)defaultEndpoints[ENDPOINT_PDF]=PdfEndpointHandler(this)defaultEndpoints[ENDPOINT_SYNC]=SyncEndpointHandler(this)}funregisterEndpoint(endpointName:String,endpointHandler:ServerEndpointHandler){endpoints[endpointName.toLowerCase()]=endpointHandler}fununregisterEndpoint(endpointName:String){endpoints.remove(endpointName.toLowerCase())}fungetEndpoint(endpointName:String):ServerEndpointHandler?{returnwhen{// First try the registered endpoint.endpoints.containsKey(endpointName)->endpoints[endpointName]// Fall back to the default endpoint.defaultEndpoints.containsKey(endpointName)->defaultEndpoints[endpointName]else->null}}overridefundispatch(request:RecordedRequest):MockResponse{...returngetEndpoint(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:

openclassAuthEndpointHandler(valserverContext:MockInstantServer):ServerEndpointHandler{overridefunhandleRequest(request:RecordedRequest,document:MockedServerDocument):MockResponse{// Validate the JWT in the request against the JWT of the mocked document.valrequestJson=request.bodyJson()if(!requestJson.has("jwt")||document.jwt!=requestJson.get("jwt")){returnMockResponse().setResponseCode(400).setBody("Invalid signature")}returnMockResponse().apply{mockResponse.addHeader("content-type","application/json")mockResponse.setBody(getAuthorizationResponseJson(document).toString())}}protectedfungetAuthorizationResponseJson(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:

Using Our Abstractions

valserver=InstantMockServer()// Add a document to the server.valmockedDocument=server.addDocument(testDocumentPath)// Start the server.valserverUrl=server.start// Create an Instant client.valinstantClient=InstantClient.create(context,serverUrl)// We'll use the correct JWT for the document generated by InstantMockServer.valjwt=mockedDocument.jwt// Open the document from the mocked server.valdocument=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 too. You can use the MockWebServer library to mock your real server. Time pressure while developing usually makes you cut corners in your tests because they are not part of the production code. This leads to bad test design, which then leads to unmaintainable tests. This article showed how to build domain-specific vocabulary in your tests instead of relying on the low-level primitives of your chosen test framework.