Разделы портала

Онлайн-тренинги

.
Введение в тестирование контрактов, часть 3: начало работы с Pact
02.03.2022 00:00

Автор: Баз Дейкстра (Bas Dijkstra)
Оригинал статьи
Перевод: Ольга Алифанова

В прошлый раз я рассказал о концепции тестирования контрактов, ориентированных на потребителя, и объяснил, как это помогает справиться с проблемами интеграции и end-to-end тестирования распределенных систем. В этой статье я расскажу, как начать работать с Pact, чтобы реализовать такое тестирование.

Итак,что же такое Pact? Как гласит документация:

Pact – это ориентированный на код инструмент для тестирования HTTP и интеграции сообщений при помощи контрактных тестов.

Вместо детального теоретического описания Pact давайте разберем четыре этапа тестирования контрактов, ориентированных на потребителя, и посмотрим, как их можно выполнить при помощи Pact для сторон, описанных в первой статье.

Примеры написаны на Java, но Pact доступен для ряда других языков, включая C#, Python, Go, и концепции применимы и для них тоже. Я думал над аналогичными примерами на других языках, но пока остановимся на Java и Pact-JVM.

Шаг 1: потребитель генерирует контракт, содержащий ожидания от поведения провайдера

Pact дает возможность определить ожидания потребителя от ответов провайдера в ходе их взаимодействия. Эти ожидания концентрируются на сообщениях, а не на поведениях – то есть Pact не предназначен для тестирования реализации провайдера; это область ответственности команды, которая его разрабатывает.

Посмотрим на ожидания, которые могут быть у нашего Customer API от ответов Address API на его запросы. Как мы видели в первой статье, если Customer API запрашивает данные для адреса, Address API возвращает данные, выглядящие так:

{
     "id": "87256abc-f6b3-4e91-9f60-3ca3f54863d5",
     "address_type": "billing",
     "street": "Main Street",
     "number": 123,
    "city": "Nothingville",
    "zip_code": 54321,
    "state": "Tennessee",
    "country": "United States"
}

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

DslPart body = LambdaDsl.newJsonBody((o) -> o
     .uuid("id", ID)
     .stringType("addressType", ADDRESS_TYPE)
     .stringType("street", STREET)
     .integerType("number", NUMBER)
     .stringType("city", CITY)
     .integerType("zipCode", ZIP_CODE)
     .stringType("state", STATE)
     .stringType("country", COUNTRY)
).build();

Класс LambdaDsl позволяет вам выражать ожидания от структуры тела ответа, используя динамический DSL. Метод stringType ("addressType", ADDRESS_TYPE) добавляет ожидание, что ответ должен содержать поле address, а его значение должно быть строкой.

Схожим образом метод integerType() используется для ожидания, что ответ содержит поле со значением integer, а метод the uuid() – что поле должно содержать не просто строку, а строку, соответствующую формату UUID.

Полный список доступных методов можно найти здесь.

Значения (ID, ADDRESS_TYPE, и т. д.), переданные, как второй аргумент мэтчер-методов – это примеры, используемые Pact для наполнения имитации ответов. Они используются, когда Pact генерирует имитатор провайдера, который может использоваться потребителем для тестирования реализации (мы поговорим об этом чуть позже). Это не значит, что поле всегда должно содержать именно это конкретное значение.

Примечание: существуют мэтчер-методы, выражающие ожидания как точные совпадения (stringValue(), numberValue(), и т. д.), но их надо использовать аккуратно – они создают очень строгие ожидания от работы провайдера, и могут привести к ненужным связкам провайдера и потребителя.

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

@Pact(consumer = "customer_consumer")
public RequestResponsePact pactForGetExistingAddressId(PactDslWithProvider builder) {
 
     return builder.given(
          "Customer GET: the address ID matches an existing address")
         .uponReceiving("A request for address data")
        .path(String.format("/address/%s", ID))
        .method("GET")
        .willRespondWith()
        .status(200)
        .body(body)
        .toPact();
}

