TL;DR: как начать использовать платформу тестирования потребительских контрактов с Pact в рабочей среде.

Мотивация

Каждый в современной индустрии разработки программного обеспечения использует тест E2E, чтобы быть уверенным в выпуске новых разработок. Эти тесты обеспечивают высокую достоверность, но требуют больших затрат с точки зрения продолжительности и обработки.

Как вы знаете, модульные тесты дешевле, чем E2E, потому что они быстрые, но с низкой достоверностью.

Тестирование Consumer Driven Contract было создано как альтернатива E2E-тестированию. Идея проста: если у нас есть интерфейс (контракт), хорошо известный всем заинтересованным сторонам (производителю и потребителю), почему бы не использовать этот контракт и не протестировать на нем потребителя и производителя?

Мы можем тестировать производителей и потребителей изолированно. Есть смысл, верно?

Контрактное тестирование и пакт

Насколько я знаю, в настоящее время не так много вариантов для тестирования CDC.

Мы можем перечислить два решения с открытым исходным кодом. Один из них — Spring Cloud Contract, а другой — Pact.

Spring Cloud Contract тесно связан с Java Spring Framework.

Пакет, тем не менее, больше не зависит от платформы/языка. Вы можете написать «контракт», используя JSON (и его схему).

Я предпочитаю Python, а не Java, так что вы можете догадаться, какой фреймворк выбран для сегодняшнего разговора. Кроме шуток, я предпочитаю агностическое решение из-за лучшего применения в архитектуре микросервисов.

Спецификация Pact — это файл JSON, в котором запросы (с заголовками, полезной нагрузкой и т. д.) указываются вместе с ожидаемым ответом.

Вы можете написать это вручную, но никто этого не делает. В конце концов, вы используете фреймворк, в котором при первом запуске теста создается спецификация пакта.

Действительно, мы начинаем писать тест с точки зрения потребителя. Мы определяем запрос и то, что нам нужно в ответ на этот запрос.

Помимо создания спецификации Pact, фреймворк запускает фиктивный сервер, на котором ваш потребительский код может работать правильно.

Платформа будет использовать спецификацию договора, написанную ранее во время тестирования кода поставщика, для проверки договора (интерфейса).

Как вы можете себе представить, есть некоторые компромиссы в том, как управлять жизненным циклом спецификации договора. Где и когда мы можем разместить эти файлы? Управление этими файлами может быть немного странным. Итак, люди, стоящие за Pact, создали для этого решение.

Представляем Pact Broker

Фонд Pact создал менеджер репозитория для управления регистрацией и извлечением спецификаций пактов, посредник пактов.

Вы можете самостоятельно разместить брокера пактов или воспользоваться его сервисом SaaS: pactflow.io.

Идея состоит в том, чтобы развернуть его в вашей инфраструктуре, чтобы его можно было использовать для конвейера CI.

Когда потребительский тест CDC инициирует свое выполнение, первое, что нужно сделать, это загрузить спецификацию Pact, если она существует. Если он не существует или нуждается в обновлении (тест изменился с момента его последнего выполнения), обновите его.

С другой стороны, когда выполняется тест CDC на стороне поставщика, спецификация Pact загружается и сверяется с реализацией поставщика.

Итак, обо всем по порядку. Мы начинаем развертывание брокера Pact в нашем кластере K8S.

Пакт-брокер под Kubernetes

Брокеру Pact нужна база данных Postgres для хранения спецификаций и их метаданных.

Развернуть базу данных Postgres в Kubernetes очень просто, и это выходит за рамки этого поста. В моем случае я собираюсь использовать общее развертывание Postgres для такого рода услуг.

K8S проявляется

Мы создаем универсальный файл манифеста YAML

Мы применяем это

kubectl apply -f pact-broker.yml

Поскольку это внутренняя служба, нет необходимости создавать входной ресурс.

Чтобы проверить это, мы можем выполнить простую переадресацию портов.

kubectl port-forward -n pact pod/pact-broker-6fd6c49854-rjhch 9292

Если мы откроем URL-адрес localhost:9292 в нашем любимом браузере, мы увидим веб-интерфейс брокера пактов с примером по умолчанию.

Мы можем получить последний договор между потребителем «Example App» и поставщиком «Example API» с помощью следующего HTTP-вызова.

http localhost:9292/pacts/provider/Example%20API/consumer/Example%20App/latest

Это результат (без метаданных HAL)

