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

На примере RabbitMq определим список возможных составляющих контракта. В зависимости от конкретного инструмента список может сильно варьироваться:

  • Модель сообщения (структура, типы данных, формат)

  • Название, тип обменника

  • Название очереди

  • Настройки вроде routing key, topics, reply-to и т.д.

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

  • .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;

  • библиотека EasyNetQ 7.8.0 и EasyNetQ.Serialization.SystemTextJson 7.8.0 для работы с RabbitMq;

  • Докер для запуска контейнеров RabbitMq и PactBroker.

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

Асинхронное взаимодействие между сервисами

Для тестирования сценариев с использованием RabbitMq добавим к существующей функциональности уведомление о готовности карты. Так, поставщик контракта (Demo.Provider) отправит в очередь модель с признаком необходимости отправки уведомления клиенту. В свою очередь потребитель (Demo.Consumer) обработает сообщение и, в зависимости от значения поля ShouldBeNotified, выведет на консоль сообщение, имитирующее уведомление пользователю.

Представим, что в результате согласования контракта были зафиксированы следующие договоренности:

  • Модель сообщения содержит поля описанные в классе CardOrderSatisfiedEvent и включает: код карты-продукта, идентификатор пользователя и признак необходимости отправки уведомления;

  • Название обменника SpecialExchangeName, тип direct;

  • Routing-key имеет значение super-routing-key.

Для реализации данного сценария добавим в сборки Consumer.Host и Provider.Host следующие зависимости:

 <PackageReference Include="EasyNetQ" Version="7.8.0" />
 <PackageReference Include="EasyNetQ.Serialization.SystemTextJson" Version="7.8.0" />

С целью упрощения реализуем отправку сообщения непосредственно в контроллере сервиса Demo.Provider:

[HttpPost("order-satisfied/{userId}")]
public async Task<ActionResult> SendCardOrderSatisfiedEvent(string userId)
{
    var advancedBus = RabbitHutch.CreateBus("host=localhost", s =>
    {
        s.EnableConsoleLogger();
        s.EnableSystemTextJson();
    }).Advanced;
    var exchange = await advancedBus
                        .ExchangeDeclareAsync("SpecialExchangeName", "direct");
    var message = new Message<CardOrderSatisfiedEvent>(
          new CardOrderSatisfiedEvent
          {
            UserId = userId,
            CardCode = Random.Shared.Next(100)
          });
    await advancedBus.PublishAsync(exchange, "super-routing-key", false, message);
    return Ok();
}

В свою очередь подписку на событие со стороны потребителя реализуем прямо в классе Program, добавим где-нибудь следующий код:

var advanced = RabbitHutch.CreateBus("host=localhost:5672;username=guest;password=guest", 
  s =>
      {
        s.EnableConsoleLogger();
        s.EnableSystemTextJson();
        s.Register<ITypeNameSerializer, SimpleTypeNameSerializer>();
      }).Advanced;
var exchange = advanced.ExchangeDeclare("SpecialExchangeName", "direct");
var queue = advanced.QueueDeclare("SpecialQueueName");
advanced.Bind(exchange, queue, routingKey: "super-routing-key");
advanced.Consume<CardOrderSatisfiedEvent>(queue, (message, _) =>
    Task.Factory.StartNew(() =>
    {
        var handler = app.Services.GetRequiredService<ConsumerCardService>();
        if(message.Body.ShouldBeNotified)
            handler.PushUser(message.Body);
    }));

// BAD CODE, только для демо
class SimpleTypeNameSerializer : ITypeNameSerializer
{
    public string Serialize(Type type) => type.Name;
    public Type DeSerialize(string typeName) => typeof(CardOrderSatisfiedEvent);
}  

Обычно при работе с EasyNetQ поставщик контракта создает отдельную nuget-сборку с необходимой моделью сообщения, поскольку по умолчанию для сериализации и десериализации используется свойство сообщения messageType. В рассматриваемом демо отсутствуют nuget-сборки, проблема несоответствия Type.FullName двух моделей CardOrderSatisfiedEvent в разных проектах решается с помощью класса SimpleTypeNameSerializer, который переопределяет поведение десериализации. Просто добавлять ссылку на сборку с контрактом бессмысленно: мы не сможем имитировать "развитие" контракта и нарушить пакт.

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

