Вторая часть статьи о тестировании асинхронных сценариев и PactBroker - тут.

Необходимость написания тестов каждый определяет сам для себя. Модульные и интеграционные тесты вполне могут спасти нас от ошибок, вызванных нашей забывчивостью: убрали проверку на null или удалили строку сохранения сущности в БД? Хороший тест скорее всего обратит наше внимание на эту оплошность, и мы исправим её ещё до того, как задача перейдёт в стадию тестирования. 

Однако, в случаях, когда между системами есть зависимости, даже при условии наличия тестов на обеих сторонах, гарантировать отсутствие ошибок при асинхронных релизах довольно тяжело. «Интегрировались давно. Зависимости между системами призрачны. У нас тесты есть, дока есть. Да кто ж знал то, что они вообще используют этот древний метод? Мы его делали то для себя».

Тест не всегда может обеспечить выполнение договоренностей между системами, даже если он контрактный. Это связано с тем, что он находится в замкнутом контуре, его результаты не видны за границами тестируемой системы. Но есть ли способ зафиксировать контракт, обеспечить его неизменяемость и сделать результаты тестирования наглядными для всех участников взаимодействия? Да, библиотека Pact позволяет писать кроссплатформенные контрактные тесты, в результатах которых заинтересованы сразу несколько систем.

В этой статье мы познакомимся с реализацией библиотеки Pact для .NET. Основной акцент будет сделан на том, как можно писать контрактные тесты, а не почему или зачем это необходимо. Но чтобы не плюхаться животом о воду, начнём с примера.

Осторожно, многобукв
Осторожно, многобукв

Контракт и его тест

Представим систему заказа банковской карты через сайт. В нашей системе веб сайт отправляет на сервер POST запрос с телом: именем клиента и типом заказываемой карты. В случае успеха сервер отвечает 201 статус кодом и некоторым телом, содержащим необходимую информацию для клиента.

Клиент-северное взаимодействие согласно контракту
Все составляющие контракта на желтом поле

Участники нашей системы не понимают друг друга с помощью телепатии - они общаются по HTTP согласно контракту. В совокупности же под контрактом в REST API чаще всего подразумевается сочетание следующего:

  • Эндпоинт запроса

  • Тип HTTP запроса

  • Входные модель и параметры

  • Заголовки

  • Статус-код ответа

  • Модель ответа

  • *Валидация входной и выходной моделей данных (зависит от принятых соглашений между командами)

Со временем любая система разрастается. При тесной коммуникации между командами-владельцами, как правило, изменения фиксируются в моменте, и в API не происходит никаких ломающих изменений. Но в давно забытых сценариях или если система обслуживает не одного и не двух клиентов, уследить за всеми изменениями контракта становится довольно тяжело.

Что произойдет с приложением, в случае если кто-то переименует поле clientName на clientFullName? А если изменится эндпоинт? Тип поля orderId вдруг станет string вместо int? В лучшем случае изменения будут обнаружены на стадии тестирования, и потребуется выделить время на доработку клиента или сервера. В худшем - пользователи будут сталкиваться с ошибкой. В обоих случаях будет потрачено немало времени: разработку, тестирование, и даже релиз придется повторить еще раз. Контрактное тестирование призвано помочь всего этого избежать.

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

А без тестов избежать головной боли совсем никак ?

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

При чём тут Pact?

Процесс тестирования контрактов можно построить своими силами. При желании с помощью средств рефлексии и сериализации можно написать тест, который проверит все составляющие контракта. Осталось выделить всё это в отдельную библиотеку, чтобы писать тесты быстрее. Однако нам также придется самим решить множество других вопросов: обеспечение масштабируемости решения, связность результатов прохода тестов с возможностью релиза, обеспечение наглядности результатов тестирования и т.д. Разумеется, все эти проблемы решаемы, но придется заплатить временем. К счастью, существуют готовые решения, одним из которых является Pact.

Библиотека Pact позиционируется как инструмент для создания контрактных тестов согласно парадигме consumer-driven contracts (контракты, ориентированные на потребителя), но следовать ей необязательно. С разными подходами к ориентированности контракта можно ознакомится в отдельной статье.

