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

Для максимальной эффективности contract testing важно понимать, как он интегрируется с другими практиками тестирования. Mock серверы в мобильной разработке обеспечивают основу для симуляции providers во время разработки, а стратегии версионирования API определяют, как эволюционировать контракты без нарушения работы существующих клиентов.

Что такое 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 критически важен для мобильных приложений, потребляющих микросервисы, позволяя быструю итерацию при поддержании стабильности.

Смотрите также