docker run --rm -d -p 15671:15671/tcp -p 15672:15672/tcp -p 25672:25672/tcp 
-p 4369:4369/tcp -p 5671:5671/tcp -p 5672:5672/tcp rabbitmq:3-management

Тестирование на стороне потребителя

Для начала определимся с понятиями consumer / provider и subscriber / publisher, поскольку терминология здесь немного не очевидна и может запутать. Как было сказано в первой части, в терминах Pact consumer`ом считается клиент, потребитель API. Также под этим понятием понимается подписчик, получатель события или subscriber в терминах брокеров сообщений. Несмотря на то, что полезную работу над данными выполняет subscriber, в асинхронных системах поставщиком является publisher. Из этого следует, что в нашей демонстрации, сервис Demo.Provider является отправителем сообщения (publisher) и источником события (provider), а сервис Demo.Consumer служит получателем сообщения (subscriber) и потребителем события (consumer).

Создадим в папке Consumer.ContractTests/RabbitMq класc CardOrderSatisfiedEventTests и наполним его следующим содержимым:

Код класса CardOrderSatisfiedEventTests
public class CardOrderSatisfiedEventTests
{
    private readonly IMessagePactBuilderV4 _pactBuilder;
    private const string ComType = "RABBITMQ";

    public CardOrderSatisfiedEventTests(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.WithMessageInteractions();
    }

    [Fact(DisplayName = "Demo.Provider присылает корректный контракт и пуш отправляется, " +
                        "когда получено событие и необходимо уведомление клиента")]
    public void CardOrderSatisfiedEvent_WhenModelCorrectAndShouldBePushed_SendsPush()
    {
        // Arrange
        var message = new
        {
            UserId = Match.Type("rabbitmqUserId"),
            CardCode = Match.Integer(100),
            ShouldBeNotified = true
        };

        _pactBuilder
            .ExpectsToReceive($"{ComType}: CardOrderSatisfiedEvent with push")
            .WithMetadata("exchangeName", "SpecialExchangeName")
            .WithMetadata("routingKey", "super-routing-key")
            .WithJsonContent(message)

            // Act
            .Verify<CardOrderSatisfiedEvent>(msg =>
            {
                // Assert
                // место для вызова IConsumer.Handle и проверки логики работы обработчика
                //_consumerCardService.Verify(x => x.PushUser(msg), Times.Once);
            });
    }
    
    [Fact(DisplayName = "Demo.Provider присылает корректный контракт и пуш не отправляется, " +
                        "когда получено событие и не нужно уведомление клиента")]
    public void CardOrderSatisfiedEvent_WhenModelCorrectAndShouldNotBePushed_DontSendPush()
    {
        // Arrange
        var message = new
        {
            UserId = Match.Type(string.Empty),
            CardCode = Match.Integer(100),
            ShouldBeNotified = false
        };

        _pactBuilder
            .ExpectsToReceive($"{ComType}: CardOrderSatisfiedEvent no push")
            .WithMetadata("exchangeName", "SpecialExchangeName")
            .WithMetadata("routingKey", "super-routing-key")
            .WithJsonContent(message)

            // Act
            .Verify<CardOrderSatisfiedEvent>(msg =>
            {
                // Assert
                // место для вызова IConsumer.Handle и проверки логики работы обработчика
                //_consumerCardService.Verify(x => x.PushUser(msg), Times.Never);
            });
    }
}

  • Вместо IPactBuilderV4 используется IMessagePactBuilderV4, определяющий пакты для систем, взаимодействующих с помощью брокеров сообщений. Создается объект путем вызова метода WithMessageInteractions(). Остальной код конфигурации не отличается от части, использованной в тестах для HTTP;

  • Метод ExpectsToReceive() по аналогии с UponReceiving() определяет название теста, а знакомый метод WithJsonContent() определяет структуру и содержимое модели события. В то же время вызов метода WithMetadata() позволяет зафиксировать другие артефакты сообщения вроде заголовков, свойств и прочих настроек. В нашем случае тест ожидает от отправителя сообщений использования обменника с названием SpecialExchangeName и топика super-routing-key;

  • Синхронный Verify все также отвечает за формирование файла pact.json, но, в отличие от HTTP версии, здесь не нужно поднимать сервер, а в секции Assert можно проверить работу обработчика сообщения.

В целом, в рамках тестирования event-driven систем, Pact абстрагируется от понятия брокеров сообщений и не подразумевает реального асинхронного взаимодействия во время тестирования. Основное внимание уделяется соответствию модели события и отчасти её валидации. Ввиду такого обобщения брокеров, Pact не предоставляет конкретных методов для работы с каждым из них и может предложить только метод WithMetadata().

Так как значения для сущностей вроде названий обменников и топиков чаще всего хранятся только там, где непосредственно используются, проверка на их соответствие контракту усложняется. Дублирование их значений в тесте (как в нашем примере) скорее всего будет начисто забыто, чтение из IConfiguration потребует дополнительных усилий, а зависимость значений от среды окружения (dev, test, prod) лишь усугубляет всю эту ситуацию. Поэтому выстроить доверие к тесту, проверяющему данные артефакты, довольно сложно.

Тестирование на стороне поставщика

Теперь создадим в папке Provider.ContractTests/RabbitMq класc ContractWithConsumerTests и наполним его следующим содержимым:

Код класса ContractWithConsumerTests
public class ContractWithConsumerTests : IDisposable
{
    private readonly PactVerifier _pactVerifier;
    private const string ComType = "RABBITMQ";

    private readonly JsonSerializerOptions _jsonSerializerOptions = new()
    {
        PropertyNameCaseInsensitive = true,
        PropertyNamingPolicy = JsonNamingPolicy.CamelCase
    };

    public ContractWithConsumerTests(ITestOutputHelper testOutputHelper) 
    {
        _pactVerifier = new PactVerifier("Demo.Provider", new PactVerifierConfig
        {
            Outputters = new []{ new PactXUnitOutput(testOutputHelper) }
        });
    }
    
    [Fact(DisplayName = "RabbitMq контракты с потребителем Demo.Consumer соблюдаются")]
    public void Verify_RabbitMqDemoConsumerContacts()
    {
        // Arrange
        var userId = "rabbitUserId";
        var cardCode = 100;
        var metadata = new Dictionary<string, string>
        {
            {"exchangeName", "SpecialExchangeName"},
            {"routingKey", "super-routing-key"}
        };
        _pactVerifier.WithMessages(scenarios =>
            {
                scenarios.Add($"{ComType}: CardOrderSatisfiedEvent with push", builder =>
                {
                    builder.WithMetadata(metadata).WithContent(() => new CardOrderSatisfiedEvent
                    {
                        UserId = userId, CardCode = cardCode, ShouldBeNotified = true
                    });
                });
                scenarios.Add($"{ComType}: CardOrderSatisfiedEvent no push", builder =>
                {
                    builder.WithMetadata(metadata).WithContent(() => new CardOrderSatisfiedEvent
                    {
                        UserId = userId, CardCode = cardCode, ShouldBeNotified = false
                    });
                });
            }, _jsonSerializerOptions)
            .WithFileSource(new FileInfo(@"..\..\..\pacts\Demo.Consumer-Demo.Provider.json"))
            
            // Act && Assert
            .WithFilter(ComType)
            .Verify();
    }

    public void Dispose()
    {
        _pactVerifier?.Dispose();
    }
}          

Вместо вызываемого раньше WithHttpEndpoint(), который использовал запущенное нами рядом приложение, метод WithMessages() выбирает первый свободный порт и отвечает за поднятие мок-хоста по адресу http://localhost:port/pact-messages/. Также данный метод принимает набор сценариев, каждый из которых включает в себя название, метаданные и тело сообщения. Такое решение обусловлено отсутствием реального брокера сообщений в рамках тестирования с Pact. Мы просто создаем абстракцию в виде MessageProvider и заполняем его нашими событиями. При выполнении теста этот виртуальный брокер сверит хранящиеся в нём сообщения с входными моделями из файла pact.json и выдаст результат при вызове метода Verify(). Также, поскольку теперь в файле с пактами содержатся как синхронные, так и асинхронные взаимодействия, вызов метода WithFilter() позволяет проверить только последние.

Смотрим pact.json и снова ломаем API

В результате прогона теста на стороне Demo.Provider в уже известный нам файл будет добавлено еще два взаимодействия, структура которых в общем похожа на предыдущие примеры. К основным отличиям можно отнести разве что определяемое уже нами содержимое секции metadata, а также иной тип взаимодействия в секции type.

{
  "contents": {
    "content": {
      "cardCode": 100,
      "shouldBeNotified": true,
      "userId": "rabbitmqUserId"
    },
    "contentType": "application/json",
    "encoded": false
  },
  "description": "RABBITMQ: CardOrderSatisfiedEvent with push",
  "matchingRules": {
    "body": {
      "$.cardCode": {
        "combine": "AND",
        "matchers": [{"match": "integer"}]
      },
      "$.userId": {
        "combine": "AND",
        "matchers": [{"match": "type"}]
      }
   }
},
  "metadata": {
    "exchangeName": "SpecialExchangeName",
    "routingKey": "super-routing-key"
},
    "pending": false,
    "type": "Asynchronous/Messages"
},

{"description": "RABBITMQ: CardOrderSatisfiedEvent no push"...}

Особых отличий в поведении от HTTP теста нет и при внесении несогласованных изменений в контракт. Так, при каком-либо изменении модели или метаданных Pact выдаст ошибку, вроде следующей:

Failures:

1) Verifying a pact between Demo.Consumer and Demo.Provider - RABBITMQ: CardOrderSatisfiedEvent with push
    1.1) has a matching body
           $.userId -> Expected 'rabbitmqUserId' (String) to be equal to 'diffUserId' (String)
           $ -> Actual map is missing the following keys: cardCode

    1.2) has matching metadata
           Expected message metadata 'routingKey' to have value '"super-routing-key"' but was '"diff-super-routing-key"'
           Expected message metadata 'exchangeName' to have value '"SpecialExchangeName"' but was '"DiffSpecialExchangeName"'

Знакомимся с PactBroker

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

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

Для работы PactBroker`у необходима база данных для хранения существующих контрактов. В официальной документации представлена вся информация о вариантах запуска PactBroker, мы же поднимем экземпляр с помощью docker-compose, представленного ниже. Файл описывает запуск кластера СУБД PostgreSQL 15, а также зависящего от него экземпляра брокера.

