Что такое контрактное тестирование. Consumer-Driven Contracts. Pact или JSON Schema. Защита от breaking changes
Contract Testing проверяет что Provider (Backend) и Consumer (Frontend) согласованы в формате API. Ловит breaking changes до production.
Цель: Научиться создавать контракты между Frontend и Backend для защиты от несовместимых изменений.
# Backend изменил API (breaking change)
# Было:
@app.get("/api/todos/{todo_id}")
def get_todo(todo_id: int):
return {"id": todo_id, "title": "Buy milk", "completed": false}
# Стало:
@app.get("/api/todos/{todo_id}")
def get_todo(todo_id: int):
return {
"id": todo_id,
"text": "Buy milk", # ❌ Переименовали title → text
"status": "pending" # ❌ Переименовали completed → status
}Результат:
1. Consumer (Frontend) определяет ожидания:
"GET /api/todos/1 должен вернуть {id, title, completed}"
2. Contract сохраняется
3. Provider (Backend) тестируется против контракта
✅ Если контракт нарушен → тесты падают ДО деплоя
# frontend/tests/contract_expectations.json
{
"endpoint": "GET /api/todos/{id}",
"response_schema": {
"type": "object",
"required": ["id", "title", "completed"],
"properties": {
"id": {"type": "integer"},
"title": {"type": "string"},
"completed": {"type": "boolean"}
}
}
}# backend/tests/test_contract.py
import jsonschema
import json
def test_get_todo_matches_contract(client):
"""Backend должен соответствовать контракту"""
# Загрузить ожидания от Frontend
with open("contracts/get_todo_schema.json") as f:
schema = json.load(f)
# Запрос к API
response = client.get("/api/todos/1")
# Валидация против контракта
try:
jsonschema.validate(response.json(), schema["response_schema"])
except jsonschema.ValidationError as e:
pytest.fail(f"Contract violated: {e.message}")pytest tests/test_contract.py
# ❌ FAILED: Contract violated: 'title' is a required property✅ Breaking change пойман ДО деплоя!
pip install pact-python# frontend/tests/test_consumer_pact.py
from pact import Consumer, Provider
def test_get_todo_pact():
"""Frontend ожидает определённый формат"""
pact = Consumer('TodoFrontend').has_pact_with(Provider('TodoAPI'))
# Определяем ожидание
(pact
.given('todo with ID 1 exists')
.upon_receiving('a request for todo 1')
.with_request('get', '/api/todos/1')
.will_respond_with(200, body={
'id': 1,
'title': 'Buy milk',
'completed': False
}))
with pact:
# Frontend код использует API
result = requests.get(pact.uri + '/api/todos/1')
assert result.json()['title'] == 'Buy milk'
# Pact file создан: pacts/todofrontend-todoapi.json# backend/tests/test_provider_pact.py
from pact import Verifier
def test_provider_honors_contract():
"""Backend проверяется против контракта от Frontend"""
verifier = Verifier(provider='TodoAPI', provider_base_url="http://localhost:8000")
# Верификация против pact file
output, logs = verifier.verify_pacts('pacts/todofrontend-todoapi.json')
assert output == 0 # Successpytest tests/test_provider_pact.py
# ❌ FAILED:
# Expected field 'title' in response
# Actual response: {'id': 1, 'text': 'Buy milk', ...}┌─────────────────┐
│ Frontend │
│ (Consumer) │
│ │
│ 1. Определяет │
│ ожидания │
│ (Pact) │
└────────┬────────┘
│
│ 2. Публикует Pact
↓
┌─────────────────┐
│ Pact Broker │
│ (Shared repo) │
└────────┬────────┘
│
│ 3. Backend читает Pact
↓
┌─────────────────┐
│ Backend │
│ (Provider) │
│ │
│ 4. Проверяется│
│ против Pact│
│ │
│ ✅ или ❌ │
└─────────────────┘
Вопросы ещё не добавлены
Вопросы для этой подтемы ещё не добавлены.
Далее: Практика: Контракты для Todo API