Этот пакт определяет, что:

  • Это ожидания потребителя, что выражено в метке customer_consumer (наш Customer API).
  • Это пакт для состояния провайдера, когда выполняется GET, и используемые в запросе address ID указывают на существующий в Address API адрес.
  • Запрос – это GET к /address/{address_id}
  • Потребитель ожидает ответа с кодом HTTP 200, и тела ответа, как определено выше (оно находится в переменной body).

Схожим образом мы можем добавить пакт для ситуации, когда Customer API запрашивает данные для правильно отформатированного Address ID, отсутствующего у провайдера Address, что приведет к ошибке HTTP 404:

@Pact(consumer = "customer_consumer")
public RequestResponsePact pactForGetNonExistentAddressId(PactDslWithProvider builder) {
 
     return builder.given(
          "Customer GET: the address ID does not match an existing address")
          .uponReceiving("A request for address data")
          .path("/address/00000000-0000-0000-0000-000000000000")
          .method("GET")
          .willRespondWith()
          .status(404)
           .toPact();
}

Образцы кода для этой серии статей содержат пакты и для других взаимодействий, включая взаимодействия через другие HTTP-методы (например, DELETE).

Чтобы Pact создал реальный контракт, используя эти пакты для взаимодействий, нужно написать юнит-тесты, которые

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

Для рассмотренной ранее GET-операции, где данные возвращаются для известного провайдеру адреса, тест может выглядеть так:

@PactVerification(fragment = "pactForGetExistingAddressId")
@Test
public void testFor_GET_existingAddressId_shouldYieldExpectedAddressData() {
 
      final Address address = addressServiceClient.getAddress(ID.toString());
 
       assertThat(address.getId()).isEqualTo(ID);
       assertThat(address.getAddressType()).isEqualTo(ADDRESS_TYPE);
       assertThat(address.getStreet()).isEqualTo(STREET);
       assertThat(address.getNumber()).isEqualTo(NUMBER);
       assertThat(address.getCity()).isEqualTo(CITY);
       assertThat(address.getZipCode()).isEqualTo(ZIP_CODE);
       assertThat(address.getState()).isEqualTo(STATE);
       assertThat(address.getCountry()).isEqualTo(COUNTRY);
}

Используя аннотацию @PactVerification, мы импортируем ожидания потребителя, определенные ранее, и привязываем их к REST-вызову (то есть взаимодействию), выполняемому в этом тесте.

Pact создает имитатор провайдера, отвечающий способом, заданным в ожиданиях, и его ответы можно использовать для проверки, что код потребителя работает. При прогоне этого юнит-теста взаимодействие будет записано в контракте в формате JSON.

Для GET-операции с несуществующим address ID тест может выглядеть так:

@PactVerification(fragment = "pactForGetNonExistentAddressId")
@Test
public void testFor_GET_nonExistentAddressId_shouldYieldHttp404() {
 
      assertThatThrownBy(
             () -> addressServiceClient.getAddress("00000000-0000-0000-0000-000000000000")
      ).isInstanceOf(HttpClientErrorException.class)
             .hasMessageContaining("404 Not Found");
}

Примеры кода на GitHub содержат больше тестов для обоих потребителей – то есть и для Customer API, и для Order API. На самом деле определение ожиданий и тесты очень похожи для обеих сторон, что означает, что на данный момент оба потребителя имеют одинаковые ожидания от поведения провайдера. В пятой статье серии мы разберем, что происходит, если это не так.

Шаг 2: потребитель публикует контракт для провайдера

Когда вы запускаете юнит-тесты на стороне потребителя, генерируется контракт в /target/pacts (локация будет отличаться, если вы используете другие связки Pact, а не Pact-JVM, по очевидным причинам). Пример контракта можно найти здесь.

Как можно видеть, сгенерированный Pact контракт – это соглашение (или, точнее, выражение ожиданий) между одним потребителем и одним провайдером, и он содержит сегмент взаимодействия с соответствующими ожиданиями для всех взаимодействий, заданных в тест-коде потребителя.