Изначально Pact написан на Ruby, но сейчас переписывается под Rust и представляет собой библиотеку для работы с контрактными тестами. На данный момент библиотека поддерживает около 10 языков программирования. В частности, реализация библиотеки для .NET предоставляет методы для написания тестов, генерации пактов, а также запуска мок-серверов. В качестве хранилища контрактов может быть использован PactBroker, а взаимодействовать со всеми инструментами можно с помощью pact-cli, что вовсе не является обязательным. Также имеется и PaaS решение - Pactflow.

Архитектура тестирования с применением Pact

Тезисно обрисуем картину происходящего. Основными участниками и артефактами тестирования с использованием Pact являются:

  • Потребитель контракта (он же consumer, client, subscriber) - система, ответственная за определение необходимого ей контракта и вызовы методов поставщика API. В результате работы тестов на стороне потребителя генерируется файл pact.json, содержащий описание ожидаемых контрактов между сервисом и потребителем.

  • Пакт - JSON файл, определяющий контракты между потребителем и поставщиком. Содержимое файла описывает, как потребитель будет вызывать API поставщика, какие данные будут отправляться, и что ожидается в ответе. По сути пакт представляет собой вариант исполнения контракта в одном из сценариев.

Для ранее приведенного примера, пакт будет выглядеть следующим образом

{
   "consumer":{
      "name":"Web.CardOrdering.Consumer"
   },
   "interactions":[
      {
         "description":"POST /api/card/order",
         "request":{
            "method":"POST",
            "path":"/api/card/order",
            "body":{
               "clientName":"Иван Петров",
               "cardType":100
            }
         },
         "response":{
            "body":{
               "success":true,
               "orderId":5,
               "text":"Карта почти готова"
            },
            "headers":{
               "Content-Type":"application/json; charset=utf-8"
            },
            "status":200
         }}]
}

Подробнее разберем файл во время практики.

  • Брокер пактов - сервис, самописный метод или готовое решение для хранения и доставки пактов от одного участника к другому. Конечно, сгенерированные файлы можно хранить в отдельном репозитории и ручками передавать в тесты на стороне поставщика. Однако в более-менее крупных системах такой процесс просто не приживется. В качестве альтернативы Pact предлагает использовать PactBroker, возможности которого будут рассмотрены во второй части статьи.

  • Поставщик контракта (он же provider, server) - система, ответственная за обеспечение и поддержку запрошенного потребителем контракта. При получении pact.json любым путём, тест на стороне поставщика: загружает из файла данные, выполняется и отправляет результаты верификации брокеру пактов.

Процесс контрактного тестирования инструментами Pact
Контрактное тестирование с Pact

В конечном итоге процесс тестирования можно представить в виде следующих этапов:

  1. Выполнение тестов на стороне потребителя. Тесты пишутся на любом удобном фреймворке вроде xUnit. С помощью методов библиотеки описываются все составляющие контракта, после чего Pact поднимает мок-сервер и, выполняя роль поставщика API, осуществляет запросы самому себе, формируя pact.json;

  2. Отправка сформированного файла в PactBroker;

  3. В начале выполнения тестов поставщика происходит получение имеющихся пактов из PactBroker средствами библиотеки Pact;

  4. Верификация контракта на стороне поставщика. В данном случае тестируется доменная логика сервиса, при необходимости для разных слоёв приложения могут быть использованы моки (БД, запросы в третью систему и т.д.). Применительно к .NET для всех контрактных тестов в проекте поднимается generic-хост, использующий Startup реального приложения. Pact не поддерживает TestServer, подробнее об этом рассказывается дальше в этой статье;

  5. После выполнения тестов PactBroker получает от поставщика результаты верификации. Ознакомиться с ними можно через админку, после чего принять решение о возможности релиза или автоматизировать это с помощью pact-cli.

Стоит отметить,что хоть Pact в первую очередь и ориентируется на consumer-driven стиль определения контрактов, это не мешает использовать его иным образом. В случаях, когда клиенты API не хотят или не могут обезопасить себя от изменений, написанием контрактных тестов на своей стороне, тесты могут быть написаны на стороне поставщика или третьего лица.

Материалы для демо

