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:

  1. Start with critical user flows (login, data fetch)
  2. Set up Pact Broker for contract management
  3. Integrate into CI/CD pipeline
  4. Add can-i-deploy checks before releases
  5. Expand coverage to all API interactions

Contract testing is essential for mobile apps consuming microservices, enabling rapid iteration while maintaining stability.