lenec ru

← все посты

Контрактные тесты с Pact: где спасает, а где это оверкил

13K

Контрактное тестирование закрывает пробел между unit-тестами и e2e: проверяет, что договор между двумя сервисами соблюдается. Кейс из жизни: фронт делает запрос на /api/orders, бекенд переименовал поле total в amount, e2e падает уже на стейдже, разработчик откатывает релиз. С контрактными тестами это ловится в момент билда, на стороне поставщика API, ещё до пуш-апрува.

Pact — самая известная библиотека для contract testing. Я гоняла её на трёх проектах, и не на каждом она прижилась. Покажу, где Pact реально спасает, где это оверкил, и какие альтернативы дешевле.

Что делает Pact

Идея простая. Есть consumer (тот, кто зовёт API) и provider (тот, кто его предоставляет). Consumer пишет тесты, в которых описывает «при таком запросе ожидаю такой ответ». Pact-библиотека из этих тестов генерирует JSON-файл — pact-контракт. Файл публикуется в общий брокер.

На стороне provider в CI запускается верификация: Pact дёргает реальный сервис теми запросами из контракта и сравнивает ответы. Если consumer ждёт поле total, а provider теперь возвращает amount — верификация падает.

Минимальный пример: consumer

import { PactV3, MatchersV3 } from '@pact-foundation/pact';
import path from 'node:path';
import { describe, it, expect } from 'vitest';
import { fetchOrder } from '../src/orders-client';

const provider = new PactV3({
  consumer: 'web-frontend',
  provider: 'orders-service',
  dir: path.resolve(__dirname, 'pacts'),
});

describe('OrdersClient', () => {
  it('получает заказ по id', async () => {
    await provider
      .given('order o-1 exists')
      .uponReceiving('a request for order o-1')
      .withRequest({ method: 'GET', path: '/api/orders/o-1' })
      .willRespondWith({
        status: 200,
        headers: { 'Content-Type': 'application/json' },
        body: {
          id: 'o-1',
          total: MatchersV3.decimal(199.99),
          status: MatchersV3.regex(/^(new|paid|shipped)$/, 'paid'),
        },
      });

    await provider.executeTest(async (mock) => {
      const order = await fetchOrder(mock.url, 'o-1');
      expect(order.id).toBe('o-1');
      expect(order.total).toBe(199.99);
    });
  });
});

На выходе теста в pacts/web-frontend-orders-service.json появляется контракт. Дальше его нужно опубликовать в брокер.

Минимальный пример: provider

На бекенде (Python, FastAPI):

from pact import Verifier

verifier = Verifier(
    provider="orders-service",
    provider_base_url="http://localhost:8000",
)

success, _ = verifier.verify_with_broker(
    broker_url="https://pact.example.com",
    publish_version="1.42.0",
    consumer_version_selectors=[{"deployedOrReleased": True}],
    enable_pending=True,
    state_change_url="http://localhost:8000/_pact/states",
)

assert success == 0

Бекенд должен поднять эндпоинт /_pact/states, который перед каждым контрактом приводит сервис в нужное состояние («заказ o-1 существует»). Это самая трудоёмкая часть — без хорошего state setup на проде не покрыть много кейсов.

Когда Pact реально спасает

Микросервисная архитектура с независимыми командами

5+ команд, каждая владеет своим сервисом, релизы независимые. Договоры между сервисами устные, ломаются регулярно. На onboarding нового сервиса — дни на интеграцию и неделя на «поймать все косяки».

Здесь Pact даёт реальный выигрыш. Каждая команда видит, какие consumers зависят от её API, в каких полях. Любая правка эндпоинта приводит к падению верификации, разработчик не может смержить PR, пока не договорится с consumers.

Внешние клиенты API

Если у тебя публичный API, который дергают мобильные приложения и сторонние клиенты, контракты — золото. Они фиксируют поведение, которое нельзя ломать без бамп мажорной версии. Брокер становится живой документацией: что ждут клиенты прямо сейчас.

Замена части e2e на быстрые проверки

Часть кейсов, которые раньше приходилось проверять полным e2e (поднять фронт, бекенд, БД, прокликать), теперь можно проверить контрактом. Быстрее на порядок. У меня на одном проекте Pact заместил ~30% e2e и сократил время прогона с 25 до 12 минут.

Когда Pact оверкил

Маленькая команда с монолитом

