АШШумилкин А. О.ОАиП · ПиТПМ
ПиТПМ · Лекция 10

Лекция №10. Архитектура микросервисов и стратегии их поддержки. Контрактное тестирование.

4 503 слов7 разделов6 иллюстраций
↓ Скачать Markdown

§10.1. От монолита к микросервисам: как изменилось тестирование

В курсе «Основы Алгоритмизации и Программирования» вы уже знакомились с понятиями монолитной и микросервисной архитектуры.

Давайте коротко освежим память, потому что это напрямую влияет на то, как мы тестируем системы.

Монолитная архитектура, это когда вся система — это одно приложение. Все компоненты (интерфейс, бизнес-логика, работа с базой данных) находятся внутри одного проекта, собираются в один исполняемый файл и развертываются как одно целое.

Ключевая особенность данной архитектуры

Все компоненты внутри одного приложения. Если один компонент падает — падает вся система.

Схема монолитной архитектуры представлена ниже:

Микросервисная архитектура, это когда система разбита на множество маленьких, независимых приложений (микросервисов). Каждый микросервис отвечает за свою узкую область (например, «Пользователи», «Заказы», «Платежи»). Микросервисы общаются друг с другом по сети (через HTTP-запросы или очереди сообщений).

Ключевая особенность данной архитектуры

Каждый микросервис — это отдельное приложение. Если один микросервис падает — остальные продолжают работать.

Схема микросервисной архитектуры представлена ниже:

Для наглядности, давайте сравним эти две архитектуры в виде таблицы:

ХарактеристикаМонолитМикросервисы
СтруктураОдно приложениеМного маленьких приложений
База данныхОдна БД для всех компонентовКаждый сервис — своя БД
РазвертываниеВсё приложение целикомКаждый сервис независимо
ТехнологииОдин язык/стек для всегоРазные языки для разных сервисов
МасштабированиеМасштабируется целикомМасштабируется только нужные сервисы

Переход от монолита к микросервисам кардинально меняет то, как мы тестируем систему.

В монолите всё тестируется вместе. То есть выглядит это следующим образом:

1. Вы запускаете приложение (одно).

2. Оно поднимает базу данных.

3. Вы запускаете тесты.

4. Тесты проверяют всё: и UI, и логику, и работу с БД.

5. Всё это происходит в одном процессе.

Преимущества для тестирования данного вида архитектуры следующие:

  • Просто. Одно приложение — один набор тестов.
  • Быстро. Нет сетевых задержек между компонентами.
  • Предсказуемо. Все компоненты всегда вместе.

но у такого вида есть и недостатки:

  • Если тесты упали — непонятно, какой компонент сломался.
  • Невозможно протестировать компонент отдельно.
  • Любое изменение требует полного регрессионного тестирования.

В микросервисах же всё тестируется по отдельности. Как это выглядит:

1. Каждый микросервис тестируется независимо (модульные тесты).

2. Проверяется, что микросервис правильно работает со своей БД (интеграционные тесты).

3. Проверяется, что микросервис правильно общается с другими микросервисами (НОВАЯ ЗАДАЧА!).

4. Проверяется, что вся система работает вместе (E2E-тесты).

Преимущества для тестирования данного вида архитектуры следующие:

  • Изолированность. Можно тестировать каждый сервис отдельно.
  • Скорость. Тесты одного сервиса не зависят от других.
  • Гибкость. Можно менять один сервис, не перетестируя все остальные.

но у такого вида есть и недостатки:

  • Сложность. Нужно тестировать не только каждый сервис, но и их взаимодействие.
  • Сеть. Появляются сетевые задержки и ошибки, которых нет в монолите.
  • Версии. Сервисы могут развиваться с разной скоростью.

А как проверить, что сервисы правильно понимают друг друга? В монолите эта проблема не стоит. Все компоненты внутри одного приложения общаются через вызовы методов. Компилятор сам проверяет, что типы данных совпадают. В монолите это происходит все следующим образом:

csharp

// Всё в одном приложении
var order = new Order { 
    Id = 1, 
    Total = 100 
};

// Компилятор проверит, что метод существует
orderService.Create(order);

В микросервисах же ситуация состоит иначе:

csharp

// Сервис А (заказы)
var order = new Order { Id = 1, Total = 100 };
var response = await httpClient.PostAsync(
   "https://payment-service/pay", 
   order
);

// Сервис Б (платежи)
// Как проверить, что Сервис Б поймет этот JSON?

Появляется один логичный вопрос. Что же может пойти не так?

