Pact Contract Testing in Android

Barry Irvine
Go City Engineering
5 min readFeb 6, 2023

Here at Go City, we’re big fans of consumer driven contract testing (to wit: ensuring that the response of a service [in my case an API] matches what the consumer of that service expects for a given scenario). This test can be carried out as an isolated unit test — you don’t need a full integration test.

We’ve already started creating consumer and provider contract tests for many of our backend services but as an Android engineer it fell on my shoulders to create the first consumer Pact test in Android. Rather than reinventing the wheel, I googled “Pact testing in Android” but realised that I needed to hone my wheel-making skills after all.

I found one Medium article where all the code samples were in embedded images that were now dead links and a question on StackOverflow asking how to do it in an Android instrumented test (which is not the right approach at all). Furthermore, any examples that did exist were in Java and heavily focused on SpringBoot and JUnit5.

Luckily the examples in the Pact implementation guide gave me enough pointers to create a Kotlin JUnit4 version using Retrofit as I normally would.

The first thing to do was to add the relevant Pact dependency to the gradle file of my network module where the API logic is located.

  testImplementation "au.com.dius.pact.consumer:junit:4.4.4"

Then I needed to create a Pact test for the API I was calling. It turns out that there is a ConsumerPactTest class that I needed to use. This includes four methods that need to be overridden and a single test annotated method called testPact. The empty Kotlin skeleton looks like this:

/* MyPactTest.kt */
class MyPactTest : ConsumerPactTest() {
override fun createPact(builder: PactDslWithProvider): RequestResponsePact {
TODO("Not yet implemented")
}

override fun providerName(): String {
TODO("Not yet implemented")
}

override fun consumerName(): String {
TODO("Not yet implemented")
}

override fun runTest(mockServer: MockServer, context: PactTestExecutionContext) {
TODO("Not yet implemented")
}
}

As per the documentation, the providerName() should be the name of the API provider that we’re going to mock and consumerName() is the API consumer that we’re testing. So let’s imagine we’re testing a customer API: maybe we do this:

/* MyPactTest.kt */
override fun providerName(): String = "customer-service"

override fun consumerName(): String = "android-customer-service"

Now, let’s look at the runTest method. This needs to use the instance of MockServer to create the Retrofit implementation of our API. This is what our API looks like.

/* ConsumerApi.kt */
internal interface CustomerApi {

@GET("/customer/{id}")
suspend fun getCustomer(
@Path("id") id: String
): Response<CustomerResponse>
}

I’m using Kotlin serialization but if you’re using Moshi or Gson or something else then just modify the code to incorporate the correct convertor factory for your response classes.