Стоит также учесть, что хоть в этом примере и провайдер, и потребитель написаны на Java, это не обязательно для использования Pact. Так как генерация контракта идет в JSON, и создается согласно стандартизированному формату, контрактами могут обмениваться стороны на разных языках, если для этих языков доступны связки Pact.

Наш свежесгенерированный контракт можно опубликовать для провайдера. Сейчас мы сделаем это, просто скопировав файл JSON и поместив его в правильную папку базы кода провайдера. В следующей статье мы разберем более эффективный и полностью автоматизированный способ распределения контрактов.

Шаг 3: провайдер забирает контракт и проверяет, что его реализация соответствует ожиданиям, выраженным потребителем в контракте

После того, как и Customer, и Order API потребителя прогнали свои тесты и опубликовали контракты, провайдер Address должен убедиться, может ли он соответствовать выраженным в контрактах ожиданиям.

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

Так как нет необходимости иметь некоторое (обычно большое) количество рабочих связанных систем, тестирование контрактов с Pact гораздо быстрее "традиционных" интеграционных тестов – как в настройке, так и в выполнении.

На стороне провайдера нужно определить точки верификации для каждого взаимодействия, указанного в контрактах:

@RunWith(SpringRestPactRunner.class)
@Provider("address_provider")
@PactFolder("src/test/pacts")
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class ContractTest {
 
      @TestTarget
       public final Target target = new SpringBootHttpTarget();
 
       // Сервис 'as-is' используется для всех состояний провайдера, дополнительная настройка не нужна
 
       @State("Customer GET: the address ID matches an existing address")
       public void addressSuppliedByCustomerGETExists() {
      }
 
      @State("Customer GET: the address ID does not match an existing address")
      public void addressSuppliedByCustomerGETDoesNotExist() {
      }
}

В нашем случае нет необходимости выполнять дополнительную настройку (наша прямолинейная реализация провайдера готова к верификации контракта "как есть"), но в случае нужды в дополнительных настройках для приведения провайдера в нужное для верификации определенного взаимодействия состояния это можно сделать в теле соответствующего метода @State.

Заметьте, что на данный момент мы задаем локацию, где провайдер может найти контракты, используя аннотацию @PactFolder, так как пока что мы физически копируем и вставляем контракты потребителя в провайдера. Повторюсь, мы разберем более эффективный способ обращения с публикацией и распределением контрактов в следующей статье.

Когда мы прогоняем эти тесты на стороне провайдера, то видимо, что на данный момент все выраженные как Customer, так и Order API ожидания могут быть выполнены реализацией Address API.

Вот пример результата, который говорит нам, что все ОК для потребителя Customer и провайдера Address – для взаимодействия, когда Customer API запрашивает существующий адрес.

Verifying a pact between customer_consumer and address_provider
      [Using File src\test\pacts\customer_consumer-address_provider.json]
      Given Customer GET: the address ID matches an existing address
       A request for address data
              returns a response which
                    has status code 200 (OK)
                    has a matching body (OK)

В пятой статье серии мы увидим, что произойдет, если Customer API задает в контракте ожидание, которое (пока что) не может быть выполнено Address API.

Шаг 4: провайдер публикует результаты верификации для информирования потребителя

Для завершения процесса тестирования контрактов, ориентированных на потребителя, провайдер должен проинформировать все задействованные стороны, что с точки зрения верификации контрактов все в порядке. В наших нынешних условиях нет реального способа это сделать, но мы увидим, как завершить этот цикл, в следующей статье.

В этой статье вы увидели, как:

  • Использовать Pact-JVM для выражения нескольких ожиданий от способа коммуникации провайдера с потребителем.
  • Формализовать их в контракте и опубликовать для провайдера, и
  • Верифицировать реализацию по отношению к ожиданиям на стороне провайдера.

В следующей статье вы увидите, как автоматизировать весь процесс CDCT, чтобы интегрировать тесты контрактов в пайплайн CI/CD на стороне как провайдера, так и потребителя.

Весь использованный в статье код можно найти здесь.