Надеюсь, что к данному моменту у вас сформировалось представление о том, что такое контрактное тестирование и как в этом может помочь Pact. Пора переходить к практике.

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

  • .NET, библиотека Pact поддерживает .netstandart2.0, демо использует .NET 6;

  • PactNet 5.0.0-beta.2 и PactNet.Abstractions 5.0.0-beta.2 для написания тестов. Причина использования пререлизной версии в том, что последний стабильный релиз библиотеки версии 4.5.0 не поддерживает non-ASCII символы. Также, до 5.x.x версии в качестве сериализатора по умолчанию использовался Newthonsoft.Json вместо более современного System.Text.Json.

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

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

Название

Заказ новой карты

Эндпоинт

api/provider/cards/{userId}

Метод

POST

Заголовки

стандартные для HttpClient

Параметры запроса

accountId:string

Модель запроса

"isNamed": bool

Модель ответа

"id": "string",
"isNamed": bool,
"balance": number,
"state": "string",
"openDate": datetime,
"closeDate": datetime,
"expiryDate": datetime

Статус ответ успеха

200

Статус ответ ошибки

404

Для примера условимся, что при любой клиентской ошибке получаем 404.

Consumer контракта

Для полноты картины наш сервис-клиент будет возвращать результат заказа карты в более читабельном для пользователя виде. К примеру, вот что возвратит клиентское приложение пользователю после обработки ответа от поставщика:

{
  "title": "Ожидайте",
  "description": "Ваша карта 5f3c4a09-dda1-4a66-8b12-07a8c41e3a53 в процессе выпуска"
}

Создадим пустое решение с папками consumer и provider. Распределим слои нашего клиентского приложения по проектам и поместим в consumer/src:

  • Consumer.Domain - в настоящем приложении здесь бы располагалась сложная бизнес логика. В нашем же случае этот проект просто содержит модели, которые одновременно служат как в роли контрактов для чего-то извне, так и для бизнес логики. Также здесь находится простой singleton сервис ConsumerCardService, ответственностью которого является получение, маппинг и возврат данных наружу.

  • Consumer.Integration - слой интеграции с другими системами / хранилищами данных и т.п. В рамках демо, здесь находится вся реализация контрактов со стороны сервиса-потребителя. Здесь представлены POCO классы, соответствующие моделям, описанным в контракте. А класс ProviderCardIntegration, в свою очередь, инкапсулирует в себе пути до эндпоинтов сервиса-поставщивка и типы HTTP запросов. Данный сервис совершает запросы с использованием HttpClient и, в зависимости от успешности кода ответа, возвращает либо объект, либо null.

  • Consumer.Host - единственнный запускаемый проект, отвечает за слой презентации приложения. Стандартный WebApi проект из шаблона с подключенным Swagger и контроллером ConsumerCardsController.

    Кстати, может просто кинуть ссылку на Swagger и не париться ?

    Можно подумать, что документация, сформированная Swagger, это и есть описание контрактов сервиса. Но это не совсем так. Хотя кажется, что Swagger может дать полное представление о том, как работать с сервисом, этот инструмент все же описывает спецификацию, а не контракт. Например: одно из полей модели может принимать лишь строковые значения "red" и "green". При согласовании и документировании контрактов всё это будет обговорено, но если вы просто кинули клиентам вашего API ссылку на сваггер, остается лишь надеяться, что они экстрасенсы.

Пишем тесты для Consumer

Преимуществом предварительного согласования контрактов является возможность покрытия интеграций тестами еще до реализации на стороне поставщика. Для начала создадим папку для наших тестов по пути consumer/test и поместим в неё проект тестов из шаблона Unit Test Project в связке с xUnit. После чего установим зависимости:

<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.11.0"/>
<PackageReference Include="PactNet" Version="5.0.0-beta.2" />
<PackageReference Include="PactNet.Abstractions" Version="5.0.0-beta.2" />
<PackageReference Include="xunit" Version="2.4.1"/>
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.3"/>
<PackageReference Include="coverlet.collector" Version="3.1.0"/>

Поскольку метод заказа карты поставщика может возвращать разные комбинации статус кодов и объектов, создадим для него отдельный класс OrderCardTests в папке Rest/Provider.

Код класса OrderCardTests
public class OrderCardTests
{
    private readonly IPactBuilderV4 _pactBuilder;
    private const string ComType = "REST";