{
    "consumer": {
        "name": "Example App"
    },
    "createdAt": "2022-12-13T20:49:39+00:00",
    "interactions": [
        {
            "_id": "ef69ceef4d7fb82af014da950a3d9028a905c4de",
            "description": "a request for an alligator",
            "providerState": "there is an alligator named Mary",
            "request": {
                "headers": {
                    "Accept": "application/json"
                },
                "method": "get",
                "path": "/alligators/Mary"
            },
            "response": {
                "body": {
                    "name": "Mary"
                },
                "headers": {
                    "Content-Type": "application/json;charset=utf-8"
                },
                "matchingRules": {
                    "$.body.name": {
                        "match": "type"
                    }
                },
                "status": 200
            }
        },
        {
            "_id": "4b3c23c364f420e1d1296d56a47695de0428d0af",
            "description": "a request for an alligator",
            "providerState": "there is not an alligator named Mary",
            "request": {
                "headers": {
                    "Accept": "application/json"
                },
                "method": "get",
                "path": "/alligators/Mary"
            },
            "response": {
                "headers": {},
                "status": 404
            }
        },
        {
            "_id": "e57e7ac251a8bd078fcb81cad1e577cbafebcef5",
            "description": "a request for an alligator",
            "providerState": "an error occurs retrieving an alligator",
            "request": {
                "headers": {
                    "Accept": "application/json"
                },
                "method": "get",
                "path": "/alligators/Mary"
            },
            "response": {
                "body": {
                    "error": "Argh!!!"
                },
                "headers": {
                    "Content-Type": "application/json;charset=utf-8"
                },
                "status": 500
            }
        }
    ],
    "metadata": {
        "pactSpecification": {
            "version": "2.0.0"
        }
    },
    "provider": {
        "name": "Example API"
    }
}

Все идет нормально. Давайте сделаем демо!

Простая демонстрация

Пришло время создать простой проект Python API.

Для Python вы можете использовать официальный пакет pact-python.

Я создал это демо-репозиторий => https://github.com/jmrobles/pact-python-demo

Это очень просто, но показательно.

Не буду вдаваться во все подробности, выделю самое интересное.

Потребительская сторона

quotes_consumer.py

import requests


class QuoteConsumer:

    QUOTES_EP =  '/api/v1/quotes'

    def __init__(self, host: str):

        self.host = host

    def get_random_quote(self) -> str:

        response = requests.get(self.host + self.QUOTES_EP)
        data = response.json()
        return f"{data['quote']}, {data['author']}"


    def put_my_quote(self, quote: str, author: str) -> bool:

        response = requests.post(self.host + self.QUOTES_EP, json={'quote': quote, 'author': author})
        return response.status_code == 201


if __name__ == '__main__':

    # Put a quote
    if not put_my_quote(quote, 'Today is a great day to do CDC testing', 'Aristestoles'):
        print('Something went wrong!')

    # Get a quote
    print(f'Quote of the day => {get_random_quote()}')

Мы начинаем определять нашего потребителя. Простой инструмент командной строки для получения случайной котировки и установки фиксированной. Да, это игрушка :)

Здесь важно, чтобы наш потребитель передал хост в конструкторе, чтобы мы могли внедрить фиктивный сервер pact.

test_quotes_consumer.py

from quotes_consumer import QuoteConsumer

PACT_MOCK_HOST = "localhost"
PACT_MOCK_PORT = 1234

def test_get_quote(pact):

    consumer = QuoteConsumer(f'http://{PACT_MOCK_HOST}:{PACT_MOCK_PORT}')

    expected = {
        'quote': 'A quote',
        'author': 'Anonymous'
    }

    (
        pact.given("A fixed quote")
        .upon_receiving("a request for the quote")
        .with_request("get", "/api/v1/quotes")
        .will_respond_with(200, body=expected)
    )
    with pact:
        quote = consumer.get_random_quote()
        assert quote == 'A quote, Anonymous'
        pact.verify()

def test_create_quote(pact):

    consumer = QuoteConsumer(f'http://{PACT_MOCK_HOST}:{PACT_MOCK_PORT}')

    quote = {
        'quote': 'To test or to test',
        'author': 'A tester'
    }

    (
        pact.given("A quote to add")
        .upon_receiving("a request for adding the new quote")
        .with_request("post", "/api/v1/quotes", body=quote, headers={'Content-type': 'application/json'})
        
        .will_respond_with(201)
    )

    with pact:

        is_created = consumer.put_my_quote(quote['quote'], quote['author'])
        assert is_created
        pact.verify()

Это более интересно. Здесь мы определяем два теста pytest, которые указывают, что мы ожидаем, и после этого в контексте объекта «pact» мы тестируем наш код.

«пакт» — это приспособление, включенное в conftest.py

conftest.py

import atexit
import os

import pytest

from pact import Consumer, Provider

PACT_BROKER_URL = "http://localhost:9292"
PACT_MOCK_HOST = "localhost"
PACT_MOCK_PORT = 1234

PACT_DIR = os.path.dirname(os.path.realpath(__file__))

def pytest_addoption(parser):
    parser.addoption(
        "--publish-pact", type=str, action="store", help="Upload generated pact file to pact broker with version"
    )

    parser.addoption("--provider-url", type=str, action="store", help="The url to our provider.")


