API contract testing гарантирует, что мобильные приложения и backend сервисы правильно взаимодействуют без необходимости полных интеграционных тестов. Этот подход рано выявляет breaking changes, обеспечивает независимое развертывание и поддерживает обратную совместимость между версиями API. В то время как основы API тестирования фокусируются на валидации отдельных endpoints, contract testing использует подход, управляемый потребителем, для обеспечения бесшовной интеграции.

Что такое Contract Testing?

Contract testing проверяет, что две отдельные системы (consumer и provider) согласны на формат сообщений, которыми они обмениваются. В отличие от end-to-end тестов, contract тесты выполняются независимо для каждого сервиса.

Традиционное Интеграционное Тестирование vs Contract Testing

Традиционное Интеграционное Тестирование:

Мобильное Приложение → Полный Backend Stack → База Данных
- Медленное (секунды-минуты)
- Нестабильное (сеть, проблемы окружения)
- Требует полную инфраструктуру
- Сложно воспроизвести крайние случаи

Contract Testing:

Мобильное Приложение → Contract Stub (Pact Mock)
Backend API → Contract Verification (Pact Provider)
- Быстрое (миллисекунды)
- Надёжное (нет сетевых зависимостей)
- Работает в CI/CD пайплайнах
- Лёгкая симуляция крайних случаев

Основные Концепции

Consumer-Driven Contracts

Consumer (мобильное приложение) определяет ожидания от поведения provider (backend API). Provider должен соблюдать эти контракты. Это особенно важно в современных стратегиях мобильного тестирования, где приложения должны адаптироваться к развивающимся backend сервисам.

Преимущества:

  • Мобильные команды не блокируются задержками backend
  • API изменения валидируются перед развертыванием
  • Чёткая коммуникация между командами
  • Стратегии версионирования и deprecation

Рабочий Процесс Pact

1. Consumer (Мобильное) пишет Pact тесты
2. Генерирует файл Pact контракта (JSON)
3. Контракт публикуется в Pact Broker
4. Provider (Backend) верифицирует контракт
5. Результаты публикуются в Pact Broker
6. Проверка Can-I-Deploy перед релизом

Pact для Мобильных Приложений

Реализация Android

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

@ExtendWith(PactConsumerTestExt::class)
@PactTestFor(providerName = "UserService")
class UserApiPactTest {

    @Pact(consumer = "MobileApp")
    fun getUserByIdPact(builder: PactBuilder): V4Pact {
        return builder
            .usingLegacyDsl()
            .given("пользователь 123 существует")
            .uponReceiving("запрос для пользователя 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": "ivan_ivanov",
                  "email": "ivan@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("ivan_ivanov", user.username)
        }
    }
}

Верификация Provider (Backend)

Верификация Spring Boot Provider

Верификация provider критически важна в архитектуре API микросервисов, где множество сервисов должны поддерживать совместимость контрактов.

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):

@SpringBootTest
@AutoConfigureMockMvc
@Provider("UserService")
@PactBroker(host = "pact-broker.example.com")
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("пользователь 123 существует")
    fun userExistsState() {
        val user = User(
            id = 123,
            username = "ivan_ivanov",
            email = "ivan@example.com"
        )
        userRepository.save(user)
    }
}

Настройка Pact Broker

Конфигурация Docker Compose

version: '3'

services:
  postgres:
    image: postgres:15
    environment:
      POSTGRES_USER: pact
      POSTGRES_PASSWORD: pact
      POSTGRES_DB: pact_broker

  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

Версионирование и Обратная Совместимость

Обработка API Изменений

Не-Breaking Изменение (Добавление Опционального Поля):

// Старый контракт
{
  "id": 123,
  "username": "ivan_ivanov",
  "email": "ivan@example.com"
}

// Новый контракт (обратно совместимый)
{
  "id": 123,
  "username": "ivan_ivanov",
  "email": "ivan@example.com",
  "avatar": "https://cdn.example.com/avatar.jpg" // Опциональное, новое поле
}

Breaking Изменение (Удаление Поля):

// Provider должен поддерживать старый контракт в течение переходного периода
@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) // Включает устаревшие поля
        "v2", null -> UserResponseV2(user) // Новый контракт
        else -> throw UnsupportedVersionException()
    }
}

Лучшие Практики

1. Тестировать Реальные Сценарии

@Pact(consumer = "MobileApp")
fun rateLimitedRequestPact(builder: PactBuilder): V4Pact {
    return builder
        .usingLegacyDsl()
        .given("превышен rate limit для пользователя 123")
        .uponReceiving("запрос, активирующий rate limit")
        .path("/api/products")
        .method("GET")
        .willRespondWith()
        .status(429)
        .body("""
            {
              "error": "rate_limit_exceeded",
              "message": "Слишком много запросов",
              "retryAfter": 60
            }
        """.trimIndent())
        .toPact()
        .asV4Pact().get()
}

2. Использовать Matchers для Гибких Контрактов

Matchers позволяют гибкую валидацию контрактов, подобно тому как REST Assured обрабатывает API assertions, но на уровне контракта, а не runtime.

import au.com.dius.pact.consumer.dsl.LambdaDsl.*

.body(
    newJsonBody { obj ->
        obj.numberType("id", 123)
        obj.stringType("username", "ivan_ivanov")
        obj.stringMatcher("email", ".*@example\\.com", "ivan@example.com")
        obj.datetime("createdAt", "yyyy-MM-dd'T'HH:mm:ss'Z'")
    }.build()
)

Заключение

Contract testing с Pact обеспечивает:

  • Быструю Обратную Связь: Обнаруживает breaking changes за секунды
  • Независимое Развёртывание: Мобильные и backend команды работают параллельно
  • Уверенность: Развертывайте без страха интеграционных сбоев
  • Документацию: Живые API контракты

Дорожная Карта Реализации:

  1. Начните с критических пользовательских потоков (login, получение данных)
  2. Настройте Pact Broker для управления контрактами
  3. Интегрируйте в CI/CD пайплайн
  4. Добавьте can-i-deploy проверки перед релизами
  5. Расширьте покрытие на все API взаимодействия

Contract testing критически важен для мобильных приложений, потребляющих микросервисы, позволяя быструю итерацию при поддержании стабильности.