ПроблемаПример
Разные версииСервис А обновился и отправляет новые поля, Сервис Б их не понимает
Разный форматСервис А отправляет {"orderId": 1}, Сервис Б ожидает {"id": 1}
Разные типыСервис А отправляет {"total": "100.00"}, Сервис Б ожидает число
Отсутствие поляСервис А перестал отправлять поле, Сервис Б ждет его

Давайте приведем простую аналогию. Представьте, что два отдела в компании должны обмениваться отчетами.

  • Отдел А отправляет отчет в Excel.
  • Отдел Б ожидает отчет в Word.

Отделы не могут договориться о формате. Каждый раз, когда приходит отчет, отдел Б не может его открыть. Работа останавливается.

В микросервисах то же самое — сервисы общаются через API, и если они не договорились о формате данных, система не работает.

Именно здесь на помощь приходит контрактное тестирование. Давайте кратко разберем, что это такое.

Контрактное тестирование

— это способ проверить, что два сервиса (или более) правильно понимают друг друга.

Оно проверяет, что:

  • Сервис А отправляет данные в том формате, который ожидает Сервис Б.
  • Сервис Б отвечает в том формате, который ожидает Сервис А.

Для простой аналогии можно сказать следующее.

Контракт

— это как договор между двумя компаниями.

В нем записано:

Информация

Отдел А обязуется отправлять отчеты в формате Excel. Отдел Б обязуется принимать отчеты в формате Excel

Если кто-то нарушает договор — работа останавливается. В мире микросервисов это звучит следующим образом:

Информация

Сервис Заказов обязуется отправлять POST-запросы на /api/pay с JSON-телом { orderId, total }. Сервис Платежей обязуется отвечать статусом 200 или 400.

Если один из сервисов нарушает этот контракт — система дает сбой. Контрактное тестирование автоматически проверяет, что контракты не нарушаются.

§10.2. Почему интеграционные и E2E-тесты не решают проблему микросервисов

В предыдущих лекциях мы изучили несколько видов тестирования:

Вид тестированияЧто проверяет
Модульные тестыОтдельные методы и классы
Интеграционные тестыСвязь с БД, внешними сервисами
E2E-тестыПолные сценарии пользователя

Естественный вопрос, который каждый может задать, это:

Интеграционные тесты проверяют, как один модуль взаимодействует с внешними системами — обычно с базой данных. Но они не проверяют взаимодействие между сервисами.

Пример интеграционного теста:

csharp

[Fact]
public async Task CreateOrder_ShouldSaveToDatabase()
{
    // Проверяем, что заказ сохраняется в БД
    var order = new Order { 
       UserId = 1, 
       Total = 100 
    };

    await orderService.CreateOrderAsync(order);
    
    var savedOrder = dbContext.Orders
                              .FirstOrDefault(o => o.UserId == 1);

    Assert.NotNull(savedOrder);
}

Что он проверяет:

  • Правильно ли работает репозиторий.
  • Сохраняются ли данные в БД.
  • Соблюдаются ли ограничения (внешние ключи, уникальность).

Чего он НЕ проверяет:

  • Правильно ли сервис А вызывает API сервиса Б.
  • Понимает ли сервис Б данные, которые отправляет сервис А.
  • Совпадают ли форматы данных между сервисами.

E2E-тесты проверяют полный сценарий пользователя от начала до конца. Они запускают все микросервисы, открывают браузер и имитируют действия пользователя. Но несмотря на то, что они проверяют все вместе, они невероятно медленные и дорогие. Пример E2E-теста:

csharp

[Fact]
public async Task UserCanBuyProduct()
{
    // 1. Открыть сайт
    await page.GotoAsync("https://myshop.com");
    
    // 2. Найти товар
    await page.FillAsync("#search", "iPhone");
    await page.ClickAsync("#search-btn");
    
    // 3. Добавить в корзину
    await page.ClickAsync(".add-to-cart");
    
    // 4. Оформить заказ
    await page.ClickAsync("#checkout");
    await page.FillAsync("#card-number", "1234");
    await page.ClickAsync("#pay");
    
    // 5. Проверить подтверждение
    await Expect(page.Locator(".confirmation")).ToBeVisibleAsync();
}

Что он проверяет:

  • Всю систему целиком.
  • Взаимодействие между всеми сервисами.
  • Пользовательский опыт.

Чего он НЕ делает:

  • Не дает быстрой обратной связи (медленный).
  • Не помогает найти, какой именно сервис сломался.
  • Не подходит для частого запуска в CI/CD.

Давайте вспомним, почему E2E тесты медленные и дорогие:

