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

Сегодня рассмотрим контрактные тесты потребитель‑управляемого формата на 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.

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

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

Комментарии (0)