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 контракты
Дорожная Карта Реализации:
- Начните с критических пользовательских потоков (login, получение данных)
- Настройте Pact Broker для управления контрактами
- Интегрируйте в CI/CD пайплайн
- Добавьте can-i-deploy проверки перед релизами
- Расширьте покрытие на все API взаимодействия
Contract testing критически важен для мобильных приложений, потребляющих микросервисы, позволяя быструю итерацию при поддержании стабильности.