Что происходит в E2E-тестеСколько времени
Запуск браузера2 – 3 секунды
Загрузка страницы1 – 2 секунды
Поиск элемента0.5 секунды
Клик, ввод текста0.5 секунды
Ожидание ответа сервера1 – 2 секунды
Загрузка следующей страницы1 – 2 секунды

В совокупности выходит 10+ секунд на один тест. А теперь представьте:

Количество E2E-тестовВремя выполнения
10 тестов~2 минуты
50 тестов~10 минут
100 тестов~2 минуты
500 тестов~1.5 часа

Что это значит для разработчика:

1. Отправил код → ждешь 20 минут → тесты упали → исправляешь → снова ждешь 20 минут.

2. За день можно сделать 5 – 6 итераций вместо 20 – 30.

3. Продуктивность падает, разработчики начинают ненавидеть тесты.

При работе с E2E тестами в микросервисной архитектуре при изменении в одном сервисе — необходимо запускать все E2E-тесты. Это самая большая проблема. Представьте ситуацию: вы работаете над сервисом Заказов. Вы изменили формат ответа для API /api/orders.

Что происходит:

1. Вы локально протестировали свой сервис (модульные и интеграционные тесты прошли).

2. Вы отправляете код в репозиторий.

3. Запускается CI/CD (конвейер из Лекции 9).

4. Запускаются все E2E-тесты — ведь только они проверяют взаимодействие сервисов.

5. E2E-тесты падают, потому что сервис Платежей не понял новый формат.

6. Вы узнаете об ошибке через 20 – 40 минут.

Ошибка в интеграции между сервисами обнаруживается слишком поздно. А если ошибку нашли через 40 минут — разработчик уже переключился на другую задачу и забыл, что именно менял.

Для решения подобной проблемы, нужен новый вид тестирования. Что нам нужно:

ТребованиеПочему
БыстрыйЗапускается за секунды, а не за минуты
ДешевыйНе требует поднятия всех сервисов и браузеров
Проверяет совместимость APIПроверяет, что сервисы правильно понимают друг друга
Локализует проблемуПоказывает, какой именно сервис и какой контракт нарушен
Работает в CI/CDЗапускается при каждом push, как модульные тесты

Этим требованиям соответствует контрактное тестирование.

Давайте сравним разные виды тестирования и посмотрим, какие есть преимущества у контрактного тестирования.

Вид тестированияСкоростьПроверяет БДПроверяет API между сервисамиПроверяет UIНаходит ошибку интеграции
МодульныеМгновенно❌❌❌❌
ИнтеграционныеБыстро✅❌ (только с БД)❌❌
E2EМедленно✅✅✅✅ (но поздно)
КонтрактныеБыстро❌✅❌✅ (рано)

Что же нам дает контрактное тестирование? До контрактного тестирования у разработчиков была следующая ситуация:

wireframe

Разработчик меняет API → Отправляет код → CI/CD → E2E-тесты (20 мин) → ❌ Ошибка!

С контрактным же тестированием:

wireframe

Разработчик меняет API → Отправляет код → CI/CD → Контрактные тесты (5 сек) → ❌ Ошибка!

В итоге получаем разницу: 20 минут против 5 секунд. Данная разница в процессе разработки является крайне важной и критической.

§10.3. Что такое Контракт и зачем он нужен с точки зрения тестировщика

Давайте еще раз рассмотрим, что такое контракт простыми словами.

Контракт

— это соглашение между двумя сервисами о том, как они будут общаться друг с другом.

В мире микросервисов это звучит следующим образом:

Информация

Сервис А (Потребитель) общается с сервисом Б (Поставщик). Контракт — это правила этого общения.

А что же входит в контракт? Контракт описывает все аспекты взаимодействия между сервисами.

Что входит в контрактЧто это значитПример
Метод HTTPКакой метод используетсяGET, POST, PUT, DELETE
URL (Endpoint)По какому адресу обращаться/api/orders, /api/users/{id}
Заголовки (Headers)Какие заголовки должны бытьContent-Type: application/json, Authorization: Bearer <token>
Тело запроса (Request Body)Какие данные отправляются и в каком форматеJSON-объект с полями productId, quantity
Тело ответа (Response Body)Какие данные возвращаются и в каком форматеJSON-объект с полями orderId, status
Статус-кодыКакие коды возвращает сервер200 OK, 400 Bad Request, 404 Not Found

Теперь давайте рассмотрим пример того, как выглядит контракт. Представьте, что у нас есть два сервиса:

1. Сервис Заказов (Потребитель) — хочет создать новый заказ.