/* MyPactTest.kt */
override fun runTest(mockServer: MockServer, context: PactTestExecutionContext) {
val retrofit = Retrofit.Builder()
.baseUrl(mockServer.getUrl())
.addConverterFactory(/* Insert your normal factory here */)
.build()
val api = retrofit.create(CustomerApi::class.java)

Normally, to run a suspend function in a unit test, we’d use the standard coroutines runTest method but we’re already inside a method of the same name(!!) so we need to fully qualify it.

/* MyPactTest.kt */
kotlinx.coroutines.test.runTest {
api.getCustomer(id = "123456")
}

Now we need to define what we expect our API to do when we request customer 123456. We need to define the Pact contract.

Let’s imagine that we expect to get something like this back from our Customer end point

{
"id":123456,
"firstName":"Barry",
"lastName":"Irvine",
"phoneNumbers":[
{
"type":"MOBILE",
"number":"07xxxxxxxxx"
}
]
}

We might write our contract like this:

/* MyPactTest.kt */

override fun createPact(builder: PactDslWithProvider): RequestResponsePact =
builder
.given("A customer exists")
.uponReceiving("A request for customer information by id")
.method("GET")
.path("/customer/1234567")
.willRespondWith()
.status(200)
.body(
LambdaDsl.newJsonBody {
it.stringMatcher("id", "\\d{5-10}", "1234567")
it.stringType("firstName", "Barry")
it.stringType("lastName", "Irvine")
it.array("phoneNumbers") { phoneNumbers ->
phoneNumbers.`object` { number ->
number.stringMatcher("type", "MOBILE|HOME|WORK", "MOBILE")
number.stringType("number", "0711-111-111")
}
}
}.build()
).toPact()

So we’ve defined our contract for what happens when we perform a request and the customer details are found. We get a customer id back (we’re expecting it to be 5–10 digits long), a couple of name fields and then some phone numbers. The phone number type is an enum and we’ve specified the regex in the contract.

We may also want to add another contract in our createPact for what happens when a customer is not found to confirm that the API returns a 404 for example.

/* MyPactTest.kt */

.given("No customer is found")
.uponReceiving("A request for customer information by id")
.method("GET")
.path("/customer/not_a_customer")
.willRespondWith()
.status(404)

And then we need to include that in our runTest as well:

/* MyPactTest.kt */

kotlinx.coroutines.test.runTest {
api.getCustomer(id = "123456")
api.getCustomer(id = "not_a_customer")
}

And that’s the basic implementation of a consumer Pact test.

⚠️ You should only make assertions and define contracts that will affect the consumer if they change. Don’t accidentally create end-to-end functional tests. See the official docs for more information.

As per the quick start guide you should also use the PactProviderRule and add the @PactVerification and @Pact to the runTest and createPact methods appropriately. My full MyPactTest looks like this:

/* MyPactTest.kt */
package com.gocity.pacttest

import au.com.dius.pact.consumer.MockServer
import au.com.dius.pact.consumer.PactTestExecutionContext
import au.com.dius.pact.consumer.dsl.LambdaDsl
import au.com.dius.pact.consumer.dsl.PactDslWithProvider
import au.com.dius.pact.consumer.junit.ConsumerPactTest
import au.com.dius.pact.consumer.junit.PactProviderRule
import au.com.dius.pact.consumer.junit.PactVerification
import au.com.dius.pact.core.model.RequestResponsePact
import au.com.dius.pact.core.model.annotations.Pact
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType
import org.junit.Rule
import retrofit2.Retrofit

class MyPactTest : ConsumerPactTest() {

@get:Rule
internal val rule: PactProviderRule
get() = PactProviderRule("customer-service", this)

@Pact(consumer = "android-customer-service", provider = "customer-service")
override fun createPact(builder: PactDslWithProvider): RequestResponsePact =
builder
.given("A customer exists")
.uponReceiving("A request for customer information by id")
.method("GET")
.path("/customer/1234567")
.willRespondWith()
.status(200)
.body(
LambdaDsl.newJsonBody {
it.stringMatcher("id", "\\d{5-10}", "1234567")
it.stringType("firstName", "Barry")
it.stringType("lastName", "Irvine")
it.array("phoneNumbers") { phoneNumbers ->
phoneNumbers.`object` { number ->
number.stringMatcher("type", "MOBILE|HOME|WORK", "MOBILE")
number.stringType("number", "0711-111-111")
}
}
}.build()
)
.given("No customer is found")
.uponReceiving("A request for customer information by id")
.method("GET")
.path("/customer/not_a_customer")
.willRespondWith()
.status(404)
.toPact()


override fun providerName(): String = "customer-service"

override fun consumerName(): String = "android-customer-service"

@PactVerification("customer-service", fragment = "pactGetCustomerById")
override fun runTest(mockServer: MockServer, context: PactTestExecutionContext) {
val retrofit = Retrofit.Builder()
.baseUrl(mockServer.getUrl())
.addConverterFactory(Json {
encodeDefaults = true
ignoreUnknownKeys = true
}.asConverterFactory("application/json".toMediaType()))
.build()
val api = retrofit.create(CustomerApi::class.java)
kotlinx.coroutines.test.runTest {
api.getCustomer(id = "123456")
api.getCustomer(id = "not_a_customer")
}
}
}

I can now run the test and it spits out a Pact json file in the build/pacts directory of my network module. This can then be uploaded to a pact broker and the customer API provider needs to write their tests to prove that they meet the contract. Eventually we can integrate this into our CI pipelines and even block deployments that break the contract.

I’m just at the start of my Pact test writing journey but I’m looking forward to writing more consumer tests, integrating the Pact gradle plugin and using it in the build process.

If this has whet your appetite to learn more about Pact, here are some useful links:

Pact Consumer JVM JUnit guide https://docs.pact.io/implementation_guides/jvm/consumer/junit

Writing Consumer tests

https://docs.pact.io/consumer

As usual, if you enjoyed this post, I’d appreciate some claps, a follow, share or tweet and if you have any questions please don’t hesitate to post them below.

--

--

Barry Irvine
Go City Engineering

Writing elegant Android code is my passion — but with 20+ years experience in roles from programme delivery to working at the coal face, I’ve seen it all.