    public OrderCardTests(ITestOutputHelper testOutputHelper)
    {
        var pact = Pact.V4(consumer: "Demo.Consumer", provider: "Demo.Provider", new PactConfig
        {
            Outputters = new[] {new PactXUnitOutput(testOutputHelper)},
            DefaultJsonSettings = new JsonSerializerOptions()
            {
                PropertyNameCaseInsensitive = true,
                PropertyNamingPolicy = JsonNamingPolicy.CamelCase
            }
        });
        _pactBuilder = pact.WithHttpInteractions();
    }

    [Fact(DisplayName = "Demo.Provider при заказе карты возвращает 200 и модель карты, " +
                        "если клиент существует и счёт найден")]
    public async Task OrderCardTests_WhenClientExistWithAccount_ReturnsSuccess200WithCard()
    {
        // Arrange
        var userIdForSuccess = "successId1";
        var accountIdForSuccess = "accountId";
        var actualRequestBody = new CreateCardOrderDto {IsNamed = true};
        var expectedResponseBody = new
        {
            Id = Match.Type(string.Empty),
            ExpiryDate = Match.Type(new DateTime(2027, 02, 14)),
            IsNamed = true,
            Balance = 0,
            State = "PENDING"
        };

        _pactBuilder.UponReceiving($"{ComType}: POST - /api/provider/cards/{{userId}}?accountId - 200 - body")
            .WithRequest(HttpMethod.Post, $"/api/provider/cards/{userIdForSuccess}")
            .WithQuery("accountId", accountIdForSuccess)
            .WithJsonBody(actualRequestBody)
            .WillRespond()
            .WithHeader("Content-Type", "application/json; charset=utf-8")
            .WithStatus(HttpStatusCode.OK)
            .WithJsonBody(expectedResponseBody);

        await _pactBuilder.VerifyAsync(async ctx =>
        {
            using var httpClient = new HttpClient();
            httpClient.BaseAddress = ctx.MockServerUri;
            var contractIntegration = new ProviderCardIntegration(httpClient);

            // Act
            var actualResponseBody = await contractIntegration.OrderCard(userIdForSuccess, accountIdForSuccess,
                actualRequestBody.IsNamed);

            // Assert
            Assert.NotNull(actualResponseBody);
        });
    }

    [Fact(DisplayName = "Demo.Provider при заказе карты возвращает 404, " +
                        "если клиент не существует")]
    public async Task OrderCardTests_WhenClientNotExist_ReturnsFailure404()
    {
        // Arrange
        var userIdForFailure = "failureId1";
        var accountId = "accountId";
        var actualRequestBody = new CreateCardOrderDto {IsNamed = true};

        _pactBuilder.UponReceiving($"{ComType}: POST - /api/provider/cards/{{userId}}?accountId - 404 - no body")
            .WithRequest(HttpMethod.Post, $"/api/provider/cards/{userIdForFailure}")
            .WithQuery("accountId", accountId)
            .WithJsonBody(actualRequestBody)
            .WillRespond()
            .WithStatus(HttpStatusCode.NotFound);

        await _pactBuilder.VerifyAsync(async ctx =>
        {
            using var httpClient = new HttpClient();
            httpClient.BaseAddress = ctx.MockServerUri;
            var contractIntegration = new ProviderCardIntegration(httpClient);

            // Act
            var actualResponseBody =
                await contractIntegration.OrderCard(userIdForFailure, accountId, actualRequestBody.IsNamed);

            // Assert
            Assert.Null(actualResponseBody);
        });
    }
}

Разберем написанный тестовый класс подробнее.

Поле типа IPactBuilderV4 - fluent-builder для пактов, заполнение происходит в конструкторе. Реализация библиотеки Pact для .NET имеет несколько версий, в каждой из которых присутствуют довольно критические изменения в принципах работы с методами библиотеки. Для демо используется билдер версии 4 (Pact.V4), на вход которому передаются названия потребителя и поставщика контракта (разработчик волен назвать их как угодно), а также объект конфигурации. В свою очередь, в настройках конфигурации можно определить путь до директории, где будут генерироваться пакты, а также настроить вывод логов библиотеки на консоль.

Вызов метода WithHttpInteractions() объекта pact определяет, что контракт полагается на HTTP взаимодействие.  Для выполнения наших запросов и создания pact.json будет запущен мок-сервер. Нам не нужно писать / скачивать / разворачивать какое-то отдельное приложение для этого, весь функционал идет из коробки библиотеки PactNet. В свою очередь класс-адаптер PactXUnitOutput нужен для вывода логов библиотеки Pact.