2. Сервис Платежей (Поставщик) — обрабатывает платежи.

Контракт между ними выглядит так:

Часть контрактаЧто должно быть
МетодPOST
URL/api/payments
ЗаголовкиContent-Type: application/json
Тело запроса{"orderId": число, "amount": число, "currency": строка}
Успешный ответСтатус 200 OK, тело {"paymentId": число, "status": "success"}
ОшибкаСтатус 400 Bad Request, тело {"error": строка}

Что это значит на практике:

1. Сервис Заказов обязан отправлять POST-запрос на /api/payments с JSON-телом, в котором есть поля orderId, amount, currency.

2. Сервис Платежей обязан принимать такие запросы и отвечать либо 200 OK (если всё хорошо), либо 400 Bad Request (если что-то не так).

Пример реального запроса:

json

POST /api/payments HTTP/1.1
Host: payment-service
Content-Type: application/json

{
  "orderId": 12345,
  "amount": 100.00,
  "currency": "USD"
}

Пример реального ответа:

json

HTTP/1.1 200 OK
Content-Type: application/json

{
  "paymentId": 67890,
  "status": "success"
}

Контракт

— это как спецификация для тестирования.

Что такое спецификация в тестировании?

Спецификация

— это документ, который описывает, что должна делать система. Тестировщик читает спецификацию и проверяет, соответствует ли система ей. Контракт — это такая же спецификация, но для API.

Спецификация (для тестировщика)Контракт (для микросервисов)
«Приложение должно авторизовать пользователя»«Сервис должен принимать POST /api/login с логином и паролем»
«Приложение должно показывать список товаров»«Сервис должен возвращать GET /api/products со списком товаров»
«Приложение должно показывать ошибку при неверном вводе»«Сервис должен возвращать 400 Bad Request при неверных данных»

Как тестировщик использует контракт:

1. Тестировщик читает контракт: «Сервис Платежей должен принимать POST /api/payments с JSON-телом { orderId, amount, currency }».

2. Тестировщик пишет тест, который проверяет, что:

  • Сервис Заказов действительно отправляет такой запрос.
  • Сервис Платежей действительно понимает такой запрос и отвечает корректно.

3. Если контракт нарушен — тест падает.

Давайте приведем аналогию в виде технического задания для тестировщика. Представьте, что вы — тестировщик в офисе. Вам дают техническое задание:

wireframe

Сотрудник отдела А должен отправлять заявки на отпуск сотруднику отдела Б. Заявка должна содержать: ФИО, даты отпуска, причину. Сотрудник отдела Б должен подтвердить заявку или вернуть её на доработку.

Вы, как тестировщик, проверяете:

1. Отправляет ли отдел А заявки в правильном формате? (тело запроса)

2. Понимает ли отдел Б эти заявки? (поставщик)

3. Если заявка заполнена неправильно — возвращает ли отдел Б ее на доработку? (ошибка 400)

4. Если заявка правильная — подтверждает ли ее отдел Б? (успех 200)

В мире микросервисов то же самое, но автоматизированное. Важно понять, что контракт — это не код. Это договоренность между командами или сервисами. Что может быть в контракте:

ФорматПримерКогда используется
Устная договоренность«Сервис А будет отправлять JSON»В маленьких командах (опасно!)
Документ (Word, Google Docs)Описание API в документеВ средних командах (трудно поддерживать)
OpenAPI / Swaggeropenapi: 3.0.0 ...В больших командах (автоматизируется)
PactКонтракт в JSON-файлеДля контрактного тестирования

§10.4. Контрактное тестирование: новый вид тестирования для микросервисов

Данный параграф хочется начать с повторения.

Контрактное тестирование

— это способ проверки, что два (или более) сервиса правильно понимают друг друга и соблюдают свои договоренности (контракты).

Почему это называется «контрактным» тестированием? Потому что, контракт — это соглашение между сервисами, а контрактное тестирование — это проверка, что обе стороны соблюдают соглашение.

Пример:

КтоЧто делаетПроверка контракта
Сервис Заказов (Потребитель)Отправляет запрос на оплатуОтправляет ли он запрос в правильном формате?
Сервис Платежей (Поставщик)Принимает запрос и обрабатываетПонимает ли он запрос и правильно ли отвечает?

Контрактное тестирование проверяет обе стороны:

1. Потребитель действительно отправляет запрос, который ожидает Поставщик.

2. Поставщик действительно умеет обрабатывать такие запросы.

Самая важная идея контрактного тестирования

Потребитель (Consumer) диктует, что ему нужно

Это называется Consumer-Driven Contracts.