2–3 разработчика, один репозиторий, один билд. Здесь контракт между «фронт-частью репы» и «бекенд-частью репы» проще обеспечить TypeScript-типами, сгенерированными из OpenAPI или GraphQL-схемы. Pact добавляет инфраструктуру (брокер, верификации, state changes) без выгоды.

Один consumer на один provider

Если у бекенд-сервиса единственный клиент — фронт-приложение, и они в одной команде, ценность Pact падает. Верификация по типам и e2e на ключевые сценарии закрывает риск.

Меняющийся API на ранней стадии

В первые месяцы продукта API меняется ежедневно. Контракты переписываются чаще тестов. Поддержка Pact-инфраструктуры в это время — обуза. Лучше вернуться к идее «контракт — это OpenAPI», и проверять только на смоук-уровне.

Альтернативы Pact

OpenAPI + dredd / schemathesis

Если у тебя единый OpenAPI-спека, тулы вроде schemathesis генерируют тесты прямо из неё. Schemathesis на Python:

pip install schemathesis
schemathesis run https://api.example.com/openapi.json \
  --hypothesis-deadline=2000 \
  --checks all

Это property-based тесты на основе схемы. Ловят случаи «вернули 500 на пустую строку», «отдают неверный enum». На простых API закрывают 70% контрактных рисков.

Сравнение типов через codegen

Бекенд генерирует TypeScript-типы из своего OpenAPI или Protobuf. Фронт импортирует. Любое изменение поля ломает компиляцию фронта. Не «контрактный тест», а «контрактный билд», но во многих кейсах эффективнее.

Spec-first GraphQL

В GraphQL контракт встроен в схему. Изменение поля без deprecation breaks консьюмеров на этапе валидации запроса. Контрактные тесты как отдельный слой — практически не нужны.

Что попробовала и не зашло

  • Pact на сервисе с одним consumer-ом. Поставила, поддерживала полгода, реальная польза — два пойманных бага. Цена — десятки часов разработчиков на инфраструктуру и state changes. Сняли.
  • Pact в режиме «consumer не публикует контракт, provider тестирует против последней версии». Это не контрактные тесты, это просто mock-тесты бекенда. Pact в этом режиме лишний.
  • Сложные state changes через UI: «логинись, создавай заказ, оплачивай». Долго, нестабильно. State changes должны делаться через служебные эндпоинты или прямую запись в БД, не через клик.
  • Замена e2e на Pact полностью. Pact не проверяет UI-логику, фронтовые валидации, маршрутизацию. Минимальный e2e-набор всё равно нужен.

Чек-лист: брать ли Pact

  1. У тебя 3+ независимые команды на бекенде? — рассмотри.
  2. У одного API больше одного consumer-а? — рассмотри.
  3. Есть выделенный человек на инфраструктуру тестов? — рассмотри.
  4. Backend готов писать state-change эндпоинты? — обязательно для серьёзного применения.
  5. API живой и часто меняется ежедневно? — пока подожди, через пару месяцев вернись.
  6. Один монорепозиторий и одна команда? — типы из OpenAPI выгоднее.

Pact — отличный паттерн для крупных распределённых систем и обуза для маленьких команд. Прежде чем вкатываться, посчитай: сколько сервисов, сколько команд, сколько consumers на provider. Если цифры не складываются в пользу Pact, контрактные риски решаются проще: схема в OpenAPI, генерация типов, smoke-e2e на главные потоки. Это закрывает 80% случаев и экономит десятки часов в спринт.

Комментарии 2

  • Алексей Морозов

    С Pact налетал на одну штуку: contract-broker как точка отказа CI. Когда сервисов десяток и пайплайны бьются по верификации, тяжело гонять локальные сборки без поднятого брокера. Пришлось завести лайт-режим с заглушенной верификацией для feature-веток и полную — только в master. У вас на проектах брокер был обязателен в каждом пайплайне или гибкая стратегия?

  • Игорь Лебедев

    Согласен с тезисом про overkill для микро-команд. У нас Pact зашёл, когда между командами начался pingpong на тему «у вас сломался ответ — у нас всё работает», а интеграционные тесты не успевали быть актуальными. Один нюанс: если у consumer нет повторяемого CI с фиксированными версиями, broker быстро превращается в свалку устаревших pacts. Нужен ttl и автоудаление.

Войдите, чтобы оставить комментарий.