В секции Arrange подготавливаем наши тестовые данные. Особый интерес тут привлекают вызовы методов билдера пактов. Метод UponReceiving() определяет новое взаимодействие между участниками контракта и по своей сути является названием тестового сценария / теста. Текст на кириллице здесь недопустим согласно замыслу разработчиков библиотеки. Обратите внимание на использование константы ComType, это пригодится нам в дальнейшем. С точки зрения смысла текст может быть любым, однако при многократном изменении этого названия в секцию interactions файла pact.json будут добавляться новые взаимодействия, поэтому необходимо за этим следить и вовремя очищать дубликаты.

С вызовом метода WithRequest() всё довольно просто: передается тип и эндпоинт для запроса, который мы собираемся протестировать. По заданному пути мок-сервер вернет ответ, определяемый вызовом метода WillRespond(). Остальные методы, вроде WithQuery() или WithStatus() продолжают настройку объекта запроса и ответа соответственно.

Особого внимания достоин лишь метод WithJsonBody(), принимающий тип dynamic и настройки сериализации (которые также можно задать в PactConfig). Поскольку метод принимает тип dynamic, ему можно передать как строго типизированную модель, так и анонимный объект. Преимуществом последнего является использование матчеров. Матчеры делают процесс подготовки тестовых данных немного гибче, а также позволяют проверять простые бизнес-правила. Например, для полей-массивов можно задать минимальную длину, а при использовании Match.Type(example) привязки к тестовым значениям и вовсе не происходит. Но несмотря на все плюсы, часто прибегать к использованию анонимных объектов с матчерами все же не стоит: количество dynamic в коде становится больше, модель контракта перестает быть связана с конкретным типом, и набор матчеров в PactNet всё же не может похвастаться изобилием и полнотой функционала как в случае с, например, PactJS. В нашем примере мы используем матчеры, однако в репозитории также есть пример с методом GET, где используется конкретный тип.

Наконец, вызов метода VerifyAsync(), принимающего делегат с контекстом мок-сервера, отвечает за создание HTTP клиента с базовым адресом равным адресу мок-сервера. После выполнения запроса в секции Act, мок-сервер получит запрос и на основе построенного пакта вернет необходимый нам результат. В отличие от стандартных тестов, результаты данного теста интересны в первую очередь с точки зрения сгенерированного файла pact.json, однако никто не запрещает разместить в секции Assert ещё какие-то проверки.

Pact.json под микроскопом

В результате успешного завершения теста, в папке проекта Consumer.ContractTests должна появиться папка pacts, а также файл Demo.Consumer-Demo.Provider.json (он же наш pact.json), рассмотрим последний подробнее:

Содержимое Demo.Consumer-Demo.Provider.json
{
  "consumer": {
    "name": "Demo.Consumer"
  },
  "interactions": [{
    "description": "REST: POST - /api/provider/cards/{userId}?accountId - 200 - body",
    "pending": false,
    "request": {
      "body": {
        "content": {
          "isNamed": true
          },
        "contentType": "application/json",
        "encoded": false
      },
      "headers": {
        "Content-Type": ["application/json"]
      },
        "method": "POST",
        "path": "/api/provider/cards/successId1",
        "query": {
          "accountId": ["accountId"]
        }
      },
      "response": {
        "body": {
          "content": {
            "balance": 0,
            "expiryDate": "2027-02-14T00:00:00",
            "id": "",
            "isNamed": true,
            "state": "PENDING"
          },
          "contentType": "application/json",
          "encoded": false
        },
        "headers": {
          "Content-Type": ["application/json; charset=utf-8"]
        },
        "matchingRules": {
          "body": {
            "$.expiryDate": {
              "combine": "AND",
              "matchers": [{"match": "type"}]
            },
            "$.id": {
              "combine": "AND",
              "matchers": [{"match": "type"}]
            }
          },
          "header": {}
        },
        "status": 200
      },
      "type": "Synchronous/HTTP"
    },
  ],
  "metadata": {
    "pactRust": {
      "ffi": "0.4.16",
      "models": "1.1.19"
    },
    "pactSpecification": {
      "version": "4.0"
    }
  },
  "provider": {
    "name": "Demo.Provider"
  }
}

  1. Секции consumer и provider содержат лишь названия взаимодействующих сервисов, заданных при конструировании билдера пактов.

  2. Секция metadata содержит метаданные о зависимостях библиотеки Pact, а также версию используемого построителя пактов (Pact.V4).

  3. Секция interactions содержит описание всех контрактов между сервисами. Не сложно узнать в узлах description, request, response, headers и status вызовы методов библиотеки из секции Arrange.

  4. Секция matchingRules содержит описание правил нашего матчера и особого интереса не представляет.

