Привет, Хабр!
Сегодня рассмотрим контрактные тесты потребитель‑управляемого формата на 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.
Полный перечень направлений доступен в каталоге курсов, а ближайшие занятия удобно посмотреть в календаре открытых уроков.
Перед принятием решения будет полезно заглянуть и в раздел с отзывами по этому курсу.