Контрактные тесты CDC на Pact

от автора

Привет, Хабр!

Сегодня рассмотрим контрактные тесты потребитель‑управляемого формата на Pact.

Consumer‑Driven Contracts фиксируют минимальный набор ожиданий клиента к API сервиса. Контракт рождается из автотеста на стороне потребителя. Потом провайдер прогоняет этот контракт против своей реализации и публикует результат в Broker. Выигрыш понятный: проверяем не всё API, а только то, что использует потребитель, и фиксируем совместимость версий до выката. Это основная идея Pact и базовая модель его работы.

Сам по себе CDC закрывает разрыв между быстрыми юнитами и медленными e2e. Контракт не заменяет e2e, но даёт дешёвую гарантию «не сломаем потребителя» на каждом изменении провайдера. CDC эффективнее всего на сетях сервисов с явными границами и стабильными интеграциями.

Минимальный рабочий пример на JVM

Зависимости для потребителя

Gradle, JUnit 5, Pact JVM 4.6.x стабильной ветки:

// build.gradle.kts (consumer) plugins {   java }  repositories { mavenCentral() }  dependencies {   testImplementation("org.junit.jupiter:junit-jupiter:5.10.2")   testImplementation("au.com.dius.pact.consumer:junit5:4.6.17")   testRuntimeOnly("org.slf4j:slf4j-simple:2.0.13") }  tasks.test {   useJUnitPlatform()   systemProperty("pact_do_not_track", "true") // выключить телеметрию }

Потребительский тест с matchers

Тест использует in‑process mock‑сервер Pact, описывает ожидания по запросу и ответу и вызывает ваш HTTP‑клиент на адрес mock.

// src/test/java/com/example/consumer/UserClientPactTest.java package com.example.consumer;  import au.com.dius.pact.consumer.dsl.PactDslJsonBody; import au.com.dius.pact.consumer.junit5.*; import au.com.dius.pact.core.model.RequestResponsePact; import au.com.dius.pact.core.model.annotations.Pact; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith;  import java.util.Map;  import static org.assertj.core.api.Assertions.assertThat;  @ExtendWith(PactConsumerTestExt.class) @PactTestFor(providerName = "user-service", port = "0") // случайный порт class UserClientPactTest {    @Pact(consumer = "billing-service")   RequestResponsePact getUserContract(PactDslWithProvider builder) {     var body = new PactDslJsonBody()       .stringType("id", "u-123")       .stringMatcher("email", ".+@.+\\..+", "ivan@habr.org")       .stringType("name", "Ivan Ivan")       .numberType("age", 30);      return builder       .given("User with id u-123 exists") // provider state       .uponReceiving("GET /users/u-123")         .path("/users/u-123")         .method("GET")         .headers(Map.of("Accept", "application/json"))       .willRespondWith()         .status(200)         .headers(Map.of("Content-Type", "application/json; charset=utf-8"))         .body(body)       .toPact();   }    @Test   @PactTestFor(pactMethod = "getUserContract")   void shouldFetchUser(MockServer mockServer) {     var client = new UserClient(mockServer.getUrl()); //  HTTP клиент     var user = client.getById("u-123");     assertThat(user.getEmail()).contains("@");     assertThat(user.getId()).startsWith("u-");   } }

Не хардкодим конкретные значения, а описываем типы и ограничения.

Публикация контракта в Pact Broker

Потребительский тест положит pact‑файл в build/pacts. Публикуем его кли‑утилитой Pact Broker Client и сразу помечаем версию и ветку. В контейнере удобно:

# пример публикации из CI шага потребителя export PACT_BROKER_BASE_URL="$BROKER_URL" export PACT_BROKER_TOKEN="$BROKER_TOKEN"  pact-broker publish build/pacts \   --consumer-app-version "${GIT_SHA}" \   --branch "${GIT_BRANCH}" \   --auto-detect-version-properties

Ветка и версия нужны для работы pending и WIP, а также для can-i-deploy.

Верификация на стороне провайдера

На стороне сервиса поднимаем Spring Boot в тестовом профиле и подключаем JUnit 5 провайдерскую либу Pact. Будем тянуть контракты из Broker по селекторам, включим pending, опционально WIP, и вернём в Broker результаты проверки.

