API contract testing ensures that mobile applications and backend services communicate correctly without requiring full integration tests. This approach catches breaking changes early, enables independent deployment, and maintains backward compatibility across API versions. While API testing fundamentals focus on validating individual endpoints, contract testing takes a consumer-driven approach to ensure seamless integration.
What is Contract Testing?
Contract testing verifies that two separate systems (consumer and provider) agree on the format of the messages they exchange. Unlike end-to-end tests, contract tests run independently for each service.
Traditional Integration Testing vs Contract Testing
Traditional Integration Testing:
Mobile App → Full Backend Stack → Database
- Slow (seconds to minutes)
- Flaky (network, environment issues)
- Requires full infrastructure
- Difficult to reproduce edge cases
Contract Testing:
Mobile App → Contract Stub (Pact Mock)
Backend API → Contract Verification (Pact Provider)
- Fast (milliseconds)
- Reliable (no network dependencies)
- Runs in CI/CD pipelines
- Easy edge case simulation
Core Concepts
Consumer-Driven Contracts
The consumer (mobile app) defines expectations for provider (backend API) behavior. The provider must honor these contracts. This is particularly crucial in modern mobile testing strategies where apps must adapt to evolving backend services.
Benefits:
- Mobile teams unblocked by backend delays
- API changes validated before deployment
- Clear communication between teams
- Versioning and deprecation strategies
Pact Workflow
1. Consumer (Mobile) writes Pact tests
↓
2. Generates Pact contract file (JSON)
↓
3. Contract published to Pact Broker
↓
4. Provider (Backend) verifies contract
↓
5. Results published to Pact Broker
↓
6. Can-I-Deploy check before release
Pact for Mobile Applications
Android Implementation
Setup (build.gradle.kts):
dependencies {
testImplementation("au.com.dius.pact.consumer:junit5:4.6.1")
testImplementation("org.junit.jupiter:junit-jupiter:5.10.0")
}
Consumer Pact Test (UserApiPactTest.kt):
import au.com.dius.pact.consumer.dsl.PactBuilder
import au.com.dius.pact.consumer.junit5.PactConsumerTestExt
import au.com.dius.pact.consumer.junit5.PactTestFor
import au.com.dius.pact.core.model.V4Pact
import au.com.dius.pact.core.model.annotations.Pact
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.Assertions.*
@ExtendWith(PactConsumerTestExt::class)
@PactTestFor(providerName = "UserService")
class UserApiPactTest {
@Pact(consumer = "MobileApp")
fun getUserByIdPact(builder: PactBuilder): V4Pact {
return builder
.usingLegacyDsl()
.given("user 123 exists")
.uponReceiving("a request for user 123")
.path("/api/users/123")
.method("GET")
.headers(mapOf("Accept" to "application/json"))
.willRespondWith()
.status(200)
.headers(mapOf("Content-Type" to "application/json"))
.body("""
{
"id": 123,
"username": "john_doe",
"email": "john@example.com",
"createdAt": "2024-01-15T10:30:00Z"
}
""".trimIndent())
.toPact()
.asV4Pact().get()
}
@Test
@PactTestFor(pactMethod = "getUserByIdPact", port = "8080")
fun testGetUserById() {
val apiClient = ApiClient("http://localhost:8080")
runBlocking {
val user = apiClient.getUser(123)
assertEquals(123, user.id)
assertEquals("john_doe", user.username)
assertEquals("john@example.com", user.email)
assertNotNull(user.createdAt)
}
}
@Pact(consumer = "MobileApp")
fun getUserNotFoundPact(builder: PactBuilder): V4Pact {
return builder
.usingLegacyDsl()
.given("user 999 does not exist")
.uponReceiving("a request for non-existent user")
.path("/api/users/999")
.method("GET")
.willRespondWith()
.status(404)
.headers(mapOf("Content-Type" to "application/json"))
.body("""
{
"error": "user_not_found",
"message": "User with ID 999 not found"
}
""".trimIndent())
.toPact()
.asV4Pact().get()
}
@Test
@PactTestFor(pactMethod = "getUserNotFoundPact", port = "8080")
fun testGetUserNotFound() {
val apiClient = ApiClient("http://localhost:8080")
runBlocking {
val exception = assertThrows(ApiException::class.java) {
apiClient.getUser(999)
}
assertEquals(404, exception.statusCode)
assertEquals("user_not_found", exception.errorCode)
}
}
@Pact(consumer = "MobileApp")
fun createUserPact(builder: PactBuilder): V4Pact {
return builder
.usingLegacyDsl()
.given("ready to create user")
.uponReceiving("a request to create user")
.path("/api/users")
.method("POST")
.headers(mapOf(
"Content-Type" to "application/json",
"Accept" to "application/json"
))
.body("""
{
"username": "new_user",
"email": "new@example.com",
"password": "secure123"
}
""".trimIndent())
.willRespondWith()
.status(201)
.headers(mapOf(
"Content-Type" to "application/json",
"Location" to "/api/users/456"
))
.body("""
{
"id": 456,
"username": "new_user",
"email": "new@example.com",
"createdAt": "2024-01-20T14:00:00Z"
}
""".trimIndent())
.toPact()
.asV4Pact().get()
}
@Test
@PactTestFor(pactMethod = "createUserPact", port = "8080")
fun testCreateUser() {
val apiClient = ApiClient("http://localhost:8080")
runBlocking {
val newUser = CreateUserRequest(
username = "new_user",
email = "new@example.com",
password = "secure123"
)
val createdUser = apiClient.createUser(newUser)
assertEquals(456, createdUser.id)
assertEquals("new_user", createdUser.username)
assertNotNull(createdUser.createdAt)
}
}
}
Generated Pact Contract (pacts/MobileApp-UserService.json):
{
"consumer": {
"name": "MobileApp"
},
"provider": {
"name": "UserService"
},
"interactions": [
{
"description": "a request for user 123",
"providerState": "user 123 exists",
"request": {
"method": "GET",
"path": "/api/users/123",
"headers": {
"Accept": "application/json"
}
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"body": {
"id": 123,
"username": "john_doe",
"email": "john@example.com",
"createdAt": "2024-01-15T10:30:00Z"
}
}
}
],
"metadata": {
"pactSpecification": {
"version": "4.0"
}
}
}
iOS Implementation
Setup (Package.swift):
dependencies: [
.package(url: "https://github.com/pact-foundation/pact-swift", from: "0.4.0")
]
Consumer Pact Test (UserAPIPactTests.swift):
import XCTest
import PactSwift
@testable import YourApp
class UserAPIPactTests: XCTestCase {
var mockService: MockService!
override func setUp() {
super.setUp()
mockService = MockService(
consumer: "MobileApp",
provider: "UserService"
)
}
func testGetUserById() {
// Define interaction
mockService
.given("user 123 exists")
.uponReceiving("a request for user 123")
.withRequest(
method: .GET,
path: "/api/users/123",
headers: ["Accept": "application/json"]
)
.willRespondWith(
status: 200,
headers: ["Content-Type": "application/json"],
body: [
"id": 123,
"username": "john_doe",
"email": "john@example.com",
"createdAt": "2024-01-15T10:30:00Z"
]
)
// Run test
mockService.run { [unowned self] completed in
let apiClient = APIClient(baseURL: self.mockService.baseUrl)
apiClient.fetchUser(id: 123) { result in
switch result {
case .success(let user):
XCTAssertEqual(user.id, 123)
XCTAssertEqual(user.username, "john_doe")
XCTAssertEqual(user.email, "john@example.com")
completed()
case .failure(let error):
XCTFail("Request failed: \(error)")
completed()
}
}
}
}
func testGetUserNotFound() {
mockService
.given("user 999 does not exist")
.uponReceiving("a request for non-existent user")
.withRequest(
method: .GET,
path: "/api/users/999"
)
.willRespondWith(
status: 404,
headers: ["Content-Type": "application/json"],
body: [
"error": "user_not_found",
"message": "User with ID 999 not found"
]
)
mockService.run { [unowned self] completed in
let apiClient = APIClient(baseURL: self.mockService.baseUrl)
apiClient.fetchUser(id: 999) { result in
switch result {
case .success:
XCTFail("Should have failed")
case .failure(let error):
if case .notFound(let message) = error {
XCTAssertTrue(message.contains("not found"))
}
}
completed()
}
}
}
func testCreateUser() {
let requestBody: [String: Any] = [
"username": "new_user",
"email": "new@example.com",
"password": "secure123"
]
mockService
.given("ready to create user")
.uponReceiving("a request to create user")
.withRequest(
method: .POST,
path: "/api/users",
headers: [
"Content-Type": "application/json",
"Accept": "application/json"
],
body: requestBody
)
.willRespondWith(
status: 201,
headers: [
"Content-Type": "application/json",
"Location": "/api/users/456"
],
body: [
"id": 456,
"username": "new_user",
"email": "new@example.com",
"createdAt": "2024-01-20T14:00:00Z"
]
)
mockService.run { [unowned self] completed in
let apiClient = APIClient(baseURL: self.mockService.baseUrl)
let newUser = CreateUserRequest(
username: "new_user",
email: "new@example.com",
password: "secure123"
)
apiClient.createUser(newUser) { result in
switch result {
case .success(let user):
XCTAssertEqual(user.id, 456)
XCTAssertEqual(user.username, "new_user")
case .failure(let error):
XCTFail("Request failed: \(error)")
}
completed()
}
}
}
}
Provider Verification (Backend)
Spring Boot Provider Verification
Provider verification is essential in microservices API architecture, where multiple services must maintain contract compatibility.
Setup (build.gradle.kts):
dependencies {
testImplementation("au.com.dius.pact.provider:junit5spring:4.6.1")
testImplementation("org.springframework.boot:spring-boot-starter-test")
}
Provider Test (UserServiceProviderTest.kt):
import au.com.dius.pact.provider.junit5.PactVerificationContext
import au.com.dius.pact.provider.junit5.PactVerificationInvocationContextProvider
import au.com.dius.pact.provider.junitsupport.Provider
import au.com.dius.pact.provider.junitsupport.State
import au.com.dius.pact.provider.junitsupport.loader.PactBroker
import au.com.dius.pact.provider.spring.junit5.MockMvcTestTarget
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.TestTemplate
import org.junit.jupiter.api.extension.ExtendWith
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.test.web.servlet.MockMvc
@SpringBootTest
@AutoConfigureMockMvc
@Provider("UserService")
@PactBroker(
host = "pact-broker.example.com",
authentication = PactBrokerAuth(
username = "\${PACT_BROKER_USERNAME}",
password = "\${PACT_BROKER_PASSWORD}"
)
)
class UserServiceProviderTest {
@Autowired
private lateinit var mockMvc: MockMvc
@Autowired
private lateinit var userRepository: UserRepository
@BeforeEach
fun setUp(context: PactVerificationContext) {
context.target = MockMvcTestTarget(mockMvc)
}
@TestTemplate
@ExtendWith(PactVerificationInvocationContextProvider::class)
fun pactVerificationTestTemplate(context: PactVerificationContext) {
context.verifyInteraction()
}
@State("user 123 exists")
fun userExistsState() {
// Prepare test data
val user = User(
id = 123,
username = "john_doe",
email = "john@example.com",
createdAt = Instant.parse("2024-01-15T10:30:00Z")
)
userRepository.save(user)
}
@State("user 999 does not exist")
fun userDoesNotExistState() {
// Ensure user doesn't exist
userRepository.deleteById(999)
}
@State("ready to create user")
fun readyToCreateUserState() {
// Clean up any existing test data
userRepository.deleteByUsername("new_user")
}
}
Pact Broker Setup
Docker Compose Configuration
version: '3'
services:
postgres:
image: postgres:15
environment:
POSTGRES_USER: pact
POSTGRES_PASSWORD: pact
POSTGRES_DB: pact_broker
volumes:
- postgres-volume:/var/lib/postgresql/data
pact-broker:
image: pactfoundation/pact-broker:latest
ports:
- "9292:9292"
depends_on:
- postgres
environment:
PACT_BROKER_DATABASE_USERNAME: pact
PACT_BROKER_DATABASE_PASSWORD: pact
PACT_BROKER_DATABASE_HOST: postgres
PACT_BROKER_DATABASE_NAME: pact_broker
PACT_BROKER_ALLOW_PUBLIC_READ: "true"
PACT_BROKER_BASIC_AUTH_USERNAME: admin
PACT_BROKER_BASIC_AUTH_PASSWORD: admin123
volumes:
postgres-volume:
Start Pact Broker:
docker-compose up -d
# Access at http://localhost:9292
Publishing Contracts to Pact Broker
Gradle Task (build.gradle.kts)
pact {
publish {
pactBrokerUrl = "http://localhost:9292"
pactBrokerUsername = "admin"
pactBrokerPassword = "admin123"
tags = listOf("dev", "android-v1.2.0")
version = "1.2.0"
}
}
Publish Command:
./gradlew pactPublish
CI/CD Integration (GitHub Actions)
name: Pact Contract Testing
on: [push, pull_request]
jobs:
consumer-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up JDK
uses: actions/setup-java@v3
with:
distribution: 'temurin'
java-version: '17'
- name: Run Pact Tests
run: ./gradlew test
- name: Publish Pacts
if: github.ref == 'refs/heads/main'
run: |
./gradlew pactPublish \
-Ppact.broker.url=${{ secrets.PACT_BROKER_URL }} \
-Ppact.broker.username=${{ secrets.PACT_BROKER_USER }} \
-Ppact.broker.password=${{ secrets.PACT_BROKER_PASS }} \
-Ppact.version=${{ github.sha }}
provider-tests:
runs-on: ubuntu-latest
needs: consumer-tests
steps:
- uses: actions/checkout@v3
- name: Set up JDK
uses: actions/setup-java@v3
with:
distribution: 'temurin'
java-version: '17'
- name: Verify Pact Contracts
run: |
./gradlew pactVerify \
-Ppact.broker.url=${{ secrets.PACT_BROKER_URL }} \
-Ppact.broker.username=${{ secrets.PACT_BROKER_USER }} \
-Ppact.broker.password=${{ secrets.PACT_BROKER_PASS }}
Can-I-Deploy Check
Before deploying mobile app or backend, verify contract compatibility:
# Check if mobile app can deploy
pact-broker can-i-deploy \
--pacticipant MobileApp \
--version 1.2.0 \
--to-environment production \
--broker-base-url http://localhost:9292 \
--broker-username admin \
--broker-password admin123
# Check if backend can deploy
pact-broker can-i-deploy \
--pacticipant UserService \
--version 2.5.0 \
--to-environment production \
--broker-base-url http://localhost:9292
CI/CD Integration:
- name: Can I Deploy
run: |
docker run --rm \
pactfoundation/pact-cli:latest \
can-i-deploy \
--pacticipant MobileApp \
--version ${{ github.sha }} \
--to-environment production \
--broker-base-url ${{ secrets.PACT_BROKER_URL }} \
--broker-username ${{ secrets.PACT_BROKER_USER }} \
--broker-password ${{ secrets.PACT_BROKER_PASS }} \
--retry-while-unknown 10 \
--retry-interval 30
Versioning and Backward Compatibility
Handling API Changes
Non-Breaking Change (Adding Optional Field):
// Old contract
{
"id": 123,
"username": "john_doe",
"email": "john@example.com"
}
// New contract (backward compatible)
{
"id": 123,
"username": "john_doe",
"email": "john@example.com",
"avatar": "https://cdn.example.com/avatar.jpg" // Optional, new field
}
Breaking Change (Removing Field):
// Provider must support old contract for transition period
@GetMapping("/api/users/{id}")
fun getUser(@PathVariable id: Long, @RequestHeader("API-Version") version: String?): UserResponse {
val user = userRepository.findById(id)
return when (version) {
"v1" -> UserResponseV1(user) // Includes deprecated fields
"v2", null -> UserResponseV2(user) // New contract
else -> throw UnsupportedVersionException()
}
}
Mobile App Version Strategy
// build.gradle.kts
android {
defaultConfig {
// Consumer version sent to Pact Broker
buildConfigField("String", "PACT_VERSION", "\"${versionName}\"")
buildConfigField("String", "API_VERSION", "\"v2\"")
}
}
// API Client
class ApiClient(private val baseURL: String) {
private val client = OkHttpClient.Builder()
.addInterceptor { chain ->
val request = chain.request().newBuilder()
.addHeader("API-Version", BuildConfig.API_VERSION)
.build()
chain.proceed(request)
}
.build()
}
Best Practices
1. Test Real Scenarios
@Pact(consumer = "MobileApp")
fun rateLimitedRequestPact(builder: PactBuilder): V4Pact {
return builder
.usingLegacyDsl()
.given("rate limit exceeded for user 123")
.uponReceiving("request that triggers rate limit")
.path("/api/products")
.method("GET")
.headers(mapOf("Authorization" to "Bearer token123"))
.willRespondWith()
.status(429)
.headers(mapOf(
"Content-Type" to "application/json",
"Retry-After" to "60"
))
.body("""
{
"error": "rate_limit_exceeded",
"message": "Too many requests",
"retryAfter": 60
}
""".trimIndent())
.toPact()
.asV4Pact().get()
}
2. Use Matchers for Flexible Contracts
Matchers allow flexible contract validation, similar to how REST Assured handles API assertions, but at the contract level rather than runtime.
import au.com.dius.pact.consumer.dsl.LambdaDsl.*
.body(
newJsonBody { obj ->
obj.numberType("id", 123)
obj.stringType("username", "john_doe")
obj.stringMatcher("email", ".*@example\\.com", "john@example.com")
obj.datetime("createdAt", "yyyy-MM-dd'T'HH:mm:ss'Z'", "2024-01-15T10:30:00Z")
}.build()
)
3. Consumer Version Selectors
@PactBroker(
host = "pact-broker.example.com",
consumerVersionSelectors = [
ConsumerVersionSelector(tag = "production"),
ConsumerVersionSelector(tag = "main", latest = true)
]
)
4. Provider States with Dynamic Data
@State("user :id exists", action = StateChangeAction.SETUP)
fun userExistsDynamic(params: Map<String, Any>) {
val userId = params["id"] as Int
val user = User(id = userId, username = "user_$userId")
userRepository.save(user)
}
Conclusion
Contract testing with Pact provides:
- Fast Feedback: Catch breaking changes in seconds
- Independent Deployment: Mobile and backend teams work in parallel
- Confidence: Deploy without fear of integration failures
- Documentation: Living API contracts
Implementation Roadmap:
- Start with critical user flows (login, data fetch)
- Set up Pact Broker for contract management
- Integrate into CI/CD pipeline
- Add can-i-deploy checks before releases
- Expand coverage to all API interactions
Contract testing is essential for mobile apps consuming microservices, enabling rapid iteration while maintaining stability.