![Полное руководство по тестированию контрактов с помощью PACT и Go Полное руководство по тестированию контрактов с помощью PACT и Go](https://nuancesprog.ru/wp-content/uploads/2022/03/1_gwOIr-PUwFD6GOBZs85wCw-696x391.png)
Моя любимая часть разработки программного обеспечения — писать тесты, как модульные, так и интеграционные. Приятно создать тест-кейс, на котором “падает” функция. Меня радует, если я нахожу ошибку на этой стадии и успеваю исправить ее до того, как кто-то найдет ее в тестовой среде или, что еще хуже, на продакшене.
Меня давно беспокоят проблемы интеграции между несколькими микросервисами. Как убедиться, что у двух микросервисов определенных версий нет проблем с интеграцией? Как сделать так, чтобы новая версия микросервиса не сломала API-интерфейс так, что другие сервисы больше не могут им воспользоваться?
Все это важно проверить до того, как запустятся масштабные сценарии сквозного тестирования. В противном случае приходится ждать целый час, чтобы получить ответ о нарушенной JSON-схеме.
Недавно я узнал о контрактном тестировании, и был поражен. Это оказался прорыв.
Контрактное тестирование
Тестирование контрактов гарантирует, что две стороны способны взаимодействовать друг с другом, путем проведения изолированной проверки того, поддерживают ли обе стороны сообщения, которыми обмениваются.
Одна сторона (потребитель) фиксирует связь с другой стороной (поставщиком) и создает контракт. Этот контракт представляет собой спецификацию запросов от потребителя и ответов от поставщика.
Код приложения автоматически генерирует контракты (в большинстве случаев это происходит на этапе модульного тестирования). Автоматическое создание гарантирует, что каждый контракт отражает актуальную реальность.
![](https://cdn-images-1.medium.com/max/1000/1*urBMTKRmAzfFVeHK4ojdHQ.png)
Когда потребитель опубликует контракт, им сможет воспользоваться поставщик. В своем коде (возможно, и в модульных тестах) поставщик выполняет проверку контракта и публикует результаты.
На обоих этапах тестирования контракта мы работаем только с одной стороной, без какого-либо реального взаимодействия со второй. На практике мы следим, чтобы обе стороны могли общаться друг с другом по отдельным каналам. Таким образом, весь процесс остается асинхронным и независимым.
Если какой-либо из этих двух этапов завершится неудачей, то потребитель и поставщик должны договориться об устранении проблем с интеграцией. В некоторых случаях потребителю следует адаптировать интеграционный код, а в других — поставщику необходимо перенастроить API.
Контрактное тестирование — это не тестирование схемы. Тестирование схемы привязано к одной стороне без подключения к какой-либо другой. Контрактное тестирование проверяет взаимодействие с обеих сторон и гарантирует совместимость любых желаемых версий.
Контрактное тестирование — это не сквозное тестирование. В нем проводится комплексное тестирование группы совместно работающих сервисов, где тестируется вся система, начиная с пользовательского интерфейса и заканчивая хранилищем данных. Контрактное тестирование отдельно выполняет тесты для каждого сервиса. Они изолированы и не требуют запуска более одного единовременно.
Итак, посмотрим, что же такое контрактное тестирование.
PACT
PACT — это инструмент для контрактного тестирования. Он применяется для проверки связи между потребителями и поставщиками по протоколу HTTP. Он также поддерживает тестирование очередей сообщений, таких как SQS, RabbitMQ, Kafka и т. д.
На стороне потребителя мы предоставляем контракты через PACT DSL для какого-либо языка программирования. Контракт содержит взаимодействия, которые состоят из ожидаемых запросов и ожидаемых минимальных ответов.
Во время выполнения теста потребитель отправляет запросы к имитации (mock) поставщика, которая использует определенное взаимодействие для сравнения фактического и ожидаемого HTTP-запроса. Если запросы совпадают, имитация поставщика возвращает ожидаемый минимальный ответ, чтобы потребитель мог проверить, соответствует ли этот ответ ожиданию.
![](https://cdn-images-1.medium.com/max/1000/1*Y6G642AezQX1qZ8xf7NDxg.png)
На стороне поставщика используются созданные контракты, чтобы проверить, способен ли сервер соответствовать ожиданиям. Результаты проверки можно опубликовывать, чтобы отслеживать, какие версии потребителей и поставщиков совместимы.
Когда мы запускаем тесты на стороне поставщика, имитация потребителя отправляет ожидаемый запрос поставщику. Тот проверяет, соответствует ли HTTP-запрос ожиданиям, и возвращает ответ. В конце концов, имитация потребителя сравнивает фактический ответ с ожидаемым минимальным ответом и предоставляет результат проверки.
![](https://cdn-images-1.medium.com/max/1000/1*TBOCrk3r9MoVn_gWJZLK5g.png)
Хранить все контракты и результаты проверки можно в 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. Его пробная версия позволяет хранить до пяти контрактов.
![](https://cdn-images-1.medium.com/max/1000/1*5EV3fQBt_OP_lYGoCVlsoA.png)
Чтобы опубликовать контракты в 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
(последний добавляется по умолчанию).
![](https://cdn-images-1.medium.com/max/1000/1*XREG9t9d9VlbXxnnYBwmxQ.png)
Из-за разницы в клиенте тест адаптирован для отправки запроса на конечную точку /user/{userId}
вместо /users/{userId}
. Кроме того, здесь также изменен тег и ConsumerVersion
на 1.0.1
вместо 1.0.0
. После выполнения теста появится дополнительный контракт.
![](https://cdn-images-1.medium.com/max/1000/1*VfKq_uEOcyl2jcjwyrVcuQ.png)
Далее нужно адаптировать тест для сервера. Изменения присутствуют только в процессе проверки. Теперь он включает хост 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}
.
![](https://cdn-images-1.medium.com/max/1000/1*kKyDc4md2I54j7W_Ue2YUQ.png)
Чтобы восстановить интеграцию между актуальной версией клиента и сервером, необходимо изменить либо сервер, либо клиента. В этом случае сервер перенастроен таким образом, чтобы он прослушивал новую конечную точку /user/{usersId}
.
После обновления сервера до новой версии 1.0.1
и повторного выполнения проверки PACT-тест снова прошел нормально, и на PACT-брокере публикуются новые результаты тестирования.
![](https://cdn-images-1.medium.com/max/1000/1*MbJjvdpoSX-b_iunkFmQXQ.png)
В Pactflow, как и в любом другом PACT-брокере, также можно просмотреть историю процесса проверки каждого контракта. Это можно сделать, получив доступ к конкретному контракту из обзора панели мониторинга, а затем переключившись на вкладку Matrix.
![](https://cdn-images-1.medium.com/max/1000/1*HwOy-2sq3Cu1f0cLyN55lg.png)
Заключение
Написание контрактных тестов — это быстрый и дешевый способ тестирования в конвейере. Проверяя потребителей и поставщиков на ранних стадиях процесса CI /CD, мы экономим время и заблаговременно получаем обратную связь о результатах интеграции.
Контрактные тесты позволяют использовать реальные версии клиентов и серверов и проверять их в изолированном процессе, чтобы выяснить, совместимы ли эти конкретные версии между собой.
Читайте также:
Читайте нас в Telegram, VK и Яндекс.Дзен
Перевод статьи Marko Milojevic: The Ultimate Guide for Contract Testing with PACT and Go