@pytest.fixture(scope="session")
def pact(request):

    version = request.config.getoption("--publish-pact")
    publish = True if version else False

    pact = Consumer("QuoteCLI", version=version).has_pact_with(
        Provider("QuoteServer"),
        host_name=PACT_MOCK_HOST,
        port=PACT_MOCK_PORT,
        pact_dir=PACT_DIR,
        publish_to_broker=publish,
        broker_base_url=PACT_BROKER_URL,
    )

    pact.start_service()

    atexit.register(pact.stop_service)

    yield pact

    pact.stop_service()

    pact.publish_to_broker = False

Этот файл отвечает за добавление возможности публикации пакта в брокере, создание объекта пакта для пары потребитель-поставщик и запуск/завершение фиктивного сервера.

Если мы запустим его с run_pytest.sh, должна появиться новая спецификация договора JSON, и договор также должен быть зарегистрирован в нашем брокере.

Сторона провайдера

Теперь пришло время реализовать серверную часть, которая удовлетворяет нашему контракту, нашему договору.

Я использовал fastapi в качестве API-фреймворка.

quote_server.py

import random

from fastapi import FastAPI, APIRouter
from pydantic import BaseModel

class Quote(BaseModel):

    quote: str
    author: str

router = APIRouter()

app = FastAPI()

quotes = []

@app.get('/api/v1/quotes')
def get_random_quote():
    return random.choice(quotes)

@app.post('/api/v1/quotes', status_code=201)
def create_new_quote(quote: Quote):
    quotes.append({'quote': quote.quote, 'author': quote.author})
    return ''

Единственная интересная вещь здесь заключается в том, что мы не используем базу данных или что-то подобное. Массив quotes будет нашим внутренним хранилищем.

pact_quotes_server.py

import uvicorn
from fastapi import APIRouter
from pydantic import BaseModel

from quote_server import app, quotes, router as main_router

pact_router = APIRouter()


class ProviderState(BaseModel):
    state: str  # noqa: E999


@pact_router.post("/_pact/provider_states")
async def provider_states(provider_state: ProviderState):
    mapping = {
        "A fixed quote": setup_fixed_quote,
        "A quote to add": setup_add_quote,
    }
    mapping[provider_state.state]()

    return {"result": mapping[provider_state.state]}


# Make sure the app includes both routers. This needs to be done after the
# declaration of the provider_states
app.include_router(main_router)
app.include_router(pact_router)


def run_server():
    uvicorn.run(app)


def setup_fixed_quote():

    quotes.clear()
    quotes.append({'quote': 'A quote', 'author': 'Anonymous'})


def setup_add_quote():

    quotes.clear()

Что ж, по моему скромному мнению, это самая странная часть проекта.

Здесь мы создаем сервер fastapi на основе исходного провайдера, но добавляем возможность хранить сопоставление состояний и фикстур для каждого взаимодействия, определенного в нашем тесте.

Как вы можете, в зависимости от спецификации теста, мы модифицируем массив quotes, чтобы включить в него необходимые данные для теста.

test_quotes_provider.py

from multiprocessing import Process

import pytest
from pact import Verifier

from pact_quotes_server import run_server


PACT_BROKER_URL = "http://localhost:9292"
PROVIDER_URL = "http://localhost:8000"

@pytest.fixture(scope="module")
def server():
    proc = Process(target=run_server, args=(), daemon=True)
    proc.start()

    yield proc

    proc.kill()


def test_quote_service(server):

    verifier = Verifier(provider="QuoteServer", provider_base_url=PROVIDER_URL)
    success, logs = verifier.verify_with_broker(
        broker_url=PACT_BROKER_URL,
        verbose=True,
        provider_states_setup_url=f"{PROVIDER_URL}/_pact/provider_states",
        enable_pending=False,
        publish_verification_results=True,
        publish_version="1"
    )
    assert success == 0

Это больше шаблонный код. Мы просто используем наш специальный провайдер, показанный ранее, и выполняем метод проверки договора против нашего брокера. С помощью publish_version и publish_verification_result мы указываем, что хотим сохранить результат проверки и в нашем брокере.

Выполнив его также с run_pytest.sh, мы можем снова проверить WebUI… вуаля! Проверка прошла успешно!

Заключение

Тестирование CDC является относительно новой парадигмой/методом. Некоторые авторы микросервисов, такие как Сэм Ньюман, обсуждают это в своем бестселлере «Создание микросервисов».

Обещание исключить дорогостоящее тестирование E2E, ну, я думаю, мы можем уменьшить их количество, но не все. Для некоторых тестов, требующих тестирования безопасности, временных ограничений и т. д., нам все равно нужно выполнять их в среде, наиболее близкой к производственной.

На мой взгляд, есть кое-что, что нужно улучшить или узнать, прежде чем запускать CDC с Pact в производство. Возможно, я попробую применить это в реальном проекте, чтобы увидеть проблемы и потенциальные решения.

Держите вас в курсе, если вы хотите узнать дополнительную информацию о тестировании CDC.