Consumer-Driven Contracts

— контракты, управляемые потребителем.

В мире микросервисов это звучит следующим образом:

wireframe

Потребитель (Сервис А) говорит: «Мне нужен API, который принимает `POST /api/payments` с JSON-телом `{ orderId, amount, currency }` и возвращает `200 OK` или `400 Bad Request`».

Поставщик (Сервис Б) обязан:

1. Иметь эндпоинт POST /api/payments.

2. Понимать JSON-тело с полями orderId, amount, currency.

3. Отвечать 200 OK или 400 Bad Request.

Возникает резонный вопрос,

В мире API существует два актора: Producer (поставщик) и Consumer (потребитель). Давайте определим, кто есть кто.

Consumer (Потребитель):

  • Сервис, который отправляет запросы.
  • Он использует API другого сервиса.
  • Он диктует, что ему нужно.

Producer (Поставщик):

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

Примерная схема взаимодействия двух акторов представлена ниже:

Что контрактное тестирование дает тестировщику? Без контрактного тестирования:

ДействиеВремя
Изменяем код в одном сервисе5 минут
Запускаем E2E-тесты20 – 40 минут
Обнаруживаем, что интеграция сломаласьПоздно
Исправляем, снова запускаем E2EЕще 20 – 40 минут

В совокупности уходит более одного часа на одну итерацию. С контрактным же тестированием:

ДействиеВремя
Изменяем код в одном сервисе5 минут
Запускаем контрактные тесты5-10 секунд
Обнаруживаем, что интеграция сломаласьСразу
Исправляем, снова запускаем контрактные тесты5-10 секунд

уходит примерно 5 – 10 минут на одну итерацию.

Давайте рассмотрим, что получает тестировщик при использовании контрактного тестирования:

Что дает контрактное тестированиеПочему это важно
Быстрая проверка совместимости5 – 10 секунд вместо 20 – 40 минут
Локализация проблемыТочно знает, какой сервис и какой контракт нарушен
Раннее обнаружение ошибокОшибка найдена до того, как код попал в продакшен
АвтоматизацияКонтрактные тесты запускаются в CI/CD автоматически
ДокументацияКонтракты сами по себе являются документацией по API

В завершение данного параграфа нужно разобрать, чем отличаются E2E – тесты от контрактных тестов:

ХарактеристикаE2E-тестыКонтрактные тесты
СкоростьМедленные (минуты)Быстрые (секунды)
Проверяет UIДаНет
Проверяет БДДаНет
Проверяет совместимость APIДаДа
Требует поднятия всех сервисовДаНет
Находит ошибку интеграцииПоздноРано
Где запускаетсяРедко (в main)Часто (при каждом push)

Главное их отличие заключается в том, что контрактные тесты проверяют совместимость API быстро и рано. E2E-тесты проверяют всю систему медленно и поздно. Простая аналогия заключается в следующем:

  • E2E-тесты — это как генеральная репетиция спектакля. Все актеры на сцене, все костюмы, все декорации. Дорого, долго, но полноценно.
  • Контрактные тесты — это как читка текста по ролям. Актеры сидят за столом и читают сценарий. Быстро, дешево, но проверяет только текст (контракт).

§10.5. Инструмент Pact: как автоматизировать контрактное тестирование

Pact

— это самый популярный инструмент для контрактного тестирования. Он позволяет автоматически создавать контракты и проверять, что обе стороны (потребитель и поставщик) соблюдают договоренности.

Pact

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

Pact работает в два этапа: сначала Consumer создает контракт, потом Producer проверяет, что его API соответствует этому контракту. Схема работы примерно следующая:

Давайте подробно разберем эти два этапа.

Этап №1. Consumer пишет тест и записывает контракт

На этом этапе Consumer (потребитель) пишет тест, который описывает, что он ожидает от API поставщика. Это выглядит следующим образом:

1. Consumer пишет тест, в котором описывает:

  • Какой запрос он будет отправлять (метод, URL, тело).
  • Какой ответ он ожидает получить (статус-код, тело).

2. Pact поднимает Mock-сервер (заглушку), который имитирует поведение поставщика.

3. Consumer отправляет запрос на Mock-сервер.

4. Mock-сервер проверяет, что запрос соответствует описанию, и возвращает ожидаемый ответ.

5. Pact записывает все взаимодействия в контракт — JSON-файл с расширением .pact.

Пример контракта (упрощенно):

json

