Полное руководство по тестированию контрактов с помощью PACT и Go

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

Меня давно беспокоят проблемы интеграции между несколькими микросервисами. Как убедиться, что у двух микросервисов определенных версий нет проблем с интеграцией? Как сделать так, чтобы новая версия микросервиса не сломала API-интерфейс так, что другие сервисы больше не могут им воспользоваться?

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

Недавно я узнал о контрактном тестировании, и был поражен. Это оказался прорыв.

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

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

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

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

Когда потребитель опубликует контракт, им сможет воспользоваться поставщик. В своем коде (возможно, и в модульных тестах) поставщик выполняет проверку контракта и публикует результаты.

На обоих этапах тестирования контракта мы работаем только с одной стороной, без какого-либо реального взаимодействия со второй. На практике мы следим, чтобы обе стороны могли общаться друг с другом по отдельным каналам. Таким образом, весь процесс остается асинхронным и независимым.

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

Контрактное тестирование  —  это не тестирование схемы. Тестирование схемы привязано к одной стороне без подключения к какой-либо другой. Контрактное тестирование проверяет взаимодействие с обеих сторон и гарантирует совместимость любых желаемых версий.

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

Итак, посмотрим, что же такое контрактное тестирование.

PACT

PACT  —  это инструмент для контрактного тестирования. Он применяется для проверки связи между потребителями и поставщиками по протоколу HTTP. Он также поддерживает тестирование очередей сообщений, таких как SQS, RabbitMQ, Kafka и т. д.

На стороне потребителя мы предоставляем контракты через PACT DSL для какого-либо языка программирования. Контракт содержит взаимодействия, которые состоят из ожидаемых запросов и ожидаемых минимальных ответов.

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

На стороне поставщика используются созданные контракты, чтобы проверить, способен ли сервер соответствовать ожиданиям. Результаты проверки можно опубликовывать, чтобы отслеживать, какие версии потребителей и поставщиков совместимы.

Когда мы запускаем тесты на стороне поставщика, имитация потребителя отправляет ожидаемый запрос поставщику. Тот проверяет, соответствует ли HTTP-запрос ожиданиям, и возвращает ответ. В конце концов, имитация потребителя сравнивает фактический ответ с ожидаемым минимальным ответом и предоставляет результат проверки.

Хранить все контракты и результаты проверки можно в PACT-брокере. Это инструмент, который разработчикам необходимо самостоятельно размещать и поддерживать на проектах, но также можно воспользоваться общедоступным Pactflow.

Простой сервер и клиент на Go

Чтобы написать первый контракт, напишем код для простого сервера и клиента. В этом случае сервер предоставляет одну конечную точку /users/:userId для возврата пользователей по идентификатору. Код генерирует результат, чтобы избежать более сложной логики, такой как связь с базой данных.

// /pkg/server/server.go
type User struct {
ID string `json:"id"`
FirstName string `json:"firstName"`
LastName string `json:"lastName"`
}

func GetUserByID(ctx *gin.Context) {
id := ctx.Param("userId")

ctx.JSON(http.StatusOK, User{
ID: id,
FirstName: fmt.Sprintf("first%s", id),
LastName: fmt.Sprintf("last%s", id),
})
}

// /cmd/main.go
func main() {
router := gin.Default()
router.GET("/users/:userId", server.GetUserByID)
router.Run(":8080")
}

Полный код для сервера находится в двух файлах  —  server.go и main.go. Для этой демонстрации используется веб-фреймворк Gin для Go, но любой другой фреймворк тоже подойдет (или можно вообще обойтись без него).

Клиентский код еще проще. Он также находится в двух файлах  —  client.go и main.go. Он создает и отправляет GET-запрос на конечную точку /users/:userId. После получения результата он преобразует JSON из тела запроса в структуру Users.

// /pkg/client/client.go
func GetUserByID(host string, id string) (*server.User, error) {
uri := fmt.Sprintf("http://%s/users/%s", host, id)
resp, err := http.Get(uri)
if err != nil {
return nil, err
}
defer resp.Body.Close()

var user server.User
err = json.NewDecoder(resp.Body).Decode(&user)
if err != nil {
return nil, err
}

return &user, nil
}

// /cmd/main.go
func main() {
user, err := client.GetUserByID("localhost:8080", "1")
if err != nil {
panic(err)
}

fmt.Println(user)
}

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

Тестирование PACT для клиента

Написание теста PACT на Go  —  это то же самое, что написание модульных тестов. В этом случае также стоит задействовать пакет от Pact Foundation. Его легко установить, а модули Go поддерживаются “из коробки».

import (
//
// некоторые импорты
//
"github.com/pact-foundation/pact-go/types"
"github.com/pact-foundation/pact-go/dsl"
)

