В распределенной архитектуре современных веб-приложений фронтенд (React / Next.js) взаимодействует с десятками независимых микросервисов бэкенда через REST, GraphQL или gRPC API. Традиционные подходы к валидации межсервисной интеграции страдают от фундаментальных проблем:
Решением является методология Consumer-Driven Contracts (CDC) и спецификация Pact. В этой модели именно потребитель (фронтенд) формулирует свои минимально необходимые ожидания от ответов API, компилирует их в детерминированный JSON-контракт (Pact File) и публикует в центральный реестр (Pact Broker). Бэкенд-провайдер обязан подтвердить корректность контракта на своей стороне перед тем, как обе стороны получат разрешение на релиз.
Жизненный цикл Consumer-Driven Contracts разрывает синхронную зависимость тестирования фронтенда и бэкенда во времени.
+-------------------------------------------------------------------------+
| ФАЗА 1: Фронтенд-потребитель (React / Vitest) |
| |
| 1. Тест выполняет реальный HTTP-запрос к локальному Pact Mock Server |
| 2. Mock Server сверяет запрос и возвращает синтетический ответ |
| 3. Генерация артефакта: order-web-order-service.json |
+-------------------------------------------------------------------------+
|
v pact-broker publish
+-------------------------------------------------------------------------+
| Pact Broker |
| - Хранение контракта с привязкой к Git SHA фронтенда |
| - Матрица совместимости (Matrix): Consumer SHA <-> Provider SHA |
| - Отправка Webhook в репозиторий бэкенда при обновлении контракта |
+-------------------------------------------------------------------------+
|
v Trigger CI Provider Verification
+-------------------------------------------------------------------------+
| ФАЗА 2: Бэкенд-провайдер (Node.js / Go) |
| |
| 1. Загрузка контракта из Pact Broker |
| 2. Установка Provider State (например: "user with ID 10 exists in DB") |
| 3. Воспроизведение запроса из контракта на реальный контроллер |
| 4. Проверка структуры ответа с помощью гибких Matchers |
| 5. Публикация результатов проверки в Pact Broker |
+-------------------------------------------------------------------------+
|
v
+-------------------------------------------------------------------------+
| ФАЗА 3: Quality Gate перед деплоем в Production |
| |
| $ pact-broker can-i-deploy --pacticipant OrderWeb |
| --version $GIT_SHA |
| --to-environment production |
| | |
| +------------------+------------------+ |
| | | |
| [PASS] [FAIL] |
| v v |
| Разрешение деплоя Блокировка пайплайна |
| (Бэкенд в проде совместим) (Несовместимая версия API) |
+-------------------------------------------------------------------------+Фронтенд объявляет запросы, коды ответов и структуры данных с использованием MatchersV3 библиотеки @pact-foundation/pact.
Запуск тестового сьюта через Vitest. Создание детерминированного JSON-файла контракта в папке pacts.
Загрузка артефакта в брокер с тегированием текущего Git SHA и ветки с помощью утилиты pact-broker publish.
Запуск верификатора на бэкенде с обработкой Provider States для симуляции требуемых записей в базе данных.
Финальный опрос матрицы совместимости в CI/CD пайплайне перед выкаткой в продакшн-окружение.
Почему в методологии Consumer-Driven Contracts (Pact) фронтенд генерирует контракт против локального Pact Mock Server, а не реального staging-бэкенда?
OrderApiClient.ts#Создадим типизированный HTTP-клиент для получения сводки заказа.
export interface OrderItem {
id: string;
sku: string;
quantity: number;
unitPrice: number;
}
export interface OrderSummary {
orderId: string;
status: 'PENDING' | 'CONFIRMED' | 'SHIPPED';
totalAmount: number;
items: OrderItem[];
createdAt: string;
}
export class OrderApiClient {
constructor(private readonly baseUrl: string) {}
async getOrder(orderId: string): Promise<OrderSummary> {
const response = await fetch(`${this.baseUrl}/api/v2/orders/${orderId}`, {
method: 'GET',
headers: {
Accept: 'application/json',
},
});
if (!response.ok) {
if (response.status === 404) {
throw new Error(`Order not found: ${orderId}`);
}
throw new Error(`Failed to fetch order: ${response.statusText}`);
}
const data = (await response.json()) as OrderSummary;
return data;
}
}order-consumer.pact.spec.ts#Используем библиотеку @pact-foundation/pact (версия PactV3) в связке с Vitest. Во время исполнения теста библиотека поднимает локальный мок-сервер, который проверяет исходящий запрос и сохраняет результат в JSON-файл контракта.
import { describe, it, expect } from 'vitest';
import { PactV3, MatchersV3 } from '@pact-foundation/pact';
import path from 'path';
import { OrderApiClient } from '../OrderApiClient';
const { like, eachLike, regex, integer, decimal } = MatchersV3;
// Инициализация контракта между Потребителем и Поставщиком
const provider = new PactV3({
consumer: 'OrderWebClient',
provider: 'OrderBackendService',
dir: path.resolve(process.cwd(), 'pacts'),
});
describe('Контракт API заказов: OrderWebClient -> OrderBackendService', () => {
it('успешно возвращает сводку заказа по идентификатору', async () => {
// 1. Описание ожидаемого взаимодействия
provider
.given('заказ с идентификатором ord-9910 существует в системе')
.uponReceiving('запрос на получение информации о заказе ord-9910')
.withRequest({
method: 'GET',
path: '/api/v2/orders/ord-9910',
headers: {
Accept: 'application/json',
},
})
.willRespondWith({
status: 200,
headers: {
'Content-Type': 'application/json; charset=utf-8',
},
body: {
orderId: like('ord-9910'),
status: regex(/^(PENDING|CONFIRMED|SHIPPED)$/, 'CONFIRMED'),
totalAmount: decimal(1499.5),
createdAt: regex(
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z$/,
'2026-09-10T14:30:00.000Z'
),
items: eachLike(
{
id: like('item-001'),
sku: like('KB-MECH-RGB'),
quantity: integer(2),
unitPrice: decimal(749.75),
},
{ min: 1 }
),
},
});
// 2. Выполнение реального сетевого запроса к локальному серверу Pact
await provider.executeTest(async (mockServer) => {
const client = new OrderApiClient(mockServer.url);
const order = await client.getOrder('ord-9910');
// 3. Валидация типизированного ответа на клиенте
expect(order.orderId).toBe('ord-9910');
expect(order.status).toBe('CONFIRMED');
expect(order.items.length).toBeGreaterThanOrEqual(1);
expect(order.items[0].sku).toBe('KB-MECH-RGB');
});
});
});После успешного прохождения тестов сгенерированный артефакт pacts/OrderWebClient-OrderBackendService.json загружается в централизованный Pact Broker.
# Переменные окружения CI
export PACT_BROKER_BASE_URL="https://broker.internal.company.net"
export PACT_BROKER_TOKEN="secret-broker-auth-token"
export GIT_COMMIT=$(git rev-parse HEAD)
export GIT_BRANCH=$(git rev-parse --abbrev-ref HEAD)
# Публикация контракта с указанием хэша коммита и текущей ветки
npx pact-broker publish ./pacts \
--broker-base-url "$PACT_BROKER_BASE_URL" \
--broker-token "$PACT_BROKER_TOKEN" \
--consumer-app-version "$GIT_COMMIT" \
--branch "$GIT_BRANCH"provider.pact.spec.ts#Провайдер загружает контракт из брокера, поднимает локальный сервер и задействует механизм Provider State для подготовки базы данных перед приемом каждого запроса.
import { Verifier } from '@pact-foundation/pact';
import { describe, it, beforeAll, afterAll } from 'vitest';
import { app } from '../src/server';
import type { Server } from 'http';
describe('Pact Provider Verification: OrderBackendService', () => {
let server: Server;
const PORT = 8089;
beforeAll(async () => {
// Запуск бэкенд-сервера на выделенном тестовом порту
server = app.listen(PORT);
});
afterAll(async () => {
server.close();
});
it('удовлетворяет контрактам всех активных потребителей из Pact Broker', async () => {
const verifier = new Verifier({
provider: 'OrderBackendService',
providerBaseUrl: `http://127.0.0.1:${PORT}`,
pactBrokerUrl: process.env.PACT_BROKER_BASE_URL,
pactBrokerToken: process.env.PACT_BROKER_TOKEN,
// Публикация результатов проверки обратно в брокер
publishVerificationResult: !!process.env.CI,
providerVersion: process.env.GIT_COMMIT || '1.0.0',
providerVersionBranch: process.env.GIT_BRANCH || 'main',
// Обработка предусловий Provider State
stateHandlers: {
'заказ с идентификатором ord-9910 существует в системе': async () => {
// Инициализация тестовой записи в БД или мок-хранилище
await mockDatabase.seedOrder({
id: 'ord-9910',
status: 'CONFIRMED',
totalAmount: 1499.5,
items: [
{ id: 'item-001', sku: 'KB-MECH-RGB', quantity: 2, unitPrice: 749.75 },
],
createdAt: new Date().toISOString(),
});
},
},
});
// Запуск прогона верификации
await verifier.verifyProvider();
});
});can-i-deploy в GitHub Actions#Перед выполнением команды deploy в продакшн пайплайн запускает проверку матрицы совместимости. Если бэкенд еще не верифицировал контракт фронтенда для окружения production, деплой прерывается с ненулевым кодом выхода.
name: Deploy Consumer Frontend
on:
push:
branches: [main]
jobs:
contract-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v3
- uses: actions/setup-node@v4
with:
node-version: 22
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
# Генерация контракта через Vitest
- run: pnpm test:pact
# Публикация контракта в Pact Broker
- name: Publish Pact Contract
run: |
npx pact-broker publish ./pacts \
--broker-base-url ${{ secrets.PACT_BROKER_URL }} \
--broker-token ${{ secrets.PACT_BROKER_TOKEN }} \
--consumer-app-version ${{ github.sha }} \
--branch main
quality-gate:
needs: contract-test
runs-on: ubuntu-latest
steps:
# Запрос матрицы совместимости: можно ли выкатывать этот коммит в прод?
- name: Can I Deploy?
run: |
npx pact-broker can-i-deploy \
--pacticipant OrderWebClient \
--version ${{ github.sha }} \
--to-environment production \
--broker-base-url ${{ secrets.PACT_BROKER_URL }} \
--broker-token ${{ secrets.PACT_BROKER_TOKEN }}
- name: Deploy to Cloud
run: pnpm run deploy:prodКакую роль выполняет механизм Provider State при верификации контракта на стороне бэкенд-сервиса?
Использование статических значений вместо матчеров типов превращает контрактный тест в хрупкий юнит-тест базы данных. Если бэкенд вернет валидный заказ с другой суммой или другой строкой в статусе, верификация упадет без реальной поломки протокола.
// ❌ Ошибка: жесткая фиксация точного числа и строки
body: {
totalAmount: 1499.50, // Упадет, если в базе заказ на 2000.00
createdAt: "2026-09-10T12:00:00Z", // Упадет при генерации текущего времени
}
// ✅ Правильно: гибкие матчеры схемы
body: {
totalAmount: MatchersV3.decimal(1499.50), // Проверяется тип float
createdAt: MatchersV3.iso8601DateTime(), // Проверяется соответствие формату ISO
}Контрактное тестирование проверяет форму и протокол взаимодействия (наличие заголовков, коды статуса, соответствие схемы JSON). Попытка закодировать в Pact все граничные расчеты скидок, налогов и условий списания на бэкенде перегружает контракт сотнями избыточных сценариев.
// ❌ Избыточно: создание 30 контрактов для проверки алгоритма начисления баллов
// ✅ Правильно: 1 контракт на успешный ответ 200 OK + 1 контракт на 404 Not Found + 1 на 400 Bad Request.
// Бизнес-логику начисления баллов бэкенд проверяет собственными юнит-тестами!Релиз фронтенда без запроса матрицы совместимости разрушает защиту: если бэкенд переименовал поле в новой ветке, но еще не развернул его на боевом сервере, фронтенд немедленно упадет у пользователей с TypeError: Cannot read properties of undefined.
# ❌ Опасный деплой: нет проверки совместимости с текущим состоянием продакшна
git push origin main && pnpm deploy:prod
# ✅ Безопасный деплой: строгий гейт перед релизом
npx pact-broker can-i-deploy --pacticipant OrderWebClient --version $GIT_SHA --to-environment production