{
  "consumer": { "name": "Сервис Заказов" },
  "provider": { "name": "Сервис Платежей" },
  "interactions": [
    {
      "description": "Запрос на оплату заказа",
      "request": {
        "method": "POST",
        "path": "/api/payments",
        "body": { "orderId": 123, "amount": 100.00 }
      },
      "response": {
        "status": 200,
        "body": { "paymentId": 456, "status": "success" }
      }
    }
  ]
}

Что получает тестировщик на этом этапе:

  • Контракт в виде JSON-файла.
  • Понимание того, какие запросы ожидает Consumer.
  • Документацию по API.

Этап №2. Producer проверяет, что его API соответствует контракту

На этом этапе Producer (поставщик) берет контракт от Consumer и проверяет, что его реальный API соответствует этому контракту. Это выглядит следующим образом:

1. Producer получает контракт (JSON-файл) от Consumer.

2. Pact запускает проверку:

  • Берет каждый запрос из контракта.
  • Отправляет его на реальный API Producer.
  • Сравнивает реальный ответ с ожидаемым из контракта.

3. Если все запросы дали ожидаемые ответы — проверка пройдена.

4. Если хотя бы один ответ не совпадает — проверка провалена.

Что получает тестировщик на этом этапе:

  • Отчет о совместимости сервисов.
  • Точное указание, какой именно контракт нарушен (если есть проблема).
  • Гарантию, что Producer совместим с Consumer.

А что же дает Pact тестировщику? На самом деле, он дает много преимуществ:

Что дает PactПочему это важно
Автоматический отчет о совместимостиНе нужно вручную проверять, что API совместимы
Быстрая обратная связьПроверка занимает секунды, а не минуты
Локализация проблемыТочно знает, какой сервис и какой контракт нарушен
Документация APIКонтракты сами по себе являются документацией
Интеграция с CI/CDКонтракты проверяются при каждом push

Самое важное преимущество контрактного тестирования – это безопасность, то есть, сервисы развиваются независимо.

Без Pact:

wireframe

Изменение в Producer → Запуск всех E2E-тестов → Ошибка через 40 минут

С Pact:

wireframe

Изменение в Producer → Запуск контрактных тестов → Ошибка через 5 секунд

Что это дает:

  • Сервисы могут развиваться независимо. Producer может менять свой код, если контракты не нарушаются.
  • Ошибки находятся рано. Разработчик узнает о проблеме сразу, а не через 40 минут.
  • Меньше стресса. Можно безопасно обновлять один сервис, не боясь сломать другие.

Контракты можно хранить в двух местах:

1. Локально (для начала):

wireframe

/pacts

└── consumer-provider.json

2. Pact Broker (для CI/CD):

Pact Broker

— это сервер для хранения и управления контрактами.

Он позволяет:

  • Хранить все контракты в одном месте.
  • Отслеживать версии контрактов.
  • Проверять, можно ли безопасно развернуть сервис.

§10.6. Контрактное тестирование в CI/CD: автоматическая проверка совместимости

В §9.5 из лекции №9 мы научились настраивать CI/CD-конвейер, который автоматически запускает модульные, интеграционные и E2E-тесты при каждом git push. Теперь мы добавим в этот конвейер контрактные тесты.

Главная идея этого действия заключается в том, что контрактное тестирование должно быть встроено в CI/CD так же, как и другие виды тестирования. Это единственный способ получить быструю обратную связь о совместимости сервисов.

В CI/CD то же самое: если контракт нарушен — конвейер останавливается, и разработчик получает уведомление.

Контрактное тестирование встраивается в CI/CD в два этапа, которые соответствуют двум сторонам контракта:

Этап №1. Consumer создает контракт

ШагЧто происходит
1Разработчик отправляет код в репозиторий (git push)
2Запускается CI/CD-конвейер
3Конвейер запускает тесты Consumer
4Pact записывает контракт в JSON-файл
5Конвейер публикует контракт в Pact Broker (общее хранилище)

Этап №2. Producer проверяет контракт

ШагЧто происходит
1Разработчик отправляет код Producer
2Запускается CI/CD-конвейер Producer
3Конвейер скачивает контракт из Pact Broker
4Конвейер запускает проверку Producer
5Pact проверяет, что API Producer соответствует контракту
6Если проверка пройдена — конвейер зеленый
7Если проверка провалена — конвейер красный

Схема работы контрактов в CI/CD выглядит следующим образом:

Что получает команда при работе с контрактным тестированием:

ЧтоКогдаПочему важно
Мгновенное уведомлениеЧерез 5 – 10 секунд после git pushРазработчик еще помнит, что менял
Точное указание проблемыСразу в отчете«Контракт нарушен: поле amount ожидается как число, пришла строка»
Красный конвейерНемедленноНельзя проигнорировать проблему
Блокировка релизаАвтоматическиПока контракт не исправлен — релиз невозможен