func TestClientPact_Local(t *testing.T) {
// инициализация PACT DSL
pact := dsl.Pact{
Consumer: "example-client",
Provider: "example-server",
}

// установка PACT Mock Server
pact.Setup(true)

t.Run("get user by id", func(t *testing.T) {
id := "1"


pact.
AddInteraction(). // задается PACT-взаимодействие
Given("User Alice exists"). // задается состояние Поставщика
UponReceiving("User 'Alice' is requested"). // задается название тест-кейса
WithRequest(dsl.Request{ // задается ожидаемый запрос
Method: "GET",
Path: dsl.Term("/users/1", "/users/[0-9]+"), // задается соответствие для конечной точки
}).
WillRespondWith(dsl.Response{ // задается минимальный ожидаемый ответ
Status: 200,
Body: dsl.Like(server.User{ // задается соответствие для тела ответа
ID: id,
FirstName: "Alice",
LastName: "Doe",
}),
})

// верификация взаимодействия на стороне клиента
err := pact.Verify(func() error {
// задается хост и публикуется PACT Mock Server в качестве актуального сервера
host := fmt.Sprintf("%s:%d", pact.Host, pact.Server.Port)

// выполнение функции
user, err := GetUserByID(host, id)
if err != nil {
return errors.New("error is not expected")
}

// проверка, что полученный пользователь соответствует ожидаемому
if user == nil || user.ID != id {
return fmt.Errorf("expected user with ID %s but got %v", id, user)
}

return err
})

if err != nil {
t.Fatal(err)
}
})

// Контракт пишется в файл
if err := pact.WritePact(); err != nil {
t.Fatal(err)
}

// остановка PACT Mock Server
pact.Teardown()
}

Как видно в приведенном выше примере, тест PACT на Go представлен в виде простого модульного теста. В начале теста определен DSL PACT, и запущена имитация сервера.

Критическая точка теста  —  определение взаимодействия. Взаимодействие содержит состояние поставщика, имя тестового примера, ожидаемый запрос и ожидаемый минимальный ответ. Можно определить множество атрибутов как для запроса, так и для ответа, включая тело, заголовок, запрос, статус-код и т. д.

После того как мы определим взаимодействие, следующим шагом будет сама проверка. Тест PACT запускает клиента, который теперь отправляет запрос на имитацию вместо реального сервера. Я убедился, что это будет так, предоставив хост мок-сервера методу GetUserByID.

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

Последний шаг  —  это написание взаимодействия для контракта. По умолчанию Pact хранит контракт в папке pacts, но это можно изменить при инициализации PACT DSL. После выполнения кода конечный результат должен выглядеть следующим образом:

=== RUN   TestClientPact_Local
2021/08/29 13:55:25 [INFO] checking pact-mock-service within range >= 3.5.0, < 4.0.0
2021/08/29 13:55:26 [INFO] checking pact-provider-verifier within range >= 1.31.0, < 2.0.0
2021/08/29 13:55:26 [INFO] checking pact-broker within range >= 1.22.3
2021/08/29 13:55:27 [INFO] INFO WEBrick 1.4.2
2021/08/29 13:55:27 [INFO] INFO ruby 2.6.3 (2019-04-16) [universal.x86_64-darwin19]
2021/08/29 13:55:27 [INFO] INFO WEBrick::HTTPServer#start: pid=21959 port=56423
--- PASS: TestClientPact_Local (2.31s)
=== RUN TestClientPact_Local/get_user_by_id
2021/08/29 13:55:27 [INFO] INFO going to shutdown ...
2021/08/29 13:55:28 [INFO] INFO WEBrick::HTTPServer#start done.
--- PASS: TestClientPact_Local/get_user_by_id (0.02s)
PASS

Тестирование PACT для сервера

Написать тест PACT для сервера еще проще. Здесь идея заключается только в проверке желаемых контрактов, которые уже предоставляет клиент. Тесты для сервера также пишутся в формате модульных тестов.

import (
//
// некоторые импорты
//
"github.com/pact-foundation/pact-go/types"
"github.com/pact-foundation/pact-go/dsl"
)

func TestServerPact_Verification(t *testing.T) {
// инициализация PACT DSL
pact := dsl.Pact{
Provider: "example-server",
}

// верификация контракта на стороне сервера
_, err := pact.VerifyProvider(t, types.VerifyRequest{
ProviderBaseURL: "http://127.0.0.1:8080",
PactURLs: []string{"../client/pacts/example-client-example-server.json"},
})

if err != nil {
t.Log(err)
}
}

Клиент уже предоставил контракт в виде JSON-файла, который содержит все взаимодействия. Здесь также необходимо определить DSL PACT, а затем выполнить проверку Контракта.

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

Если проверка пройдет успешно, мы получим результат, аналогичный этому:

=== RUN   TestServerPact_Verification
2021/08/29 14:41:13 [INFO] checking pact-mock-service within range >= 3.5.0, < 4.0.0
2021/08/29 14:41:14 [INFO] checking pact-provider-verifier within range >= 1.31.0, < 2.0.0
2021/08/29 14:41:14 [INFO] checking pact-broker within range >= 1.22.3
--- PASS: TestServerPact_Verification (2.45s)
=== RUN TestServerPact_Verification/Pact_between__and__
--- PASS: TestServerPact_Verification/Pact_between__and__ (0.00s)
=== RUN TestServerPact_Verification/has_status_code_200
pact.go:637: Verifying a pact between example-client and example-server Given User Alice exists User 'Alice' is requested with GET /users/1 returns a response which has status code 200
--- PASS: TestServerPact_Verification/has_status_code_200 (0.00s)
=== RUN TestServerPact_Verification/has_a_matching_body
pact.go:637: Verifying a pact between example-client and example-server Given User Alice exists User 'Alice' is requested with GET /users/1 returns a response which has a matching body
--- PASS: TestServerPact_Verification/has_a_matching_body (0.00s)
PASS

