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

Общее представление pact-манифеста
Общее представление pact-манифеста

Блок 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"
  }
}

Рассмотрим его чуть подробнее.

pact-манифест: http-interaction
pact-манифест: http-interaction
Пример провайдер-теста
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 в провайдер-тестах.

CI: консюмер-пайплайн
CI: консюмер-пайплайн

Стоит добавить, что в сложных случаях, когда ведется параллельная разработка провайдера и консюмера, pipeline-builder позволяет указывать git-ветку провайдера и таким образом поддерживать целостность контрактов на всех этапах разработки зависимостей.

CI: провайдер-пайплайн
CI: провайдер-пайплайн

Провайдер-пайплайн значительно проще предыдущего — все потому, что провайдер, как владелец контракта, не зависит от потребителей.

Опыт эксплуатации

Можно выделить несколько интересных моментов, с которыми мы столкнулись:

  • Зачастую в контрактных тестах возникает желание начать тестировать бизнес-логику провайдеров и консюмеров. Это возможно, однако в контексте контрактных тестов - не совсем корректно. Основное преимущество контрактных тестов - простота и скорость их работы, фокус на форматах данных и их обратной совместимости, а также быстрая обратная связь. Тестирование бизнес-логики - отдельная задача.

  • В сложных случаях, когда часть микросервисов имеет транзитивные зависимости, которые тоже нужно тестировать в процессе эволюции контрактов, 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)


  1. dsalahutdinov
    27.09.2024 09:02
    +1

    Насколько понятно про Pact я еще ни разу не читал!


  1. donRumatta
    27.09.2024 09:02

    Спасибо за подробную статью! Но уже не в первый раз читаю про Pact, и пока никак не осознаю от какого класса ошибок он защищает. Допустим, изменил ты контракт сервиса, перегенерил клиентов в вызывающих его сервисах, откуда может возникнуть несоответствие?


    1. bibendi
      27.09.2024 09:02
      +1

      Pact защищает от ошибок, возникающих, когда контракт изменили, а вызовы между сервисами перестали соответствовать новым ожиданиям. Даже если ты перегенерил клиентов, могут быть нюансы — например, изменения в формате данных, которые клиенты не учли, или обратно-несовместимые изменения, которые затронули старые версии. Pact ловит эти расхождения на этапе тестирования, не дожидаясь, пока они всплывут в продакшене.