Provider контракта и его тест

Теперь, когда клиентский сервис и тесты для слоя интеграции на его стороне готовы, реализуем логику на стороне сервиса-поставщика. Структура проекта провайдера будет очень похожа на структуру проекта потребителя за исключением нескольких моментов. Создадим в папке provider/src следующие проекты:

  • Provider.Domain - по аналогии с доменной сборкой потребителя здесь расположена бизнес логика приложения. В целях демонстрации здесь также расположился статический класс SomeDatabase, который играет роль контекста базы данных. Также репозиторий данных реализует интерфейс ICardAccountsRepository, что позволит использовать моки в наших тестах.

  • Provider.Contracts - простая сборка POCO классов, соответствующих моделям, описанным в контракте. В реальном приложении данный проект часто упаковывается в nuget-пакет и распространяется между командами-интеграциями. В нашем случае сервис-потребитель не нуждается в распространяемой сборке поскольку имеет соответствующий набор DTO.

  • Provider.Host - на данный момент почти такой же, как Consumer.Host. Часть контракта: пути до эндпоинтов и типы HTTP запросов реализована здесь.

Сервис-поставщик пока реализует одну функцию - создание сущности карты. Как можно заметить, при сравнении с ответом сервиса-потребителя: не все поля контракта находят применение на стороне клиента. Это нормально, поскольку данные поля могут быть нужны другим клиентам API:

{
  "id": "d1960b79-9cfb-4eae-b1fd-4e1ec906d268",
  "isNamed": true,
  "balance": 0,
  "state": "PENDING",
  "openDate": "2024-05-24T16:53:43.5207193+03:00",
  "closeDate": null,
  "expiryDate": "2027-05-24T16:53:43.5207193+03:00"
}

С одной стороны реализация контрактного тестирования на стороне поставщика легче, поскольку на все тесты потребителя достаточного одного теста поставщика. С другой стороны - автор тестов на стороне поставщика должен продумать, как хранить тестовые данные:

  1. Хранение тестовых данных в реальном хранилище. Самый простой в реализации, но самый дорогой относительно последствий. Клиент ожидает, что запрос get:userId=1 API вернет body { Name = 'Иван' }. Поместим в хранилище сервиса запись {id:1, Name: 'Иван'} и постараемся не трогать её никогда, иначе есть риск сломать тесты.

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

  3. Использование моков. Если после этих слов в связке с ASP.NET вы сразу вспомнили о TestServer с in-memory Web API, то для вас плохие новости. Pact не поддерживает хост такого рода, поскольку для взаимодействия с TestServer используется специальный клиент. По этой причине в качестве хоста используется стандратный generic-host, в который уже будут прокидываться моки. Данный подход, конечно же, далек от идеала, тем не менее довольно удобен и с задачей справляется.


Итак, тестовый класс на стороне поставщика:

Код класса ContractsWithConsumerTests
public class ContractsWithConsumerTests : IDisposable
{
    private readonly Uri _serverUri = new ("http://localhost:5000");
    private const string ComType = "REST";
    private readonly Mock<ICardAccountsRepository> _cardAccountsRepository;
    private readonly PactVerifier _pactVerifier;
    private bool _disposedValue;
    
    private readonly IHostBuilder _serverBuilder;
    private IHost _server;

    public ContractsWithConsumerTests(ITestOutputHelper outputHelper)
    {
        _cardAccountsRepository = new Mock<ICardAccountsRepository>();
        var config = new PactVerifierConfig
        {
            Outputters = new []{ new PactXUnitOutput(outputHelper) }
        };
        _pactVerifier = new PactVerifier("Demo.Provider", config);
        Environment.SetEnvironmentVariable("PACT_DO_NOT_TRACK", "true");
        _serverBuilder = Microsoft.Extensions.Hosting.Host.CreateDefaultBuilder()
            .ConfigureWebHostDefaults(webBuilder =>
            {
                webBuilder.UseUrls(_serverUri.ToString());
                webBuilder.UseStartup<Startup>();
            }); 
    }
    
