Привет! Меня зовут Юрий, я старший разработчик в Купере в команде Ruby Platform — занимаюсь разработкой внутренних библиотек, инструментов мониторинга и поддержки микросервисов.
У нас в Купере более 200 микросервисов на Go, Ruby, JS, Python, etc, а также несколько монолитов. С точки зрения инфраструктуры интеграционное тестирование такого количества компонентов — довольно затратная задача, но при этом хочется обеспечить стабильность системы, не проводя ручные интеграционные регресс-тесты. В таких условиях оптимальным решением являются контрактные тесты.
Из этой статьи вы узнаете:
общий принцип работы контрактных тестов;
о проблемах, с которыми мы столкнулись при внедрении контрактного тестирования, и как их решали;
как мы разработали свое решение для контрактного тестирования Ruby-приложений;
о настройке CI/CD для автоматизации контрактных тестов.
Материал будет полезен тем, кто задумывается о повышении надежности интеграций между сервисами и внедрении контрактных тестов в свои проекты.
О контрактных тестах
Основые термины:
Контракт (contract) — соглашение или спецификация API, описывающая структуру и форматы данных при взаимодействии между сервисом-поставщиком и сервисами-потребителями.
Провайдер (provider) — поставщик контракта, предоставляет API.
Консюмер (consumer) — потребитель контракта, является клиентом API.
Контрактное тестирование — это способ проверки сервиса-поставщика и сервиса-потребителя данных на соответствие контракту API в точке интеграции.
В пирамиде тестирования Mike Kohn контрактные тесты находятся в блоке Service Tests вместе с интеграционными тестами.
Контракты между сервисами можно тестировать в рамках UI-тестов (end-to-end), однако:
это медленно (как по времени работы, так и по циклу обратной связи);
это хрупко (высокая сложность интеграций приводит к разного рода edge-кейсам).
Следовательно, ошибки в контрактах (например, обратно-несовместимые изменения, человеческий фактор, etc) гораздо удобнее (и дешевле) выявлять на раннем на этапе разработки. Для этих целей и существуют контрактные тесты.
Pact
В качестве решения для организации контрактного тестирования был выбран фреймворк Pact. Наши основные аргументы:
consumer-driven подход;
поддержка разных стеков: у нас как минимум используются Ruby, Golang, JS/TS, Python;
удобство организации CI/CD за счет существующего инструментария: pact-broker, pact-cli;
хорошая документация и поддержка.
Основные термины:
Pact-манифест — специальный json-файл (пример ниже), в котором описаны форматы запросов/ответов, их матчеры, а также используемые транспорты (http, grpc, etc).
-
Pact-спецификация — описание формата pact-манифеста и поддерживаемой им функциональности. На момент написания статьи существует четыре версии спецификации:
V1/V2 - поддерживает только http-взаимодействия
V3 - поддерживает асинхронные (message) взаимодействия
V4 - поддерживает плагины, позволяющие реализовать поддержку любых транспортов/форматов (например, grpc/protobuf) и их матчинга
Взаимодействие (interaction) — описанные в pact-манифесте форматы запроса-ответа в рамках контракта и их соответствие ожиданиям.
Матчеры (matchers) — специальные правила соответствия форматов запросов/ответов, поддерживаемые спецификацией.
Формат pact-манифеста
Рассмотрим пример pact-манифеста, который генерируется консюмер-тестами из данной статьи
service-consumer-service-provider.json
{
"consumer": {
"name": "service-consumer"
},
"interactions": [
{
"contents": {
"content": "CAEQAQ==",
"contentType": "application/protobuf;message=.protobuf.order_data.Order",
"contentTypeHint": "BINARY",
"encoded": "base64"
},
"description": "async: order via kafka",
"interactionMarkup": {
"markup": "```protobuf\nmessage Order {\n int32 id = 1;\n enum .protobuf.order_data.Order.OrderStatus status = 2;\n}\n```\n",
"markupType": "COMMON_MARK"
},
"matchingRules": {
"body": {
"$.id": {
"combine": "AND",
"matchers": [
{
"match": "integer"
}
]
},
"$.status": {
"combine": "AND",
"matchers": [
{
"match": "regex",
"regex": "(?-mix:(PENDING|COMPLETED|CANCELED))"
}
]
}
},
"metadata": {
"key": {
"combine": "AND",
"matchers": [
{
"match": "regex",
"regex": "(?-mix:.*)"
}
]
},
"topic": {
"combine": "AND",
"matchers": [
{
"match": "regex",
"regex": "(?-mix:.*)"
}
]
}
}
},
"metadata": {
"contentType": "application/protobuf;message=.protobuf.order_data.Order",
"key": "key",
"topic": "orders-topic"
},
"pending": false,
"pluginConfiguration": {
"protobuf": {
"descriptorKey": "2a5b88336a6f5a708460709e23f3c701",
"message": ".protobuf.order_data.Order"
}
},
"providerStates": [
{
"name": "order exists",
"params": {
"contentType": "application/protobuf;message=.protobuf.order_data.Order",
"order_id": 1
}
}
],
"type": "Asynchronous/Messages"
},
{
"description": "grpc: fetch order via grpc",
"interactionMarkup": {
"markup": "```protobuf\nmessage OrderStatusResponse {\n message .orders.Order order = 1;\n}\n```\n",
"markupType": "COMMON_MARK"
},
"pending": false,
"pluginConfiguration": {
"protobuf": {
"descriptorKey": "5a39c2b98badf0e1d0ed2e038cba0d62",
"service": ".orders.Orders/StatusById"
}
},
"providerStates": [
{
"name": "order exists",
"params": {
"order_id": 1
}
}
],
"request": {
"contents": {
"content": "CAE=",
"contentType": "application/protobuf;message=.orders.OrderStatusRequest",
"contentTypeHint": "BINARY",
"encoded": "base64"
},
"matchingRules": {
"body": {
"$.id": {
"combine": "AND",
"matchers": [
{
"match": "integer"
}
]
}
}
},
"metadata": {
"contentType": "application/protobuf;message=.orders.OrderStatusRequest"
}
},
"response": [
{
"contents": {
"content": "CgQIChAD",
"contentType": "application/protobuf;message=.orders.OrderStatusResponse",
"contentTypeHint": "BINARY",
"encoded": "base64"
},
"matchingRules": {
"body": {
"$.order.id": {
"combine": "AND",
"matchers": [
{
"match": "integer"
}
]
},
"$.order.status": {
"combine": "AND",
"matchers": [
{
"match": "equality"
}
]
}
}
},
"metadata": {
"contentType": "application/protobuf;message=.orders.OrderStatusResponse"
}
}
],
"transport": "grpc",
"type": "Synchronous/Messages"
},
{
"description": "http: fetch order via http",
"pending": false,
"providerStates": [
{
"name": "order exists",
"params": {
"order_id": 1
}
}
],
"request": {
"method": "GET",
"path": "/api/v1/orders/1"
},
"response": {
"body": {
"content": {
"id": 1,
"status": "COMPLETED"
},
"contentType": "application/json",
"encoded": false
},
"headers": {
"Content-Type": [
"application/json"
]
},
"matchingRules": {
"body": {
"$.id": {
"combine": "AND",
"matchers": [
{
"match": "integer"
}
]
},
"$.status": {
"combine": "AND",
"matchers": [
{
"match": "regex",
"regex": "(?-mix:(PENDING|COMPLETED|CANCELED))"
}
]
}
}
},
"status": 200
},
"transport": "http",
"type": "Synchronous/HTTP"
}
],
"metadata": {
"pactRust": {
"ffi": "0.4.22",
"mockserver": "1.2.9",
"models": "1.2.3"
},
"pactSpecification": {
"version": "4.0"
},
"plugins": [
{
"configuration": {
"2a5b88336a6f5a708460709e23f3c701": {
"protoDescriptors": "Cr0BCgtvcmRlci5wcm90bxITcHJvdG9idWYub3JkZXJfZGF0YSKQAQoFT3JkZXISDgoCaWQYASABKAVSAmlkEj4KBnN0YXR1cxgCIAEoDjImLnByb3RvYnVmLm9yZGVyX2RhdGEuT3JkZXIuT3JkZXJTdGF0dXNSBnN0YXR1cyI3CgtPcmRlclN0YXR1cxILCgdQRU5ESU5HEAASDQoJQ09NUExFVEVEEAESDAoIQ0FOQ0VMRUQQAmIGcHJvdG8z",
"protoFile": "syntax = \"proto3\";\n\npackage protobuf.order_data;\n\nmessage Order {\n enum OrderStatus {\n PENDING = 0;\n COMPLETED = 1;\n CANCELED = 2;\n }\n\n int32 id = 1;\n OrderStatus status = 2;\n}\n"
},
"5a39c2b98badf0e1d0ed2e038cba0d62": {
"protoDescriptors": "Cu0CCgxvcmRlcnMucHJvdG8SBm9yZGVycyKIAQoFT3JkZXISDgoCaWQYASABKAVSAmlkEiwKBnN0YXR1cxgCIAEoDjIULm9yZGVycy5PcmRlci5TdGF0dXNSBnN0YXR1cyJBCgZTdGF0dXMSCwoHUEVORElORxAAEg0KCUNPTVBMRVRFRBABEgwKCENBTkNFTEVEEAISDQoJUFJPQ0VTU0VEEAMiJAoST3JkZXJTdGF0dXNSZXF1ZXN0Eg4KAmlkGAEgASgFUgJpZCI6ChNPcmRlclN0YXR1c1Jlc3BvbnNlEiMKBW9yZGVyGAEgASgLMg0ub3JkZXJzLk9yZGVyUgVvcmRlcjJPCgZPcmRlcnMSRQoKU3RhdHVzQnlJZBIaLm9yZGVycy5PcmRlclN0YXR1c1JlcXVlc3QaGy5vcmRlcnMuT3JkZXJTdGF0dXNSZXNwb25zZUIP6gIMR3JwYzo6T3JkZXJzYgZwcm90bzM=",
"protoFile": "syntax = \"proto3\";\n\npackage orders;\noption ruby_package = \"Grpc::Orders\";\n\nservice Orders {\n rpc StatusById(OrderStatusRequest) returns (OrderStatusResponse);\n}\n\nmessage Order {\n int32 id = 1;\n enum Status {\n PENDING = 0;\n COMPLETED = 1;\n CANCELED = 2;\n PROCESSED = 3;\n }\n Status status = 2;\n}\n\nmessage OrderStatusRequest {\n int32 id = 1;\n}\n\nmessage OrderStatusResponse {\n Order order = 1;\n}\n"
}
},
"name": "protobuf",
"version": "0.5.1"
}
],
"sbmt-pact": {
"pact-ffi": "0.4.22"
}
},
"provider": {
"name": "service-provider"
}
}
Блок interaction и использование матчеров разберем далее в конкретных примерах.
Таким образом pact-манифест — это служебный файл, содержащий в себе набор данных, необходимый для проверки контракта между двумя сервисами.
Процесс тестирования консюмера и провайдера
Где pact-core — ядро pact, общее для клиентских библиотек разных стеков.
В консюмер-тестах:
описываются форматы запросов и ответов во взаимодействии с провайдером;
pact-core поднимает мок-сервер (мок-провайдер) на основе описанного взаимодействия;
консюмер делает реальные запросы в мок-сервер в соответствии со своими ожиданиями;
pact-core по результатам взаимодействия формирует и записывает всю необходимую информацию в pact-манифест;
pact-манифест публикуется в pact-брокере, задача которого централизованно хранить версионированные манифесты, статус их верификации и вести реестр взаимодействующих компонентов (консюмеров и провайдеров).
В провайдер-тестах:
поднимается сервер провайдера;
pact-core, обращаясь к pact-брокеру, определяет все консюмеры, имеющие контракты с данным провайдером и получает их pact-манифесты;
pact-core для каждого консюмера с помощью своего мок-клиента делает тестовые запросы в провайдер и таким образом верифицирует контракт на основе описанных там форматов запросов/ответов;
pact-core публикует результат верификации (да/нет) каждого консюмера с текущим провайдером в pact-брокере.
Provider States
Стоит подробнее остановиться на состояниях провайдера.
При тестировании провайдера pact-core выступает клиентом, воспроизводящим тестовые запросы из pact-манифеста. В этот момент запущен реальный сервер провайдера, который эти запросы получает и обрабатывает.
В случаях, когда логика провайдера предполагает получение информации из БД — необходимо заранее подготовить ее состояние, используя метаданные из pact-манифеста.
Поддержка Ruby и V3/V4-спецификаций
Если с Golang/JS проблем на старте не было, то для Ruby возникли некоторые нюансы:
официальный руби-гем поддерживал только V1/V2-спецификации, которые предполагают возможность тестирования только http-взаимодействий;
необходимые нам grpc/kafka-взаимодействия поддерживаются в V3/V4-спецификациях;
немногим ранее в процессе эволюции и поддержки V3/V4-спецификаций в pact-foundation решили переработать архитектуру и перешли на shared rust-core, предполагающий тест-библиотекам для разных стеков использовать FFI (foreign-function interface) как единый интерфейс для взаимодействия с ядром на rust;
официальный руби-гем на длительное время остался в подвешенном состоянии и не развивался, параллельно был создан pact-ruby-ffi, предоставляющий низкоуровневый интерфейс к pact-core;
и лишь недавно появились планы по развитию официального руби-гема и поддержке V3/V4 — см. Pact V3 Tracking Issue и Pact V4 Tracking Issue.
Таким образом на старте использования единственным вариантом для нас была реализация своего решения на базе pact-ruby-ffi. Так появился гем sbmt-pact, предоставляющий высокоуровневый интерфейс для написания пакт-тестов и поддерживающий спецификации V3/V4 для Ruby.
Архитектура гема sbmt-pact
Основные возможности:
поддержка актуальных pact-спецификаций благодаря использованию официального pact-ffi, возможность расширения и поддержки новых протоколов взаимодействия;
высокоуровневый rspec-DSL для написания pact-тестов, поддержка provider-states и consumer version selectors;
встроенная поддержка серверов провайдера: HTTP (Rails), gRPC (Gruf), Kafka;
возможность разделения тестов одного провайдера на несколько модулей по используемым транспортам, а также под каждого консюмера за счет consumer version selectors;
конфигурирование pact-broker — несколькими ENV-переменными. В том числе, присутствует возможность указания версии провайдера в консюмер-тестах, что позволяет легко запускать и отлаживать pact-тесты локально.
Далее рассмотрим несколько примеров использования для тестирования контрактов HTTP, gRPC и Kafka.
Пример использования: HTTP
Рассмотрим два микросервиса, взаимодействующих по HTTP:
Пример консюмер-теста
RSpec.describe "Http::Orders", :pact do
# декларируем тип взаимодействия
has_http_pact_between "service-consumer", "service-provider"
let(:order_id) { 1 }
let(:client) { Http::Orders::V1::Client.new }
let(:make_request) { client.order_status(id: order_id) }
# определяем заимодействие между сервисами и матчеры запроса/ответа
let(:interaction) do
new_interaction
# определяем provider state с необходимыми метаданными
# которые можно будет использовать позже, в момент запуска провайдер-теста
.given("order exists", order_id: order_id)
.upon_receiving("fetch order via http")
# описываем формат запроса
.with_request(:get, "/api/v1/orders/#{order_id}")
# и формат ответа
.with_response(200, headers: {}, body: {
id: match_any_integer,
status: match_regex(/(PENDING|COMPLETED|CANCELED|PROCESSED)/, "COMPLETED")
})
end
it "executes the pact test without errors" do
# запускаем тест, в этот момент pact-core поднимает mock-сервер,
# а наш http-клиент делает реальный запрос в данный мок
interaction.execute do
# в примере нам важен только критерий успешности запроса,
# форматы проверяются под капотом pact-core
expect(make_request).to be_success
end
# по результатам будет сгенерирован (локально) pact-манифест
end
end
Консюмер-тест состоит из 3х основных блоков:
декларация типа взаимодействия;
определение форматов запроса-ответа с матчерами;
запуск теста.
Особо стоит отметить использование provider states. Грубо говоря, это способ описания требуемого состояния провайдера в момент его тестирования (метаданные, которые мы укажем в консюмер-тесте, будут записаны в pacе-манифест и доступны в рантайме провайдер-теста).
Сгенерированный в тесте pact-манифест:
service-consumer-service-provider.json
{
"consumer": {
"name": "service-consumer"
},
"interactions": [
{
"description": "http: fetch order via http",
"pending": false,
"providerStates": [
{
"name": "order exists",
"params": {
"order_id": 1
}
}
],
"request": {
"method": "GET",
"path": "/api/v1/orders/1"
},
"response": {
"body": {
"content": {
"id": 1,
"status": "COMPLETED"
},
"contentType": "application/json",
"encoded": false
},
"headers": {
"Content-Type": [
"application/json"
]
},
"matchingRules": {
"body": {
"$.id": {
"combine": "AND",
"matchers": [
{
"match": "integer"
}
]
},
"$.status": {
"combine": "AND",
"matchers": [
{
"match": "regex",
"regex": "(?-mix:(PENDING|COMPLETED|CANCELED))"
}
]
}
}
},
"status": 200
},
"transport": "http",
"type": "Synchronous/HTTP"
}
],
"metadata": {
"pactRust": {
"ffi": "0.4.22",
"mockserver": "1.2.9",
"models": "1.2.3"
},
"pactSpecification": {
"version": "4.0"
},
"sbmt-pact": {
"pact-ffi": "0.4.22"
}
},
"provider": {
"name": "service-provider"
}
}
Рассмотрим его чуть подробнее.
Пример провайдер-теста
RSpec.describe "Orders::Http", :pact do
# аналогично - декларируем тип взаимодействия и название провайдера
# тут указывать консюмер уже не нужно: все консюмеры определяются в рантайме,
# по запросу в pact-брокер
http_pact_provider "service-provider"
# наш provider-state
provider_state 'order exists' do
set_up do |params|
# для которого необходимо в БД предсоздать сущность
# заказа с ID, который берем из метаданных
FactoryBot.create(:order, id: params['order_id'])
end
end
# под капотом будет поднят http-сервер провайдера
# в который pact-core mock-client сделает запрос,
# описанный в pact-манифесте
end
С провайдер-тестами все немного проще. Тут уже не требуется описывать какие-то форматы запросов/ответов, т.к. они уже описаны в pact-манифесте на этапе консюмер-теста. Нам лишь требуется при необходимости учесть все provider states.
В данном случае наш тест требует, чтобы заказ с указанным ID существовал в БД - что мы и сделали, создав его.
Провайдер-тест состоит из 1-2х основных блоков:
декларация типа взаимодействияж;
(опционально) описание 1 или более provider states.
Мы рассмотрели простое взаимодействие на базе REST API. В микросервисной архитектуре часто используется gRPC, рассмотрим соответствующий пример.
Пример использования: gRPC
Немного усложним предыдущий кейс и рассмотрим те же микросервисы, но взаимодействующие по gRPC.
Proto-контракт
syntax = "proto3";
package orders;
service Orders {
rpc StatusById(OrderStatusRequest) returns (OrderStatusResponse);
}
message Order {
int32 id = 1;
enum Status {
PENDING = 0;
COMPLETED = 1;
CANCELED = 2;
PROCESSED = 3;
}
Status status = 3;
}
message OrderStatusRequest {
int32 id = 1;
}
message OrderStatusResponse {
Order order = 1;
}
Пример консюмер-теста
RSpec.describe "Grpc::Orders", :pact do
# декларируем тип взаимодействия
has_grpc_pact_between "service-consumer", "service-provider"
let(:order_id) { 1 }
let(:client) { Grpc::Orders::V1::Client.new }
let(:make_request) { client.order_status_by_id(id: order_id) }
# определяем заимодействие между сервисами и матчеры запроса/ответа
let(:interaction) do
new_interaction
# указываем proto-файл и название тестируемого rpc-сервиса
.with_service("deps/services/orders.proto", "Orders/StatusById")
.upon_receiving("fetch order via grpc")
# определяем provider state с необходимыми метаданными
# которые можно будет использовать позже, в момент запуска провайдер-теста
.given("order exists", order_id: order_id)
# описываем формат данных с матчерами
.with_request(id: match_any_integer(order_id))
.with_response(
order: {
id: match_any_integer,
status: match_exactly("PROCESSED")
}
)
end
it "executes the pact test without errors" do
# запускаем тест, в этот момент pact-core поднимает mock-сервер,
# а наш grpc-клиент делает реальный запрос в данный мок
interaction.execute do
# в примере нам важен только критерий успешности запроса,
# форматы проверяются под капотом pact-core
expect(make_request).to be_success
end
# по результатам будет сгенерирован (локально) pact-манифест
end
end
Тут все аналогично http-консюмер-тесту, за исключением специфики gRPC: необходимо указать proto-файл и название rpc-сервиса, эта информация будет использована внутри pact-core для того, чтобы корректно замокать и провалидировать запросы/ответы и их типы данных.
Пример провайдер-теста
RSpec.describe "Orders::Grpc", :pact do
# аналогично - декларируем тип взаимодействия и название провайдера
# тут указывать консюмер уже не нужно: все консюмеры определяются в рантайме,
# по запросу в pact-брокер
grpc_pact_provider "service-provider"
# определяем provider-state
provider_state 'order exists' do
set_up do |params|
# для которого нам нужно в БД предсоздать сущность
# заказа с ID, который берем из метаданных
FactoryBot.create(:order, id: params['order_id'])
end
end
# под капотом будет поднят grpc-сервер провайдера
# в который pact-core mock-client сделает запрос,
# описанный в pact-манифесте
end
service-consumer-service-provider.json
{
"consumer": {
"name": "service-consumer"
},
"interactions": [
{
"description": "grpc: fetch order via grpc",
"interactionMarkup": {
"markup": "```protobuf\nmessage OrderStatusResponse {\n message .orders.Order order = 1;\n}\n```\n",
"markupType": "COMMON_MARK"
},
"pending": false,
"pluginConfiguration": {
"protobuf": {
"descriptorKey": "5a39c2b98badf0e1d0ed2e038cba0d62",
"service": ".orders.Orders/StatusById"
}
},
"providerStates": [
{
"name": "order exists",
"params": {
"order_id": 1
}
}
],
"request": {
"contents": {
"content": "CAE=",
"contentType": "application/protobuf;message=.orders.OrderStatusRequest",
"contentTypeHint": "BINARY",
"encoded": "base64"
},
"matchingRules": {
"body": {
"$.id": {
"combine": "AND",
"matchers": [
{
"match": "integer"
}
]
}
}
},
"metadata": {
"contentType": "application/protobuf;message=.orders.OrderStatusRequest"
}
},
"response": [
{
"contents": {
"content": "CgQIChAD",
"contentType": "application/protobuf;message=.orders.OrderStatusResponse",
"contentTypeHint": "BINARY",
"encoded": "base64"
},
"matchingRules": {
"body": {
"$.order.id": {
"combine": "AND",
"matchers": [
{
"match": "integer"
}
]
},
"$.order.status": {
"combine": "AND",
"matchers": [
{
"match": "equality"
}
]
}
}
},
"metadata": {
"contentType": "application/protobuf;message=.orders.OrderStatusResponse"
}
}
],
"transport": "grpc",
"type": "Synchronous/Messages"
}
],
"metadata": {
"pactRust": {
"ffi": "0.4.22",
"mockserver": "1.2.9",
"models": "1.2.3"
},
"pactSpecification": {
"version": "4.0"
},
"plugins": [
{
"configuration": {
"5a39c2b98badf0e1d0ed2e038cba0d62": {
"protoDescriptors": "Cu0CCgxvcmRlcnMucHJvdG8SBm9yZGVycyKIAQoFT3JkZXISDgoCaWQYASABKAVSAmlkEiwKBnN0YXR1cxgCIAEoDjIULm9yZGVycy5PcmRlci5TdGF0dXNSBnN0YXR1cyJBCgZTdGF0dXMSCwoHUEVORElORxAAEg0KCUNPTVBMRVRFRBABEgwKCENBTkNFTEVEEAISDQoJUFJPQ0VTU0VEEAMiJAoST3JkZXJTdGF0dXNSZXF1ZXN0Eg4KAmlkGAEgASgFUgJpZCI6ChNPcmRlclN0YXR1c1Jlc3BvbnNlEiMKBW9yZGVyGAEgASgLMg0ub3JkZXJzLk9yZGVyUgVvcmRlcjJPCgZPcmRlcnMSRQoKU3RhdHVzQnlJZBIaLm9yZGVycy5PcmRlclN0YXR1c1JlcXVlc3QaGy5vcmRlcnMuT3JkZXJTdGF0dXNSZXNwb25zZUIP6gIMR3JwYzo6T3JkZXJzYgZwcm90bzM=",
"protoFile": "syntax = \"proto3\";\n\npackage orders;\noption ruby_package = \"Grpc::Orders\";\n\nservice Orders {\n rpc StatusById(OrderStatusRequest) returns (OrderStatusResponse);\n}\n\nmessage Order {\n int32 id = 1;\n enum Status {\n PENDING = 0;\n COMPLETED = 1;\n CANCELED = 2;\n PROCESSED = 3;\n }\n Status status = 2;\n}\n\nmessage OrderStatusRequest {\n int32 id = 1;\n}\n\nmessage OrderStatusResponse {\n Order order = 1;\n}\n"
}
},
"name": "protobuf",
"version": "0.5.1"
}
],
"sbmt-pact": {
"pact-ffi": "0.4.22"
}
},
"provider": {
"name": "service-provider"
}
}
Провайдер-тест почти полностью аналогичен http-провайдер-тесту, отличается только тип взаимодействия.
Теперь очередь за асинхронным взаимодействием. Мы для этих целей используем kafka, рассмотрим следующий пример.
Пример использования: Kafka
Если тестирование синхронных http/gRPC взаимодействий практически не отличается друг от друга, то с асинхронным взаимодействием все чуть сложнее (на примере Кафки):
мы не очень хотим поднимать реальный кафка-брокер: это долго и ресурсоемко (хотя при желании — возможно);
в pact уже придумали механизм тестирования асинхронных взаимодействий: можно указать специальный http-сервер, который будет использоваться в качестве транспорта для тестируемого взаимодействия.
Рассмотрим все те же микросервисы, но взаимодействующие по gRPC через Кафка-топики (producer сообщений в Кафку в данном случае является провайдером).
Рассмотрим простейшие продюсер/консюмер, реализованные на базе гема sbmt-kafka_consumer(karafka 2).
Класс продюсера
Продюсер получает сущность Order, кодирует ее в protobuf и публикует в кафку
class OrderProducer < Sbmt::KafkaProducer::BaseProducer
option :topic, default: -> { "orders-topic" }
def publish(order)
payload = encode_payload(order)
sync_publish(payload)
end
private
def encode_payload(order)
{
id: order.id,
status: order.status.upcase
}
end
end
Класс консюмера
Консюмер вычитывает из кафки закодированный proto-пейлоад (который раскодируется под капотом гема sbmt-kafka_consumer) и логирует его параметры.
class OrdersConsumer < Sbmt::KafkaConsumer::BaseConsumer
def process_message(message)
logger.info "Processing message #{message.payload.id}: status:#{message.payload.status}"
end
end
Пример консюмер-теста
RSpec.describe "Consumer::Orders", :pact do
# декларируем тип взаимодействия
has_message_pact_between "service-consumer", "service-provider"
let(:deserializer) do
Sbmt::KafkaConsumer::Serialization::ProtobufDeserializer.new(
# где Protobuf::Order - сгенерированный руби-класс
# на основе orders.proto
message_decoder_klass: "Protobuf::Order"
)
end
let(:consumer) { build_consumer(OrdersConsumer.consumer_klass.new) }
let(:order_id) { 123 }
# определяем заимодействие между сервисами и матчеры запроса/ответа
let(:interaction) do
new_interaction
# указываем proto-файл и название data-класса (message)
.with_proto_class("deps/services/orders.proto", "Order")
# определяем provider state с необходимыми метаданными
# которые можно будет использовать позже, в момент запуска провайдер-теста
.given("order exists", order_id: order_id)
.upon_receiving("order via kafka")
# описываем формат данных с матчерами
.with_proto_contents(
id: match_any_integer(order_id),
status: match_regex(/(PENDING|COMPLETED|CANCELED)/, "COMPLETED")
)
# описываем метаданные: топик и ключ партиционирования
.with_metadata(
topic: match_exactly("orders-topic"),
key: match_any_string
)
end
it "executes the pact test without errors" do
# запускаем тест, в этот момент под капотом конфигурируется pact-core,
# который в параметрах блока вернет уже провалидированные payload
# и метаданные, которые мы задали в interaction
interaction.execute do |proto_payload, meta|
# "публикуем" сообщение в кафку с помощью testing-инструментов
# sbmt-kafka_consumer (взаимодействия с реальным кафка-брокером нет)
publish_to_sbmt_karafka(
proto_payload, deserializer: deserializer,
topic: meta["topic"], key: meta["key"]
)
# простой expectation для демонстрации консюминга
expect(Rails.logger).to receive(:info).with(/Processing message/)
# консюмим опубликованное сообщение с помощью testing-инструментов
# гема sbmt-kafka_consumer - вызывается класс консюмера,
# определенный выше
consume_with_sbmt_karafka
end
# по результатам будет сгенерирован (локально) pact-манифест
end
end
В целом, кафка-консюмер-тест практически не отличается от синхронных http/gRPC:
декларация типа взаимодействия;
определение форматов запроса-ответа с матчерами;
запуск теста с обвязкой sbmt-kafka_consumer (конфигурация консюмера, десериализатора).
Пример провайдер-теста
RSpec.describe "Consumers::Kafka", :pact do
# аналогично - декларируем тип взаимодействия и название провайдера
# тут указывать консюмер уже не нужно: все консюмеры определяются в рантайме,
# по запросу в pact-брокер
message_pact_provider "service-provider"
handle_message "order via kafka" do |provider_state|
# получаем метаданные из provider state
# и создаем в БД заказ с нужным ID
order_id = provider_state.dig("params", "order_id")
order = FactoryBot.create(:order, id: order_id)
# это специальный хелпер, позволяющий запродюсить событие
# в mock-message-server, тем самым взаимодействуя с pact-core,
# где будет производиться матчинг формата пейлоада
with_pact_producer do |client|
# client - мок-клиент sbmt-kafka_producer
OrderProducer.new(client: client).publish(order)
end
end
end
Провайдер-тест для асинхронного взаимодействия отличается от http/gRPC тем, что:
вместо описания provider states (которые опциональны) тут описывается как обрабатывать каждое сообщение (в консюмер-тесте его название указывается в upon_receiving);
provider state уже находится внутри и передается в параметрах блока;
специальный хелпер with_pact_producer позволяет сильно упростить написание тестов, абстрагируя логику взаимодействия с pact-core и sbmt-kafka_producer.
service-consumer-service-provider.json
{
"consumer": {
"name": "service-consumer"
},
"interactions": [
{
"contents": {
"content": "CAEQAQ==",
"contentType": "application/protobuf;message=.protobuf.order_data.Order",
"contentTypeHint": "BINARY",
"encoded": "base64"
},
"description": "async: order via kafka",
"interactionMarkup": {
"markup": "```protobuf\nmessage Order {\n int32 id = 1;\n enum .protobuf.order_data.Order.OrderStatus status = 2;\n}\n```\n",
"markupType": "COMMON_MARK"
},
"matchingRules": {
"body": {
"$.id": {
"combine": "AND",
"matchers": [
{
"match": "integer"
}
]
},
"$.status": {
"combine": "AND",
"matchers": [
{
"match": "regex",
"regex": "(?-mix:(PENDING|COMPLETED|CANCELED))"
}
]
}
},
"metadata": {
"key": {
"combine": "AND",
"matchers": [
{
"match": "regex",
"regex": "(?-mix:.*)"
}
]
},
"topic": {
"combine": "AND",
"matchers": [
{
"match": "equality"
}
]
}
}
},
"metadata": {
"contentType": "application/protobuf;message=.protobuf.order_data.Order",
"key": "any",
"topic": "orders-topic"
},
"pending": false,
"pluginConfiguration": {
"protobuf": {
"descriptorKey": "2a5b88336a6f5a708460709e23f3c701",
"message": ".protobuf.order_data.Order"
}
},
"providerStates": [
{
"name": "order exists",
"params": {
"contentType": "application/protobuf;message=.protobuf.order_data.Order",
"order_id": 1
}
}
],
"type": "Asynchronous/Messages"
}
],
"metadata": {
"pactRust": {
"ffi": "0.4.22",
"models": "1.2.3"
},
"pactSpecification": {
"version": "4.0"
},
"plugins": [
{
"configuration": {
"2a5b88336a6f5a708460709e23f3c701": {
"protoDescriptors": "Cr0BCgtvcmRlci5wcm90bxITcHJvdG9idWYub3JkZXJfZGF0YSKQAQoFT3JkZXISDgoCaWQYASABKAVSAmlkEj4KBnN0YXR1cxgCIAEoDjImLnByb3RvYnVmLm9yZGVyX2RhdGEuT3JkZXIuT3JkZXJTdGF0dXNSBnN0YXR1cyI3CgtPcmRlclN0YXR1cxILCgdQRU5ESU5HEAASDQoJQ09NUExFVEVEEAESDAoIQ0FOQ0VMRUQQAmIGcHJvdG8z",
"protoFile": "syntax = \"proto3\";\n\npackage protobuf.order_data;\n\nmessage Order {\n enum OrderStatus {\n PENDING = 0;\n COMPLETED = 1;\n CANCELED = 2;\n }\n\n int32 id = 1;\n OrderStatus status = 2;\n}\n"
}
},
"name": "protobuf",
"version": "0.5.1"
}
],
"sbmt-pact": {
"pact-ffi": "0.4.22"
}
},
"provider": {
"name": "service-provider"
}
}
CI/CD
Неотъемлемой частью организации контрактного тестирования является автоматизация выполнения тестов и поддержка со стороны инфраструктуры.
Требования к CI/CD, которые можно выделить в нашем случае:
унифицированное решение, т.к. микросервисов / тестов множество;
поддержка разных стеков;
минимум лишних действий для конечных пользователей (разработчиков);
оптимизация времени выполнения: один большой сервис может быть консюмером для 20+ провайдер-сервисов — крайне желательно запускать каждую такую группу тестов (per provider) отдельно/параллельно и минимизировать задержки деплоя очередного релиза.
Данные требования привели нас к интересному и нетривиальному решению.
Был реализован специальный тулинг для CI, использующий автогенерацию pact-пайплайнов, который:
генерирует тест-джобы под каждый микросервис в рамках общего pact-пайплайна;
учитывает зависимости между микросервисами;
учитывает наличие консюмер/провайдер-тестов в репо сервиса;
абстрагирует запуск тестов под разные стеки (например,
bundle exec rspec
илиgo test
);предоставляет возможность при необходимости поднимать сопутствующие докер-контейнеры (например, postgres, необходимый в рамках провайдер-тестов);
поддерживает работу с feature-ветками провайдеров (например, когда разработка контракта ведется параллельно в консюмере и провайдере и нужно периодически валидировать их консистентность);
интегрирован с утилитой can-i-deploy, с помощью которой на основе данных верификации в pact-брокере мы можем определить, возможен ли деплой данной версии консюмера и провайдера;
позволяет использовать consumer version selectors и environments в провайдер-тестах.
Стоит добавить, что в сложных случаях, когда ведется параллельная разработка провайдера и консюмера, pipeline-builder позволяет указывать git-ветку провайдера и таким образом поддерживать целостность контрактов на всех этапах разработки зависимостей.
Провайдер-пайплайн значительно проще предыдущего — все потому, что провайдер, как владелец контракта, не зависит от потребителей.
Опыт эксплуатации
Можно выделить несколько интересных моментов, с которыми мы столкнулись:
Зачастую в контрактных тестах возникает желание начать тестировать бизнес-логику провайдеров и консюмеров. Это возможно, однако в контексте контрактных тестов - не совсем корректно. Основное преимущество контрактных тестов - простота и скорость их работы, фокус на форматах данных и их обратной совместимости, а также быстрая обратная связь. Тестирование бизнес-логики - отдельная задача.
В сложных случаях, когда часть микросервисов имеет транзитивные зависимости, которые тоже нужно тестировать в процессе эволюции контрактов, CI-пайплайны становятся более сложными и чуть более хрупкими (например, несколько MR с зависимостями друг от друга в разных проектах). Какого-либо универсального решения этот кейс не имеет, все зависит от конкретной ситуации. Мы придерживаемся подхода: мержим сначала MR провайдеров, затем консюмеры.
По умолчанию в провайдер-тестах pact-core определяет перечень консюмеров, зависимых от данного провайдера, как: “последняя версия из main-ветки, опубликованная в pact-брокере”. Это не всегда удобно, т.к. открывает широкие возможности для появления race conditions. Для решения этой проблемы мы используем consumer version selectors - отличное решение от меинтейнеров Pact.
Итог
Мы рассмотрели опыт использования CDC-решения на базе Pact, реализации поддержки V3/V4-спецификаций в Ruby, а также специфику тестирования большого количества связанных микросервисов в CI.
Pact оказался не просто фреймворком, а целой экосистемой, готовой к любым вызовам современной микросервисной архитектуры. С поддержкой различных языков и протоколов, он становится незаменимым союзником в борьбе за качество кода.
Да, настройка CI/CD для контрактных тестов может показаться сложной, но результат определенно стоит усилий. Автоматизация и параллелизация становятся ключами к эффективному процессу, превращая потенциальный хаос в стройную систему.
Ruby-разработчикам особенно приятно: даже когда официальная поддержка отстает, community всегда найдет выход. Наш опыт с sbmt-pact - яркое тому подтверждение. Это еще раз доказывает, что в мире open-source нет нерешаемых задач.
А если вы уже используете контрактные тесты, поделитесь своим опытом! Ваша история может стать вдохновением для других разработчиков, делающих первые шаги в этом увлекательном мире.
Ссылки:
Tech-команда Купера (ex СберМаркет) ведет соцсети с новостями и анонсами. Если хочешь узнать, что под капотом высоконагруженного e-commerce, следи за нами в Telegram и на YouTube. А также слушай подкаст «Для tech и этих» от наших it-менеджеров.
Комментарии (3)
donRumatta
27.09.2024 09:02Спасибо за подробную статью! Но уже не в первый раз читаю про Pact, и пока никак не осознаю от какого класса ошибок он защищает. Допустим, изменил ты контракт сервиса, перегенерил клиентов в вызывающих его сервисах, откуда может возникнуть несоответствие?
bibendi
27.09.2024 09:02+1Pact защищает от ошибок, возникающих, когда контракт изменили, а вызовы между сервисами перестали соответствовать новым ожиданиям. Даже если ты перегенерил клиентов, могут быть нюансы — например, изменения в формате данных, которые клиенты не учли, или обратно-несовместимые изменения, которые затронули старые версии. Pact ловит эти расхождения на этапе тестирования, не дожидаясь, пока они всплывут в продакшене.
dsalahutdinov
Насколько понятно про Pact я еще ни разу не читал!