// build.gradle.kts (provider) plugins { java }  repositories { mavenCentral() }  dependencies {   testImplementation("org.junit.jupiter:junit-jupiter:5.10.2")   testImplementation("au.com.dius.pact.provider:junit5:4.6.17")   testImplementation("org.springframework.boot:spring-boot-starter-test:3.3.2") }  tasks.test {   useJUnitPlatform()   systemProperty("pact.provider.version", System.getenv("GIT_SHA") ?: "local")   systemProperty("pact.provider.branch", System.getenv("GIT_BRANCH") ?: "local")   systemProperty("pact.broker.token", System.getenv("PACT_BROKER_TOKEN") ?: "")   systemProperty("pact.broker.url", System.getenv("PACT_BROKER_BASE_URL") ?: "") }

Сам тест:

// src/test/java/com/example/provider/PactProviderVerificationTest.java package com.example.provider;  import au.com.dius.pact.provider.junit5.*; import au.com.dius.pact.provider.junitsupport.loader.*; import org.junit.jupiter.api.TestTemplate; import org.junit.jupiter.api.extension.ExtendWith; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.context.ActiveProfiles;  import java.util.Map;  @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.DEFINED_PORT) // сервис слушает порт @Provider("user-service") @PactBroker(   host = "${pact.broker.host:}",   scheme = "https",   port = "443",   authentication = @Authentication(token = "${pact.broker.token:}") ) @PactBrokerConsumerVersionSelectors({   // дефолтные селекторы: main ветка + задеплоенные + released   @PactBrokerConsumerVersionSelector(defaultSelector = true) }) @VerificationReports({"console"}) @ActiveProfiles("test") class PactProviderVerificationTest {    @TestTemplate   @ExtendWith(PactVerificationInvocationContextProvider.class)   void pactVerification(PactVerificationContext context) {     context.verifyInteraction();   }    @State("User with id u-123 exists")   void userExists() {     TestFixtures.seedUser("u-123");   }    @BeforeEach   void before(PactVerificationContext context) {     context.setTarget(HttpTestTarget.fromUrl("http://localhost:8080"));   }    @TestInstance(TestInstance.Lifecycle.PER_CLASS)   @ProviderStateParams // опционально, если нужны параметры из контракта   void setup() { }    @BeforeAll   static void config(PactVerificationContext context) {     // включаем pending и wip, если поддерживаются версией     System.setProperty("pact.verifier.publishResults", "true");     System.setProperty("pact.verifier.enablePending", "true");     System.setProperty("pact.verifier.wipPactsSince", "2024-01-01"); // формат ISO   } }

pending pacts защищает пайплайн провайдера от новых ожиданий, WIP гарантирует, что новые контракты попадут в верификацию автоматически, селекторы управляют набором проверяемых контрактов. Результаты верификации возвращаются в Broker и используются дальше в can-i-deploy.

Между собой pending и WIP дополняют друг друга. Pending не «роняет» билд провайдера на свежем контракте, а WIP заставляет его этот контракт всё равно подобрать и проверить. Эти особенности описаны в официальных доках Broker. (docs.pact.io)

Pact Broker в эксплуатации: вебхуки, can-i-deploy, релизы

Broker добавляет автоматизацию. Самое полезное:

Во‑первых, webhooks. При публикации нового контракта Broker может вызвать ваш CI провайдера, например GitHub Actions workflow dispatch. В обратную сторону вебхуком можно обновлять статусы проверок в VCS.

Во‑вторых, can‑i-deploy. Это CLI‑команда, которая смотрит матрицу совместимости в Broker и отвечает, можно ли выпускать конкретную версию сервиса на конкретную среду. Используйте совместно с record-deployment и record-release, чтобы матрица знала, что уже выезжало и где.

Пример набора команд в CI потребителя и провайдера:

# после публикации контракта потребителем: pact-broker can-i-deploy \   --pacticipant billing-service \   --version "${GIT_SHA}" \   --to-environment "staging"  # на стороне провайдера после успешной верификации: pact-broker record-deployment \   --pacticipant user-service \   --version "${GIT_SHA}" \   --environment "staging"  # перед релизом в прод: pact-broker can-i-deploy \   --pacticipant user-service \   --version "${GIT_SHA}" \   --to-environment "production"