Использование брокера PACT с Pactflow

Как уже упоминалось, тестирование PACT не может быть полным без PACT-брокера. Странно было бы ожидать, что у нас будет доступ к контрактам в физических файлах клиентов внутри конвейеров серверов.

Таким образом, команды разработчиков должны использовать отдельный PACT-брокер, предназначенный для конкретного проекта. Можно задействовать Docker-образ, предоставленный PACT Foundation, и установить его как часть инфраструктуры.

Кроме того, если вы готовы заплатить за брокер PACT, идеально подойдет решение от Pactflow. Его пробная версия позволяет хранить до пяти контрактов.

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

func TestClientPact_Broker(t *testing.T) {
pact := dsl.Pact{
Consumer: "example-client",
Provider: "example-server",
}

t.Run("get user by id", func(t *testing.T) {
id := "1"

pact.
AddInteraction().
Given("User Alice exists").
UponReceiving("User 'Alice' is requested").
WithRequest(dsl.Request{
Method: "GET",
Path: dsl.Term("/users/1", "/users/[0-9]+"),
}).
//
// верификация PACT
//
})

if err := pact.WritePact(); err != nil {
t.Fatal(err)
}

// определение издателя PACT
publisher := dsl.Publisher{}
err := publisher.Publish(types.PublishRequest{
PactURLs: []string{"./pacts/"}, // a folder with all PACT test
PactBroker: "<PACT BROKER>", // PACT broker
BrokerToken: "<API TOKEN>", // API token for PACT broker
ConsumerVersion: "1.0.0",
Tags: []string{"1.0.0", "latest"},
})
if err != nil {
t.Fatal(err)
}

pact.Teardown()
}

После регистрации в Pactflow вы должны получить новый хост для PACT-брокера. Кроме того, для завершения определения издателя в коде необходимо будет воспользоваться API-токеном. Найти API-токен можно в настройках панели мониторинга.

При выполнении нового клиентского теста в Pactflow добавляется первый контракт. Этот контракт снабжен тегами 1.0.0, latest и master (последний добавляется по умолчанию).

Первый контракт на Pactflow

Из-за разницы в клиенте тест адаптирован для отправки запроса на конечную точку /user/{userId} вместо /users/{userId}. Кроме того, здесь также изменен тег и ConsumerVersion на 1.0.1 вместо 1.0.0. После выполнения теста появится дополнительный контракт.

Два контракта на Pactflow

Далее нужно адаптировать тест для сервера. Изменения присутствуют только в процессе проверки. Теперь он включает хост PACT-брокера, API-токен и решение о публикации результата проверки.

func TestServerPact_BrokerVerification(t *testing.T) {
pact := dsl.Pact{
Provider: "example-server",
}

_, err := pact.VerifyProvider(t, types.VerifyRequest{
BrokerURL: "<PACT BROKER>",
BrokerToken: "<API TOKEN>",
ProviderBaseURL: "http://127.0.0.1:8080",
ProviderVersion: "1.0.0",
ConsumerVersionSelectors: []types.ConsumerVersionSelector{
{
Consumer: "example-client",
Tag: "1.0.0",
},
},
PublishVerificationResults: true, // опубликовать результаты проверки в PACT брокере
})

if err != nil {
t.Log(err)
}
}

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

Второе выполнение теста, которое завершилось неудачей, предназначено для проверки версии клиента 1.0.1. Второе выполнение должно прерываться, так как сервер все еще прослушивает конечную точку /users/{usersId}.

Верификация результата на Pactflow

Чтобы восстановить интеграцию между актуальной версией клиента и сервером, необходимо изменить либо сервер, либо клиента. В этом случае сервер перенастроен таким образом, чтобы он прослушивал новую конечную точку /user/{usersId}.

После обновления сервера до новой версии 1.0.1 и повторного выполнения проверки PACT-тест снова прошел нормально, и на PACT-брокере публикуются новые результаты тестирования.

В Pactflow, как и в любом другом PACT-брокере, также можно просмотреть историю процесса проверки каждого контракта. Это можно сделать, получив доступ к конкретному контракту из обзора панели мониторинга, а затем переключившись на вкладку Matrix.

Заключение

Написание контрактных тестов  —  это быстрый и дешевый способ тестирования в конвейере. Проверяя потребителей и поставщиков на ранних стадиях процесса CI /CD, мы экономим время и заблаговременно получаем обратную связь о результатах интеграции.

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

Читайте также:

Читайте нас в TelegramVK и Яндекс.Дзен


Перевод статьи Marko Milojevic: The Ultimate Guide for Contract Testing with PACT and Go

Предыдущая статьяКогда стоит использовать перечисления в Java?
Следующая статьяPydantic  —  гарантия надежного и безошибочного кода Python