Клятва на крови: контрактные тесты с Pact в .NET. Часть вторая
В первой части темы были рассмотрены теория и процесс контрактного тестирования взаимодействий по 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