Код docker-compose.yaml
version: "3.9"

services:
  postgres:
    image: postgres:15
    container_name: pact-postgres
    ports:
      - "5432:5432"
    healthcheck:
      test: psql postgres -U postgres --command 'SELECT 1'
    environment:
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: postgres
      POSTGRES_DB: postgres
  
  broker:
    image: pactfoundation/pact-broker:latest-multi
    container_name: pact-broker-1
    depends_on:
      - postgres
    ports:
      - "9292:9292"
    restart: always
    environment:
      PACT_BROKER_ALLOW_PUBLIC_READ: "false"
      PACT_BROKER_BASIC_AUTH_USERNAME: admin
      PACT_BROKER_BASIC_AUTH_PASSWORD: pass
      PACT_BROKER_DATABASE_URL: "postgres://postgres:postgres@postgres/postgres"

    healthcheck:
      test: ["CMD", "curl", "--silent", "--show-error", "--fail",
             "http://pactbroker:pactbroker@localhost:9292/diagnostic/status/heartbeat"]
      interval: 1s
      timeout: 2s
      retries: 5

В результате исполнения данного файла запускаются приложения базы данных и брокера.

Запущенные Postgress и PactBroker
Запущенные Postgress и PactBroker

Прикручиваем автоматизированную доставка пактов

Сохранение сгенерированных пактов в PactBroker

Несмотря на то, что библиотека PactNet предоставляет возможность получать из брокера пакты (что мы увидим совсем скоро), способность отправлять их в него она утратила. Субъективно, правильным решением в среде для реального приложения является отдельный шаг отправки сгенерированных пактов используя pact-cli. Но так как обзор pact-cli выходит за рамки данного материала, в нашем демо мы используем довольно противоречивое, однако более понятное для целей демонстрации решение.

Создадим в папке shared новый проект библиотеки классов. В нашем случае сгенерированные файлы будут отправляется брокеру в конце работы всех тестов класса. Для достижения этой цели используем интерфейс IClassFixture и метод Dispose(). PactBroker предоставляет перечень методов API для работы с ним, ознакомится с которыми можно в панели брокера. Для отправки пактов будем использовать метод pacts/provider/{provider}/consumer/{consumer}/version/{consumerVersion}.

Создадим класс для отправки пактов и назовем его PactBrokerPublisher. В рамках демо ограничимся простой логикой, суть класса сводится к вызову PUT метода по пути, представленному выше:

private readonly HttpClient _httpClient;
    
public PactBrokerPublisher(HttpClient httpClient) {_httpClient = httpClient;}
    
public async Task Publish(string consumer, string provider, string content, string consumerVersion)
{
    var response = await _httpClient
    .PutAsync($"pacts/provider/{provider}/consumer/{consumer}/version/{consumerVersion}",
    new StringContent(content)
    {
      Headers = { ContentType = new MediaTypeHeaderValue("application/json") }
    });

    if (response.IsSuccessStatusCode == false)
        throw new ArgumentNullException($"Ошибка во время отправки пакта в PactBroker: {response.StatusCode}");
}

