В первой части темы были рассмотрены теория и процесс контрактного тестирования взаимодействий по 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) } }); _providerVersion = Assembly.GetAssembly(typeof(CardOrderSatisfiedEvent))? .GetCustomAttribute<AssemblyInformationalVersionAttribute>()? .InformationalVersion!; } [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
В результате исполнения данного файла запускаются приложения базы данных и брокера.

Прикручиваем автоматизированную доставку пактов
Сохранение сгенерированных пактов в 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}"); }); }
Дело осталось за малым, выполним следующие шаги:
Классы OrderCardTests и CardOrderSatisfiedEventTests реализуют интерфейс IClassFixture<PactBrokerFixture>, а также внедряют в конструктор зависимость PactBrokerFixture.
Сборка Consumer.Integration имеет тег версии <Version>1.0.0</Version>.
Конструкторы классов 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 все еще довольно информативен. Однако, если вам не приходится работать с кириллицей, можно использовать более стабильные версии библиотеки 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 все же больше, и надеюсь из двух статей стало понятно, в чём они заключаются.e