Главное правило при интеграции контрактного тестирования в CI/CD заключается в том, что конвейер должен падать, если контракт нарушен.

Это единственный способ гарантировать, что несовместимые сервисы не попадут в продакшен.

Как это выглядит в CI/CD (GitHub Actions):

yaml

name: Contract Tests
on:
  push:
    branches: [ main ]
jobs:
  consumer-tests:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v4
      - name: Setup .NET
        uses: actions/setup-dotnet@v4
        with:
          dotnet-version: '8.0'  
      - name: Run consumer tests
        run: dotnet test --filter "Category=Contract"
      
      - name: Publish contract to Pact Broker
        run: |
          # Публикуем контракт в Pact Broker
          # Если контракт не опубликован — конвейер падает         
  provider-verification:
    runs-on: ubuntu-latest
    needs: consumer-tests
    steps:
      - name: Checkout code
        uses: actions/checkout@v4      
      - name: Setup .NET
        uses: actions/setup-dotnet@v4
        with:
          dotnet-version: '8.0'
      - name: Run provider verification
        run: |
          # Скачиваем контракт из Pact Broker
          # Проверяем, что API соответствует контракту
          # Если проверка провалена — конвейер падает

А для разработчиков это обычно проявляется в двух сценариях.

Сценарий №1. Всё хорошо

1. Разработчик меняет код в Consumer и отправляет git push.

2. Конвейер запускается → зеленый.

3. Контракт публикуется в Pact Broker.

4. Producer автоматически проверяет контракт → зеленый.

5. Разработчик спокоен: «Всё совместимо».

Сценарий №2. Контракт нарушен

1. Разработчик меняет код в Producer (изменил формат ответа) и отправляет git push.

2. Конвейер Producer запускается.

3. Контрактные тесты провалены.

4. Разработчик видит красный значок и отчет: «Ошибка: ожидалось поле 'amount', пришло поле 'total'».

5. Разработчик исправляет код и отправляет снова.

6. Конвейер зеленый.

Время на обнаружение ошибки варьируется в диапазоне от 5 – 10 секунд.

Самая частая ситуация в микросервисной архитектуре это когда Consumer и Producer находятся в разных репозиториях. У каждого сервиса свой репозиторий.

Как это работает:

1. Consumer публикует контракт в Pact Broker при каждом push.

2. Producer скачивает контракт из Pact Broker при каждом push и проверяет свой API.

Схема данного взаимодействия представлена ниже:

§10.7. Антипаттерны в контрактном тестировании или его делать НЕ надо

Контрактное тестирование — это мощный инструмент, но только если вы используете его правильно.

Есть несколько типичных ошибок, которые сводят на нет всю пользу от контрактного тестирования.

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

Давайте подробно разберем антипаттерны, которые часто встречаются при работе с контрактным тестированием.

Антипаттерн №1. «Проверяем только успешные сценарии»

Суть проблемы заключается в том, что разработчик пишет контрактные тесты только для «счастливого пути» (Happy Path) — когда всё работает правильно. Но он забывает про ошибки и исключения. Пример (как НЕ делать):

json

{
  "consumer": "Сервис Заказов",
  "provider": "Сервис Платежей",
  "interactions": [
    {
      "description": "Успешная оплата",
      "request": {
        "method": "POST",
        "path": "/api/payments",
        "body": { "orderId": 123, "amount": 100.00 }
      },
      "response": {
        "status": 200,
        "body": { "paymentId": 456, "status": "success" }
      }
    }
  ]
}

Почему это плохо? В реальной жизни сервис Платежей может вернуть ошибку:

  • Недостаточно средств.
  • Неверный номер карты.
  • Сервис временно недоступен.
  • Превышен лимит операций.

Если Consumer не умеет обрабатывать эти ошибки — система упадет в продакшене.

Что нужно делать правильно:

json

{
  "consumer": "Сервис Заказов",
  "provider": "Сервис Платежей",
  "interactions": [
    {
      "description": "Успешная оплата",
      "request": {
        "method": "POST",
        "path": "/api/payments",
        "body": { "orderId": 123, "amount": 100.00 }
      },
      "response": {
        "status": 200,
        "body": { "paymentId": 456, "status": "success" }
      }
    },
    {
      "description": "Недостаточно средств",
      "request": {
        "method": "POST",
        "path": "/api/payments",
        "body": { "orderId": 123, "amount": 10000.00 }
      },
      "response": {
        "status": 400,
        "body": { "error": "Недостаточно средств" }
      }
    },
    {
      "description": "Сервис недоступен",
      "request": {
        "method": "POST",
        "path": "/api/payments"
      },
      "response": {
        "status": 503,
        "body": { "error": "Сервис временно недоступен" }
      }
    }
  ]
}