Это самая ценная интеграция Pact Broker в жизненный цикл релизов.

Управление контрактом: matchers, генераторы, provider states

Контракт не должен быть хрупким. Используем matchers, а не точные значения. Для дат и идентификаторов подойдёт term с регуляркой. Для массивов eachLike с ограничением по минимуму элементов.

Генераторы помогают избегать хардкода в ответах. Например, «подставить значение из provider state» или «сгенерировать UUID».

Provider states — основной способ объяснить провайдеру, какой набор данных нужен для конкретной интеракции. Хорошая практика в state‑хендлере не мокать сервисный слой, а инициализировать тестовую БД или фикстуры на транспортном уровне.

Сообщения, очереди и gRPC

Pact — не только HTTP. Есть message‑контракты для очередей, а для gRPC и Protobuf/Avro работает плагин‑архитектура V4. Для gRPC используйте pact-protobuf-plugin, он разбирает proto и применяет matchers к полям сообщений.

Набросок потребительского теста с gRPC на JVM:

// примерный контур: плагин читает proto и поднимает mock gRPC @ExtendWith(PactConsumerTestExt.class) @PactTestFor(providerName = "area-service", port = "0") class GrpcAreaPactTest {    @Pact(consumer = "shape-client")   V4Pact areaContract(PactBuilder builder) {     // конфигурация плагина/прото через metadata     return builder.usingPlugin("protobuf")       .interactions()         .uponReceiving("calculate area for circle r=2")         .withRequest(/* gRPC method, message */)       .willRespondWith(/* AreaResponse matchers */)       .toPact();   }    @Test   @PactTestFor(pactMethod = "areaContract")   void shouldCalculateArea(MockServer mock) {     // создать gRPC stub на адрес mock.getUrl(), вызвать метод, проверить результат   } } 

Интеграция с CI

Пайплайн для репозитория потребителя:

  1. сборка и юниты.

  2. потребительские Pact‑тесты с публикацией контракта в Broker.

  3. can‑i-deploy для потребителя против нужной среды.

  4. маркировка релиза и выкладка.

Для провайдера:

  1. сборка.

  2. провайдерские Pact‑верификации из Broker с включенным pending и публикацией результатов.

  3. record‑deployment.

  4. can‑i-deploy на целевую среду.

Пример GitHub Actions шага публикации и can-i-deploy:

# .github/workflows/consumer-pact.yml jobs:   pact:     runs-on: ubuntu-latest     steps:       - uses: actions/checkout@v4       - uses: actions/setup-java@v4         with: { distribution: temurin, java-version: '21' }       - run: ./gradlew test       - name: Publish pacts         run: |           curl -fsSL https://raw.githubusercontent.com/pact-foundation/pact-ruby-cli/main/install.sh | bash           pact-broker publish build/pacts \             --consumer-app-version $GITHUB_SHA \             --branch $GITHUB_REF_NAME \             --auto-detect-version-properties         env:           PACT_BROKER_BASE_URL: ${{ secrets.PACT_BROKER_URL }}           PACT_BROKER_TOKEN: ${{ secrets.PACT_BROKER_TOKEN }}       - name: Can I deploy to staging         run: |           pact-broker can-i-deploy \             --pacticipant billing-service \             --version $GITHUB_SHA \             --to-environment staging         env:           PACT_BROKER_BASE_URL: ${{ secrets.PACT_BROKER_URL }}           PACT_BROKER_TOKEN: ${{ secrets.PACT_BROKER_TOKEN }}

В Broker настраиваем вебхук, который триггерит провайдера при публикации контракта.


Когда Pact не нужен

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

Тем не менее Pact полезен там, где у вас несколько независимых сервисов, разные команды и нужен контроль совместимости версий до выката.

Делитесь опытом в комментариях: где Pact реально помог, а где оказался избыточным.

Контрактные тесты на Pact помогают контролировать совместимость сервисов без тяжелых e2e‑проверок. Если вы хотите глубже разобраться в том, как подобные подходы применяются на практике и освоить инструменты тестирования на Java, обратите внимание на курс Java QA Engineer. Basic.

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

Перед принятием решения будет полезно заглянуть и в раздел с отзывами по этому курсу.


ссылка на оригинал статьи https://habr.com/ru/articles/941366/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *