Когда команда бэкенда переименовывает поле ответа, E2E-тесты в staging ловят это после деплоя. Контрактное тестирование ловит это до: фронтенд генерирует формальный контракт из своих тестов, а бэкенд проверяет что может удовлетворить эти ожидания независимо, без запуска всего интеграционного сьюта. Здесь разобран Pact для consumer-driven contract testing в TypeScript-стеке: от генерации pact-файла до запуска верификации провайдера в CI.
Что такое контрактное тестирование
Контрактное тестирование проверяет что два сервиса согласованы по формату коммуникации, а не только то что каждый сервис работает корректно сам по себе.
Контракт описывает что именно consumer (фронтенд или другой сервис) ожидает от provider (API) в части формата ответов. Consumer генерирует контракт из своих тестов. Provider проверяет что может удовлетворить эти ожидания без запуска полного тест-сьюта consumer'а.Результат: ломающие изменения API ловятся до того как попадают в staging.
Pact: стандартный инструмент
Pact: наиболее распространённая библиотека контрактного тестирования. Работает записывая реальные API-взаимодействия во время тестов consumer'а, сохраняя их как «pacts» (JSON-файлы), и проигрывая их против реального provider'а.
Установка на стороне consumer (фронтенд)
npm install -D @pact-foundation/pactНаписание consumer-теста
Этот тест определяет что фронтенд ожидает от эндпоинта /users/1:
import { PactV3, MatchersV3 } from '@pact-foundation/pact';
import path from 'path';
const { like, string, integer } = MatchersV3;
const provider = new PactV3({
consumer: 'frontend',
provider: 'user-api',
dir: path.resolve(process.cwd(), 'pacts'),
});
describe('User API contract', () => {
it('returns user data', async () => {
await provider
.given('user 1 exists')
.uponReceiving('a request for user 1')
.withRequest({
method: 'GET',
path: '/users/1',
headers: { Authorization: like('Bearer token123') },
})
.willRespondWith({
status: 200,
headers: { 'Content-Type': 'application/json' },
body: {
id: integer(1),
name: string('Alice'),
email: string('alice@example.com'),
},
})
.executeTest(async (mockServer) => {
const response = await fetch(`${mockServer.url}/users/1`, {
headers: { Authorization: 'Bearer token123' },
});
const data = await response.json();
expect(data.name).toBeDefined();
expect(data.email).toBeDefined();
});
});
});При запуске этого теста Pact:
1. Запускает mock-сервер
2. Записывает взаимодействие (запрос + ожидаемый ответ) как pact-файл в ./pacts/
3. Запускает тест против mock-сервера
Сгенерированный pact-файл и есть контракт. Выглядит так:
{
"consumer": { "name": "frontend" },
"provider": { "name": "user-api" },
"interactions": [{
"description": "a request for user 1",
"request": { "method": "GET", "path": "/users/1" },
"response": {
"status": 200,
"body": { "id": 1, "name": "Alice", "email": "alice@example.com" }
}
}]
}Верификация на стороне provider (бэкенд)
Команда бэкенда берёт этот pact-файл и проверяет что API его удовлетворяет:
import { Verifier } from '@pact-foundation/pact';
describe('Provider verification', () => {
it('validates the expectations of Frontend', () => {
return new Verifier({
provider: 'user-api',
providerBaseUrl: 'http://localhost:3001',
pactUrls: [path.resolve(process.cwd(), 'pacts/frontend-user-api.json')],
stateHandlers: {
'user 1 exists': async () => {
// засеиваем тестового пользователя в базу
await db.users.create({ id: 1, name: 'Alice', email: 'alice@example.com' });
},
},
}).verifyProvider();
});
});Верификатор отправляет записанный запрос к реальному бэкенду и проверяет что ответ соответствует контракту. Если команда бэкенда переименует name в full_name, эта верификация упадёт до того как какой-либо код попадёт в staging.
Где Playwright вписывается
Playwright обрабатывает E2E интеграционное тестирование. Контрактное тестирование обрабатывает верификацию на границах сервисов. Они дополняют друг друга.
На практике: Контрактные тесты ловят переименование полей, удалённые поля, изменённые статус-коды и несоответствия типов. E2E-тесты Playwright ловят проблемы с рендерингом UI, сбои пользовательских флоу и полные full-stack интеграционные сбои.
Не заменяй Playwright-тесты контрактными. Используй контрактные тесты чтобы сделать Playwright-тесты быстрее и стабильнее: меньше E2E-падений из-за изменений формата API которые должны были быть пойманы раньше.
Pact Broker: общий доступ к контрактам между командами
Когда consumer и provider находятся в отдельных репозиториях (типично для микросервисов), нужно центральное место для хранения и обмена pact-файлами. Pact Broker: стандартный инструмент, open source, для самостоятельного хостинга, или доступен как PactFlow (их облачный сервис).
Команды публикуют pacts после запуска consumer-тестов. Пайплайны provider'а забирают последний pact и верифицируют перед деплоем. Это создаёт проверку зависимостей: provider не может деплоиться если ломает контракт consumer'а.
Когда контрактное тестирование оправдано
Контрактное тестирование добавляет накладные расходы: написание consumer-тестов на DSL Pact, настройка обработчиков состояний provider'а, поддержка broker'а. Оно окупается когда:
- Несколько фронтенд-приложений или сервисов вызывают одно API
- Изменения API часто ломают downstream consumer'ов
- Архитектура микросервисов где команды деплоятся независимо
- E2E-сьют медленный и хочется ловить интеграционные сбои раньше
Вероятно не оправдано для монолита где фронтенд и бэкенд в одном репозитории и деплоятся вместе, команды из двух человек где обе стороны один и тот же человек, и раннего проекта где API меняется слишком часто чтобы поддерживать стабильные контракты.
Начни с Playwright для E2E. Добавляй контрактное тестирование когда есть два независимо деплоящихся сервиса которые ломают предположения друг друга.
→ See also: API-тестирование с Playwright APIRequestContext (без Postman) | API-тестирование 101: всё, что нужно знать QA-инженеру в 2026 году | Пирамида тестирования: объяснение для QA-инженеров