Антипаттерн №2. «Игнорируем старые версии»

Суть проблемы заключается в том, что в микросервисной архитектуре сервисы обновляются с разной скоростью. Producer обновил API, но Consumer еще не обновился и использует старую версию. Пример:

wireframe

Версия API: v1

Producer:   /api/payments (старый формат)

Consumer:   /api/payments (старый формат)

✅ Всё работает

Через неделю:

Producer:   /api/v2/payments (новый формат)

Consumer:   /api/payments (старый формат)

❌ Контракт нарушен! Consumer не может найти старый формат.

Почему это плохо:

Что произошлоПоследствия
Producer изменил API, но Consumer не обновилсяConsumer падает при каждом запросе
Producer удалил старый эндпоинтПользователи не могут оплатить заказы
Producer переименовал полеConsumer не понимает новые данные

Что нужно делать правильно:

1. Поддерживать обратную совместимость:

json

{
  "consumer": "Сервис Заказов",
  "provider": "Сервис Платежей",
  "interactions": [
    {
      "description": "Старая версия API (v1)",
      "request": {
        "method": "POST",
        "path": "/api/payments",
        "body": { "orderId": 123, "amount": 100.00 }
      },
      "response": {
        "status": 200,
        "body": { "paymentId": 456, "status": "success" }
      }
    },
    {
      "description": "Новая версия API (v2)",
      "request": {
       "method": "POST",
       "path": "/api/v2/payments",
       "body": { "orderId": 123, "amount": 100.00, "currency": "USD" }
      },
      "response": {
        "status": 200,
        "body": { "paymentId": 456, "status": "success" }
      }
    }
  ]
}

2. Использовать версионирование API:

  • Не удалять старые эндпоинты сразу.
  • Дать время Consumer перейти на новую версию.
  • Удалять старую версию только когда все Consumer обновились.

Антипаттерн №3. «Контракт один раз и забыли»

Суть проблемы заключается в том, что контракт был написан один раз в начале проекта и с тех пор не обновлялся. API изменилось, но контракт остался старым. Пример:

wireframe

Месяц 1: Контракт создан ✅

Месяц 3: API изменилось, но контракт не обновлен ⚠️

Месяц 6: API сильно изменилось, контракт устарел ❌

Почему это плохо:

Что произошлоПоследствия
Контракт устарелОн больше не отражает реальное поведение API
Тесты проходят, но API не работаетЛожное чувство уверенности
Новый разработчик смотрит контрактПолучает неверную информацию об API

Что нужно делать правильно:

1. Контракт обновляется при каждом изменении API.

Если вы изменили API — вы должны изменить контракт.

2. Контракт — это живые документ.

Он должен быть частью процесса разработки, а не артефактом из прошлого.

3. Контрактные тесты запускаются при каждом push.

Если кто-то изменил API и забыл обновить контракт — тесты упадут и напомнят об этом.

Антипаттерн №4. «Всё через E2E»

Суть проблемы заключается в том, что команда решает, что контрактное тестирование не нужно, потому что есть E2E-тесты. Они проверяют всё, значит, достаточно. Почему это плохо:

E2E-тестыКонтрактные тесты
Медленные (минуты)Быстрые (секунды)
Дорогие (поднимают все сервисы)Дешевые (проверяют только API)
Сложно отлаживатьЛегко локализовать проблему
Не работают в CI/CD частоРаботают при каждом push

Контрактное тестирование НЕ заменяет E2E. Они решают разные задачи:

Что проверяетКонтрактные тестыE2E-тесты
Совместимость API✅✅
Правильность работы UI❌✅
Правильность работы БД❌✅
Полный пользовательский сценарий❌✅
Скорость выполненияБыстроМедленно

Что нужно делать правильно:

УровеньЧто использоватьКак часто
Быстрая проверкаКонтрактные тестыПри каждом push
Полная проверкаE2E-тестыВ main или перед релизом

Можно привести простую аналогию при работе с контрактными тестами и E2E – тестами:

  • Контрактные тесты — это как быстрая проверка зубов у стоматолога. Занимает 5 минут, но показывает основные проблемы.
  • E2E-тесты — это как полное МРТ-обследование. Занимает час, но показывает всё.

Важно понимать, что нужно и то, и другое.