Привет, Хабр!
Сегодня рассмотрим контрактные тесты потребитель‑управляемого формата на 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
Пайплайн для репозитория потребителя:
-
сборка и юниты.
-
потребительские Pact‑тесты с публикацией контракта в Broker.
-
can‑i-deploy для потребителя против нужной среды.
-
маркировка релиза и выкладка.
Для провайдера:
-
сборка.
-
провайдерские Pact‑верификации из Broker с включенным pending и публикацией результатов.
-
record‑deployment.
-
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/
Добавить комментарий