Каждый раз, когда API ломался в продакшене у моей команды, он проходил CI. Все разы. Юнит-тесты были зелёными. Интеграционный набор тестов проходил. Деплой уходил, а потом начинали приходить сообщения в Slack.

Проблема была не в том, что мы не тестировали. Мы тестировали не то. Большинство CI pipelines проверяют, что код запускается. Они не проверяют, что API contract между поставщиком и клиентом остался нетронутым.

Почему ваши текущие тесты не ловят поломки API

Юнит-тесты отрабатывают вашу внутреннюю логику. Они вызывают ваш обработчик с мок-зависимостями и проверяют ответ. Интеграционные тесты поднимают весь стек и бьют по реальным эндпоинтам.

Оба этих типа тестируют поставщика. Они проверяют, что ваш API ведёт себя корректно, когда вызывается так, как вы его сейчас вызываете. Они не тестируют, может ли внешний клиент, собранный по контракту прошлой недели, всё ещё с вами разговаривать.

Тонкие поломки — самые опасные. Вы переименовываете поле из user_id в userId, потому что линтер ругался. Вы меняете 200-ый ответ, чтобы возвращать вложенный объект вместо плоского. Вы делаете параметр запроса обязательным, который раньше был опциональным. Ваши собственные тесты обновлены в том же PR, так что всё проходит. Но каждый клиент в продакшене ломается.

Вот в чём разница между тестированием кода и тестированием contracts.

Что считается изменением, ломающим обратную совместимость API

Изменение, ломающее обратную совместимость, — это любая модификация, которая заставляет корректно реализованного клиента упасть. Речь не о багах. Речь о promise, который дал ваш API.

Самые распространённые поломки делятся на три категории:

  • Структурные изменения: удаление или переименование полей, изменение типов, изменение вложенности
  • Поведенческие изменения: превращение опциональных параметров в обязательные, изменение дефолтов пагинации, модификация формы ответов об ошибках
  • Изменения жизненного цикла: удаление эндпоинтов, изменение URL-путей, депрекация версий без предупреждения

Некоторые из них очевидны. Другие очевидны только если смотреть на API с точки зрения клиента. Большинство команд так не делают.

Уровень 1: Ловим структурные поломки через сравнение OpenAPI

Спецификации OpenAPI описывают форму вашего API. Если вы рассматриваете spec как contract, вы можете сравнить её с предыдущей версией и пометить изменения, ломающие обратную совместимость, до мёрджа.

Инструменты вроде oasdiff сравнивают два OpenAPI-документа и категоризируют изменения как ломающие, опасные или безопасные. Изменение, ломающее обратную совместимость, — это что-то вроде удаления поля из ответа или изменения параметра из опционального в обязательный.

Вот как это выглядит в GitHub Actions workflow:

# .github/workflows/api-contract.yml
name: API Contract Check

on:
  pull_request:
    paths:
      - 'openapi.yaml'

jobs:
  diff:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Download oasdiff
        run: |
          curl -L https://github.com/Tufin/oasdiff/releases/latest/download/oasdiff_linux_amd64.tar.gz | tar xz
          sudo mv oasdiff /usr/local/bin/

      - name: Check for breaking changes
        run: |
          oasdiff breaking \
            --base origin/main:openapi.yaml \
            --revision openapi.yaml \
            --fail-on WARN

Это ломает билд, если PR вносит структурное изменение, ломающее обратную совместимость. Это быстро, детерминировано и ловит переименования и удаления, которые интеграционные тесты пропускают.

Но есть подвох. Сравнение OpenAPI видит только схему. Оно не может сказать, изменили ли вы смысл поля, сохранив тип. Булево поле active, которое теперь означает «подтверждение email» вместо «аккаунт активен», не покажется в диффе. Тип не изменился. Contract изменился.

Уровень 2: Проверяем контракты клиентов через Pact

Тестирование, управляемое потребителем (consumer-driven contract testing), переворачивает модель. Вместо того чтобы поставщик API утверждал собственную корректность, клиенты определяют, что им нужно. Эти ожидания становятся contracts, которым поставщик должен удовлетворять в CI.

Вот как это работает. Ваша фронтенд-команда пишет тест, который говорит: «Когда я вызываю GET /users/123, я ожидаю 200 с телом, содержащим user_id как строку.» Pact записывает это взаимодействие и сохраняет contract.

На стороне поставщика ваш backend CI скачивает все контракты клиентов и воспроизводит их против текущего кода. Если PR удаляет user_id или меняет его на число, проверка поставщика падает. Даже если ваши собственные тесты проходят.

Минимальный consumer test с Pact JS:

