Привет! Меня зовут Юрий, я старший разработчик в Купере в команде 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-менеджеров.