    [Fact(DisplayName = "Rest контракты с потребителем Demo.Consumer соблюдаются")]
    public void Verify_RestDemoConsumerContacts()
    {
        // Arrange
        var successId = "successId1";
        var failureId = "failureId1";
        var accountId = "accountId";
        _cardAccountsRepository.Setup(x => x.AddCard(successId, accountId, It.IsAny<bool>()))
            .ReturnsAsync(DataForTests.CardSuccessResult);
        _cardAccountsRepository.Setup(x => x.AddCard(failureId, accountId, It.IsAny<bool>()))
            .ReturnsAsync((CardInfo?)null);

        _serverBuilder.ConfigureServices(services => 
                services.AddSingleton<ICardAccountsRepository>(_ => _cardAccountsRepository.Object));
        _server = _serverBuilder.Build();
        _server.StartAsync();
        
        // Act & Assert
        _pactVerifier
            .WithHttpEndpoint(_serverUri)
            .WithFileSource(new FileInfo(@"..\..\..\pacts\Demo.Consumer-Demo.Provider.json"))
            .WithFilter(ComType)
            .Verify();
    }
    
    protected virtual void Dispose(bool disposing)
    {
        if (!_disposedValue)
        {
            if (disposing)
            {
                _server.StopAsync().GetAwaiter().GetResult();
                _server.Dispose();
                _pactVerifier.Dispose();
            }
            _disposedValue = true;
        }
    }

    public void Dispose() => Dispose(true);
}

Поля IHostBuilder и IHost - как было сказано выше в статье, Pact не работает в связке с TestServer, ввиду этого в рамках теста на стороне провайдера создается реальный сервер приложения. Для этого необходимо вернуть в шаблон проекта .NET 6 класс Startup, а также немного подкорректировать Program.cs. Запуск приложений для тестов является довольно дорогой операцией, поэтому часто такой функционал выделяют в общий контекст для всех запускаемых тестов класса. В нашем случае общий контекст не столь критичен ввиду того, что для тестирования контрактов с конкретным клиентом достаточно одного теста (тестовый сервер и так будет поднят единожды).

Экземпляр класса PactVerifier служит для проверки соответствия фактической реализации контракта определению изложенному в pact.json. Для этого в метод WithHttpEndpoint() передается адрес запущенного для теста приложения, а метод WithFileSource() принимает путь по которому расположен pact.json, сгенерированный на стороне клиента. Метод WithFilter() используется для фильтрации взаимодействий, полагающихся на REST, и продемонстрирует свою пользу, когда появятся сценарии, использующие RabbitMq.

По умолчанию PactNet, как и остальные реализации библиотеки, собирает статистику о системе и среде, на которых запускаются тесты. Для отключения мониторинга необходимо установить переменную окружения PACT_DO_NOT_TRACK в значение true.

В свою очередь объект Mock<ICardAccountsRepository> библиотеки Moq используется для подготовки фиктивных данных для тестирования. В целом применять моки стоит к хранилищам и третьим системам. Наш мок доменного сервиса создается для демонстрации возможности их использования вместе с PactNet.


Как мы видим, тестирование всех взаимодействий, описанных в Demo.Consumer-Demo.Provider.json производится с помощью одного теста на стороне провайдера. На первый взгляд неочевидность результатов такого теста может отталкивать, однако, как было сказано ранее, все результаты представляются в виде информативных логов, а также наглядно отображаются в панели PactBroker, которую мы рассмотрим во второй части.

Ломаем API

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

Для начала запустим тест на стороне провайдера и убедимся в том, что сейчас все контракты соблюдаются.

Контракт соблюдается, тесты проходят
Контракт соблюдается, тесты проходят