// consumer.spec.js
const { PactV3 } = require('@pact-foundation/pact');
const { expect } = require('chai');

const provider = new PactV3({
  consumer: 'web-app',
  provider: 'user-service',
  dir: './pacts',
});

describe('GET /users/:id', () => {
  it('returns the user', async () => {
    await provider
      .given('user exists')
      .uponReceiving('a request for user 123')
      .withRequest({
        method: 'GET',
        path: '/users/123',
      })
      .willRespondWith({
        status: 200,
        headers: { 'Content-Type': 'application/json' },
        body: {
          user_id: '123',
          email: 'alice@example.com',
        },
      });

    await provider.executeTest(async (mockserver) => {
      const user = await fetchUser(mockserver.url, '123');
      expect(user.user_id).to.equal('123');
    });
  });
});

Проверка поставщика в CI:

# .github/workflows/verify-contracts.yml
name: Verify Consumer Contracts

on: [pull_request]

jobs:
  verify:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Verify pacts
        run: |
          docker run --rm \
            -v $(pwd)/pacts:/pacts \
            pactfoundation/pact-cli \
            verify-provider \
            --provider-app-version ${{ github.sha }} \
            --pact-broker-base-url https://your-pact-broker.io \
            --provider user-service

Это ловит семантические поломки, которые сравнение схем не видит. Если клиент полагается на конкретный формат значения или сообщение об ошибке, Pact отметит это.

Трейдоффы, которые большинство команд игнорирует

Ни один подход не бесплатен.

Сравнение OpenAPI требует поддерживать точную спецификацию. Если ваша спецификация генерируется из code annotations, она остаётся актуальной автоматически. Если она пишется от руки, она будет дрейфовать, и сравнение станет бессмысленным. Generated specs лучше подходят для этого рабочего процесса.

Pact требует организационной дисциплины. Клиенты должны писать contract tests. Вам нужен broker для хранения и версионирования contracts. Когда ожидания клиента ошибочны, кому-то приходится договариваться об изменении. Это создаёт трение. В этом и смысл. Трение предотвращает тихие поломки.

Запускать оба — идеально, но не всегда практично. Если у вас один или два критичных клиента, Pact быстро окупается. Если у вас десятки анонимных клиентов API, сравнение OpenAPI — лучшая отправная точка.

Ещё одно: ни один инструмент не ловит регрессии производительности или изменения аутентификации. Новый эндпоинт, который требует OAuth, где раньше ничего не было — это изменение, ломающее обратную совместимость. Ваш инструмент сравнения может не отметить его, если auth schema уже был определён где-то ещё. Держите глаза открытыми на предмет пробелов.

FAQ

Что такое изменение, ломающее обратную совместимость API?

Изменение, ломающее обратную совместимость API, — это любая модификация API, которая заставляет существующих, корректно реализованных клиентов упасть. Это включает удаление полей, изменение типов, превращение опциональных параметров в обязательные или изменение кодов состояния ответа для существующих эндпоинтов.

Чем API contract testing отличается от integration testing?

Integration tests проверяют, что ваша система работает как единое целое. Contract tests проверяют, что публичный interface API соответствует тому, что ожидают клиенты. Integration tests могут проходить даже когда contract ломается, если обе стороны системы обновлены в одном коммите.

Можно ли использовать сравнение OpenAPI без написания specs от руки?

Да. Инструменты вроде oasdiff работают с любым OpenAPI-документом, включая те, что генерируются из code annotations с помощью библиотек вроде SpringDoc, FastAPI или drf-spectacular. Generated specs обычно надёжнее, потому что они не могут отдаляться от implementation.

Нужен ли Pact broker для consumer-driven contracts?

Для команд с более чем парой сервисов — да. Broker хранит contracts, отслеживает версии и показывает, какие клиенты затронуты предлагаемым изменением. Без него вы перекидываете contract-файлы вручную, что быстро перестаёт работать.

А что насчёт GraphQL?

У GraphQL другая семантика изменений, ломающих обратную совместимость. Удаление поля — это ломающее изменение, но добавление — безопасно. Инструменты вроде GraphQL Inspector предоставляют сравнение схем, похожее на OpenAPI-инструменты. Pact также поддерживает взаимодействия GraphQL.

Начните с сравнения схем

Начните с сравнения OpenAPI. Это минимальные усилия и ловит самые распространённые поломки. Добавьте это в ваш CI сегодня, даже если ваша спецификация несовершенна. Несовершенная проверка лучше, чем отсутствие проверки.

Как только вы поймаете на код-ревью поломку, которая могла бы испортить кому-то выходные, вы поймёте, почему это важно.