Для отправки пактов в конце выполнения тестов всего класса создадим класс PactBrokerFixture и реализуем в нём интерфейс IDisposable. Цель класса заключается в отправке файла пактов PactBroker`у во время вызова метода Dispose().

private readonly Uri _pactBrokerUri = new ("http://localhost:9292");
private readonly string _pactUsername = "admin";
private readonly string _pactPassword = "pass";
private readonly PactBrokerPublisher _pactBrokerPublisher;

public string ConsumerVersion { get; set; } 
public IPact? PactInfo { get; set; }
    
public PactBrokerFixture()
{
    var baseAuthenticationString = Convert.ToBase64String(System.Text.Encoding.ASCII.GetBytes($"{_pactUsername}:{_pactPassword}"));
    _pactBrokerPublisher = new PactBrokerPublisher(new HttpClient
    {
        DefaultRequestHeaders =
        {
            Authorization = new AuthenticationHeaderValue("Basic", baseAuthenticationString)
        },
        BaseAddress = _pactBrokerUri
    });
}
    
public void Dispose()
{
    Task.Run(async () =>
    {
        var versionSuffix = Guid.NewGuid().ToString().Substring(0, 5);
        var pactJson = await File.ReadAllTextAsync($"{PactInfo.Config.PactDir}/{PactInfo.Consumer}-{PactInfo.Provider}.json");
        await _pactBrokerPublisher.Publish(
                consumer: PactInfo.Consumer, provider: PactInfo.Provider, content: pactJson,
                $"{ConsumerVersion}-{versionSuffix}");
    });
}

Дело осталось за малым, выполним следующие шаги:

  1. Классы OrderCardTests и CardOrderSatisfiedEventTests реализуют интерфейс IClassFixture<PactBrokerFixture>, а также внедряют в конструктор зависимость PactBrokerFixture.

  2. Сборка Consumer.Domain имеет тег версии <Version>1.0.0</Version>.

  3. Конструкторы классов OrderCardTests и CardOrderSatisfiedEventTests записывают значения в свойства фикстуры: ConsumerVersion и PactInfo.

brokerFixture.PactInfo = pact;
brokerFixture.ConsumerVersion = Assembly
    .GetAssembly(typeof(CardOrderSatisfiedEvent))?
    .GetCustomAttribute<AssemblyInformationalVersionAttribute>()?
    .InformationalVersion;

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

Получение пактов из PactBroker

Для скачивания существующих пактов библиотека PactNet предоставляет метод WithPactBrokerSource(), вызов которого мы добавим в два наших теста на стороне поставщика.

_pactVerifier
    ...
    .WithPactBrokerSource(new Uri("http://localhost:9292"), options =>
    {
        options.BasicAuthentication("admin", "pass");
        options.PublishResults(_providerVersion + $" {Guid.NewGuid().ToString().Substring(0, 5)}");
    })
    // .WithFileSource(new FileInfo(@"..\..\..\pacts\Demo.Consumer-Demo.Provider.json"))
    ...

Метод BasicAuthentication() отвечает за аутентификацию в PactBroker, учетные данные для которого были заданы в момент поднятия контейнеров. В свою очередь метод PublishResult() вызывать необязательно, поскольку он необходим лишь для отображения результатов верификации контракта поставщиком в панели PactBroker. Поле _providerVersion заполняется аналогично ConsumerVersion, который мы видели ранее, но тег версии уже принадлежит сборке Provider.Contracts.

Обзор панели PactBroker

Наконец оба проекта покрыты контрактными тестами, а сгенерированные пакты доставляются с помощью PactBroker. Последовательно запустим тесты в папке consumer и provider. Если все проверки прошли, то открыв в браузере страницу http://localhost:9292/, можно увидеть таблицу, отображающую имеющиеся у PactBroker пакты.

Домашнаяя страница панели брокера
Домашнаяя страница панели брокера

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

Брокер хранит пакты в базе данных:

Хранилище пактов
Хранилище пактов

При нажатии на иконку документа открывается просмотр пактов. К сожалению, при использовании PactV4 файл пакта по каким-то причинам не преобразуется в удобный для чтения формат и отображается просто как JSON файл. В то же время предыдущие версии, вроде PactV3 успешно парсятся. Сравните:

Отображения пакта при использовании PactV4
Отображения пакта при использовании PactV4
Отображения пакта при использовании PactV3
Отображения пакта при использовании PactV3

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

Перейдем в матрицу контрактов между системами. В ней отображаются зависимости между системами, а также результаты верификаций. Как мы видим, контракт между Demo.Consumer версии 1.0.0-d1549 и Demo.Provider версии 1.0.0 соблюдается обеими сторонами. Но, если поставщик контракта вдруг внесет в контракт какое-то несогласованное изменение, то пакт между системами будет нарушен. Так, Demo.Provider версии 2.0.0 и Demo.Consumer версии 1.0.0-d1549 уже не смогут работать без ошибок.

Зависимости между системами
Зависимости между системами

При нажатии на значение в столбце Pact verified можно увидеть ошибку, которая также выводилась в консоль приложения:

Результат изменения названия поля на стороне поставщика
Результат изменения названия поля на стороне поставщика

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

Для демонстрации добавлено еще несколько провайдеров
Для демонстрации добавлено еще несколько провайдеров

Заключение

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

  • ProviderState - функционал для задания поставщику перед тестом некоторого состояния. К примеру, проверка статуса заказа подразумевает, что поставщик уже хранит сущность определенного заказа. Чтобы не поддерживать большой объем тестовых данных на стороне тестов поставщика, существует возможность непосредственно в тесте потребителя задать состояние второго участника. Пример реализации такого сценария можно найти в репозитории библиотеки PactNet;

  • Branches, tags - Pact имеет поддержку ветвления кода, лучшее применение которого раскрывается в совокупности с применением pact-cli;

  • pact-cli;

  • GraphQL API;

  • WebHooks;

  • Matchers - реализация под .NET все же несколько сырая по сравнению с PactJS;

  • Остальные методы PactBroker API, с помощью которых в теории можно сконструировать гибкое решение вообще без использования PactNet;

  • Много чего еще касательно конфигурирования, чтения пактов и т.д.

Контрактные тесты не являются чем-то обязательным, однако иногда, действительно помогают обнаруживать breaking changes на раннем этапе. Разумеется, как и любой инструмент, использовать такого рода тесты следует с умом.

Реализация библиотеки Pact для .NET предоставляет все основные возможности для написания контрактных тестов из коробки. К основным её минусам можно отнести отсутствие поддержки in-memory TestServer, отсутствие подробной документации (лишь готовые примеры реализации) и широкое использование типа dynamic, что в принципе обуславливается реализацией библиотеки. Несмотря на все вышеперечисленные минусы, достоинств у Pact все же больше, и надеюсь из двух статей стало понятно, в чём они заключаются.

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