Проделаем несколько операций в модели CardInfoResponse на стороне провайдера, чтобы сломать наши контракты:

  1. Переименуем поле OpenDate на OpenAt;

  2. Изменим тип поля CloseDate с Datetime на string;

  3. Переименуем Id на CardId, изменим тип поля с string на int;

  4. Добавим поле PaymentSystem и установим в значение любую строку;

  5. Удалим поле Balance;

  6. Изменим тип поля IsNamed c bool на int;

  7. Изменим значение константы PendingStateс PENDING на WAITING;

  8. Изменим возвращаемое значение метода CreateCardOrder() контроллера с NotFound на BadRequest;

  9. Добавим в метод CreateCardOrder() строчку //Response.ContentType = "text/plain" тем самым перезаписывая заголовок ответа.

В результате работы нашего теста будет выброшено исключение PactFailureException, и на консоль будут выведено несколько сообщений:

Starting verification...
Pact verification failed

Verifier Output
---------------

Verifying a pact between Demo.Consumer and Demo.Provider

  REST: POST - /api/provider/cards/{userId}?accountId - 200 - body (20ms loading, 363ms verification)
    returns a response which
      has status code 200 (OK)
      includes headers
        "Content-Type" with value "application/json; charset=utf-8" (OK)
      has a matching body (FAILED)

  REST: POST - /api/provider/cards/{userId}?accountId - 404 - no body (20ms loading, 11ms verification)
    returns a response which
      has status code 404 (FAILED)
      has a matching body (OK)


Failures:

1) Verifying a pact between Demo.Consumer and Demo.Provider - REST: POST - /api/provider/cards/{userId}?accountId - 200 - body
    1.1) has a matching body
           $.isNamed -> Expected 1 (Integer) to be equal to true (Boolean)
           $ -> Actual map is missing the following keys: balance, id

2) Verifying a pact between Demo.Consumer and Demo.Provider - REST: POST - /api/provider/cards/{userId}?accountId - 404 - no body
    2.1) has status code 404
           expected 404 but was 400

There were 2 pact failures

Секция Failures указывает на найденные несоответствия в контрактах. Сообщение "There were 2 pact failures" указывает, что ошибки были найдены в двух пактах. Изменения полей OpenDate и CloseDate не вызвали никаких ошибок, это связано с тем, что сервис Demo.Consumer не использует эти поля, и их просто нет в файле.

Согласно robustness principle, система должна быть либеральна к данным на приём, и консервативна относительно отсылаемых данных, именно этого подхода придерживается Pact. Поэтому мы не получаем ошибку при добавлении поля PaymentSystem. Обратная ситуация складывается с удалением поля Balance, что приводит к ошибке "Actual map is missing the following keys: balance".

В то же время изменение типа поля IsNamed вызывало ошибку "Expected 1 (Integer) to be equal to true (Boolean)". А сообщение "Actual map is missing the following keys: id" вызвано переименованием поля Id. Если сейчас вернуть исходное имя полю, будет получена уже знакомая нам ошибка: "Expected 474 (Integer) to be the same type as '' (String)".

Как вы, наверное, заметили пункт 7 не оказал влияния на появление какого-либо сообщения. Причиной этого является забывчивость программиста, который не изменил статус в фиктивных данных DataForTests.CardSuccessResult, использующихся в моке. Проверку наполнения полей можно назвать бизнес правилами, и при использовании фиктивных данных в контрактных тестах её можно очень легко потерять (не мокайте доменный код сервиса). Теперь изменим наши фиктивные данные и получим ошибку "Expected 'WAITING' (String) to be equal to 'PENDING' (String)", иллюстрирующую возможность тестирования частей контракта, относящихся к валидации бизнес правил при грамотном использовании моков.

Вторая запись в секции Failures говорит о несоответствии результатов негативного ответа. Потребитель контракта ожидал код ответа 404, однако фактическая реализация поставщика API в случае ошибки возвращает 400.

Также, если раскомментировать строчку добавленную в пункте 9 для первого пакта, будет получено сразу три ошибки, вызванные несоответствием ожидаемого и фактического заголовка ContentType:

  1. expected a body of 'application/json' but the actual content type was 'text/plain'

  2. expected 200 but was 406

  3. expected header 'Content-Type' to have value 'application/json; charset=utf-8' but was 'text/plain'

При изменении эндпоинта будет получена ошибка о несоответствии выходной модели (пустое тело), заголовков и статус-кода (404, поскольку эндпоинт попросту не будет найден).

Конец первой части

На самом деле это весь материал, который необходим вам для написания контрактных тестов с применением PactNet. Это, так сказать, база. Но...

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

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