- PVSM.RU - https://www.pvsm.ru -
Всем привет! Меня зовут Кирилл Поляков, я QA-инженер в компании Lamoda. Мы тестируем бекэнд большой e-commerce платформы. В этой статье я расскажу, как мы пришли к автотестам на языке разметки для тестирования микросервисов и делаем это с помощью инструмента собственной разработки – Gonkey [1], который позволяет использовать стандартизированный набор решений и легко писать тесты на Go.
Когда пользователь открывает приложение Lamoda и собирает корзину, то взаимодействует с e-commerce платформой. После его действий выполняется множество запросов. Они летят в различные системы для сбора информации о наличии товаров, доступных методах доставки и оплаты, возможных скидках. После оформления заказа пользователь будет получать уведомления об изменениях статуса.
Технический стек e-commerce платформы – это более 100 микросервисов на Go и базы данных на Postgres. У нас микросервисная архитектура с событийной моделью на базе Kafka. Continues Integrations организован на стеке Bamboo, деплоимся с помощью Kubernetes.
С ростом числа микросервисов мы задались вопросом о новом подходе к тестированию. К этому моменту у нас уже был стандартизирован процесс проектирования сервисов, их описания при помощи Swagger и автогенерация кода на основе swagger-спецификации. Также мы обновили свой CI/CD-пайплайн под нужды go-шных проектов. Следующим шагом было определиться с тестированием.
Традиционно тесты принято делить на три уровня: unit, integration, e2e. При этом чем выше по пирамиде располагается тест, тем он сложнее, медленнее и дороже. Но поскольку мы тестируем микросервисы, пришлось по-своему определить эти уровни:
Если поднять несколько микросервисов в тестовом окружении легко, то поднять всю инфраструктуру Lamoda, чтобы выполнить полный регресс, крайне непросто. Но мы и не видели в этом смысла, поскольку микросервисы должны быть отказоустойчивыми. Как говорится, Design for failure. Мы собираем множество метрик с наших сервисов с помощью Prometeus и отображаем их на дашбордах Grafana. Если метрики показывают проблемы во время выкатки микросервиса, то мы его откатываем.
Таким образом, у нас разработчики пишут юнит-тесты, а E2E-тестирование проводится вручную. Оставалось лишь придумать, как будем реализовывать интеграционные тесты.
Когда мы решили писать интеграционные автотесты, то выбирали между Python и Go. Критерии были такими:
В конечном итоге мы склонились к тому, чтобы использовать язык Go. Существующие инструменты тестирования на Go не дают нам готового решения, поэтому каждая команда может по-своему решать проблемы в тестировании. Нам требовался единый стандарт, который бы могли использовать все. В идеале мы хотели также максимально упростить процесс написание тестов, и тем самым снизить порог входа в автотесты на Go.
Самыми популярными инструментами для написания автотестов являются:
Для начала мы решили написать стандартный автотест на go testing. В BDD фреймворки нужно глубоко погружаться, а нам хотелось побыстрее и на деле понять, с какими проблемами предстоит столкнуться.
Для эксперимента мы взяли сервис менеджмента заказов, у которого есть база на Postgres и зависимость в виде сервиса платежей.
Для проведения теста создания заказа нужно загрузить фикстуры в базу данных, засетапить мок нашей зависимости, запустить тестируемый сервис, выполнить запрос, проверить ответ и убедиться, что мок отработал корректно. И неплохо бы сформировать красивый отчет в Allure.
Наш первый автотест на Go выглядел так:
func TestOrdersCreate(t *testing.T) {
allure.StartSuite("Gonkey", time.Now())
tc := allure.StartCase("Orders Create", time.Now())
tc.AddLabel("story", "Positive")
mocks, err := setupMocks(mocksResponses)
if err != nil {…}
defer tearDownMocks(mocks)
err = loadFixtures()
if err != nil {…}
srv, err := server.NewOrdersManagementServer()
if err != nil {…}
defer srv.Close()
want := LoadGoldenFile(t, filepath.Join("testdata", "orderCreate.json"))
req := client.OrdersCreateBody{..}
cli := client.New(&client.Config{..})
ctx := context.Background()
resp, err := cli.OrdersCreate(ctx, &client.OrdersCreateParams{Body: req})
if err != nil {…}
got, err := json.MarshalIndent(resp.Payload, "", " ")
if err != nil {}
assert.Nil(t, err)
assert.Equal(t, 200, resp.Status)
assert.JSONEq(t, string(got), want)
if t.Failed() {
allure.EndCase("failed", errors.New("storage fallback failed"), time.Now())
} else {
allure.EndCase("passed", nil, time.Now())
}
allure.EndSuite(time.Now())
}
В результате мы получаем следующие выводы:
Мы пришли к идее собственного инструмента, которой помог бы нам стандартизировать набор библиотек для тестирования и упростить процесс написания тестов. Любой тест в нашем случае – это setup, выполнение запроса, проверка ответа, teardown.
Мы задумались, как написать тест в виде нотации. У нас уже был опыт генерации Swagger, поэтому решили использовать для тестирования все наработки парсинга YAML.
Задаем структуру в виде YAML:
- name: "Создание заказа"
method: "POST"
path: "/jsonrpc/v2/orders.create"
request: >
{
"jsonrpc": "2.0",
"id": "550e8400-e29b-41d4-a716-446655440000",
"method": "orders.create",
"params": {
"checkout_type": "FULL",
"platform": "site",
"country": "ru",
...
}
}
response:
200: >
{
"jsonrpc": "2.0",
"id": "550e8400-e29b-41d4-a716-446655440000",
"result": {
"lid": "070012AC1BB5C75946007D2C02000000",
"new_customer": true,
"order_nr": "RU-123",
"checkout_type": "FULL",
"platform": "site",
...
}
}
Не забыли и о проблемах с вхождением json в json. Теперь можно проверить всё по схеме или убедиться, что оба json эквивалентные или что один входит в другой. Такие возможности очень помогают, когда есть непредсказуемые данные. Например, генерируется случайный номер заказа или в ответе может вернуться список в любом порядке.
- name: "Создание заказа"
method: "POST"
path: "/jsonrpc/v2/orders.create"
comparisonParams:
ignoreValues: false
ignoreArraysOrdering: false
disallowExtraFields: false
request: >
{…}
response:
200: >
{…}
Мы решили добавить конфигурацию моков в тот же YAML. Ключем выступает имя мока и далее описывается его стратегия. Предполагаем, что запрос в мок может приходить как один раз, так и несколько. Далее проверяем, что обращение было определенное количество раз в рамках сценария. Также заложили в мок возможность проверять входящий запрос. Ответ же определяется в соответствии со стратегией.
- name: "Создание заказа"
method: "POST"
path: "/jsonrpc/v2/orders.create"
mocks:
paymentProcessing:
strategy: "uriVary"
basePath: "/json-rpc/v3"
uris:
orders.create:
calls: 1
strategy: "constant"
statusCode: 200
body: >
{
"jsonrpc": "2.0",
"id": "7c497e8d-b3e3-4921-bfb4-0b4472179fde",
"error": null,
"result": {…}
}
requestConstraints:
- kind: "bodyMatchesJSON"
body: >
{
"jsonrpc": "2.0",
"method": "orders.create",
"params": {…}
}
request: >
{…}
response:
200: >
{…}
Хранить фикстуры решили в отдельных YAML-файлах и ссылаться на них из тестовых сценариев. Изначально задали перечисление таблиц и записей в этих таблицах, а потом подумали про построение взаимосвязей между записями так, как это обычно и бывает в реляционных базах данных.
Затем добавили возможность описать шаблон. Достаточно описать одну запись и на основании нее, переопределив отличающиеся поля, сделать набор данных для фикстуры. Всё это можно указать в сценарии, откуда нужно взять YAML и на его основе сгенерить фикстуры.
Теперь наша тестовая функция немного изменилась:
func TestOrdersManagementServer(t *testing.T) {
m := mocks.NewNop(
"paymentProcessing",
)
err := m.Start()
if err != nil {…}
defer m.Shutdown()
envVars := server.EnvVars
envVars[server.PaymentProcessingHost] = m.Service("paymentProcessing").ServerAddr()
db, err := sql.Open("postgres", server.EnvVars["STORAGE_DSN"])
if err != nil {…}
defer db.Close()
for key, value := range envVars {
err = os.Setenv(key, value)
if err != nil {…}
}
srv, err := server.NewOrdersManagementServer()
if err != nil {…}
defer srv.Close()
dirs := []string{
"./cases/orders_create",
}
for _, dir := range dirs {
runner.RunWithTesting(t, &runner.RunWithTestingParams{
TestsDir: dir,
Server: srv,
Mocks: m,
DB: db,
FixturesDir: "./fixtures",
})
}
}
Мы также поместили Allure-adapter внутрь нашего инструмента и на выходе получаем отчет, в котором переиспользуется описанный в YAML тестовый сценарий. Таким образом, мы сохраняем в отчете информацию о запросе и ответе, и в случае ошибки отображаем diff между ожидаемым и полученным результатом. Все тесты сгруппированы по использованным API-методам и по названиям.
В результате мы добились того, что хотели:
Активное использование Gonkey показало, что мы не все предусмотрели, так как наши сервисы имеют свойство развиваться. Оказалось, что иногда в запросе нужно передавать определенный хедер или куки. Или мок должен последовательно дать несколько разных ответов. Да и неплохо бы использовать параметризацию, если тесты однообразны. От нашего инструмента потребовались новые возможности.
С хедером и куками все просто. Добавляем несколько параметров YAML и делаем нужный запрос.
- name: "Создание заказа"
method: "POST"
path: "/jsonrpc/v2/orders.create"
headers:
Authorization: "Bearer HsHG67d38hJKJFdfjj=="
Content-Type: "application/json"
cookies:
sid: "ZmEwZDkwYzgwMmQzMGIzOGIxODM3ZmFiOTGJhMzU="
lid: "AAAEAFu/TdhHBg7UAgA="
responseHeaders:
200:
Content-Type: "application/json"
request: >
{…}
response:
200: >
{…}
Для параметризации ввели новую нотацию, с помощью которой можно задать параметры в запросе и ответе, а потом определить их значение в виде набора данных в блоке cases. Один YAML превращается во множество тестов.
- name: "Создание заказа"
method: "POST"
path: "/jsonrpc/v2/orders.create"
request: >
{
"jsonrpc": "2.0",
"id": "550e8400-e29b-41d4-a716-446655440000",
"method": "orders.create",
"params": {
"country": "ru",
"checkout_type": {{ .checkout }},
"platform": {{ .platform }},
...
}
}
response:
200: >
{
"jsonrpc": "2.0",
"id": "550e8400-e29b-41d4-a716-446655440000",
"result": {
"lid": "070012AC1BB5C75946007D2C02000000",
"new_customer": true,
"order_nr": "RU-123",
"checkout_type": {{ .checkout }},
"platform": {{ .platform }},
...
}
}
cases:
- requestArgs:
checkout: "FULL"
platform: "site"
responseArgs:
200:
checkout: "FULL"
platform: "site"
- requestArgs:
checkout: "Quick"
platform: "mobile"
responseArgs:
200:
checkout: "Quick"
platform: "mobile"
Проблему с непредсказуемыми данными мы решили через проверку по маске. Тут нам на помощь пришли регулярные выражения.
Например, если номер заказа генерируется с префиксом “RU-” и тремя случайными цифрами, проверка будет выглядеть, как сравнение с выражением “RU-[0-9]{3}”. Мы не можем проверить значение, но зато будем уверены, что данные соответствуют заданному формату.
- name: "Создание заказа"
method: "POST"
path: "/jsonrpc/v2/orders.create"
request: >
{…}
response:
200: >
{
"jsonrpc": "2.0",
"id": "550e8400-e29b-41d4-a716-446655440000",
"result": {
"lid": "070012AC1BB5C75946007D2C02000000",
"new_customer": true,
"order_nr": "RU-[0-9]{3}]",
...
}
}
Сервисы продолжали развиваться и усложняться – появились воркеры, шедулеры, которые необходимо тестировать. Тесты должны были выйти за рамки API и проверять другие компоненты.
Что делать, если нам нужно протестировать консьюмер сообщений из Kafka? Например, необходимо проверить добавление задач по отправке писем в базу данных. Мы добавили в Gonkey возможность выполнять SQL-запрос и проверять полученный ответ как набор json, чтобы была возможность отображать diff в случае ошибки.
- name: "Создание заказа"
method: "POST"
path: "/jsonrpc/v2/orders.create"
request: >
{…}
response:
200: >
{…}
dbQuery: |
SELECT was_sent FROM notifications WHERE email = '{{ .buyer_email }}'
cases:
- dbQueryArgs:
buyer_email: "buyer_test_1@test.ru"
dbResponse:
- '{"was_sent": false}’
- dbQueryArgs:
buyer_email: "buyer_test_2@test.ru"
dbResponse:
- '{"was_sent": false}’
- dbQueryArgs:
buyer_email: "buyer_test_3@test.ru"
dbResponse: []
Другой пример уже про моки. Что, если мы создадим заказ с мульти-оплатой? Тогда сервис создания заказа обратится в сервис платежей дважды несколько раз и будет ожидать в ответах разные варианты оплаты. Для выполнения таких тестов мы добавили возможность конфигурировать мок так, чтобы он последовательно отвечал по-разному.
- name: "Создание заказа"
method: "POST"
path: "/jsonrpc/v2/orders.create"
mocks:
paymentProcessing:
strategy: "sequence"
sequence:
- strategy: "uriVary"
basePath: "/json-rpc/v3"
uris:
orders.create:
strategy: "constant"
statusCode: 200
body: >
{…}
- strategy: "constant"
statusCode: 500
body: >
{
"status": "error",
"errorCode": 33888,
"errorMessage": "Internal error"
}
request: >
{…}
response:
200: >
{…}
В Gonkey не получится сделать универсальный мок, который имитирует обмен с Kafka. В довесок к этому консьюмер в сервисе может быть реализован по-разному. А нам нужно как-то проверить, что пришло событие, и мы что-то сделали.
err := ConsumeEventsMock(
models.NameStockUpdateV1,
models.VersionStockUpdateV1,
quantityUpdateV1Processor)
if err != nil {…}
eventMock := &Event{}
srv.MustRegisterServiceWithHTTPPost(eventMock)
В данном случае решение не будет отличаться от того, когда тесты реализованы в виде кода. Нам нужно добавить мок-консьюмер, который сразу передал бы сообщение напрямую в процессор. Поскольку в нашем инструменте есть возможность взаимодействовать через API, то проще всего добавить в приложение еще один метод, который будет имитировать шину сообщений и направлять сообщения в мок. Таким образом, мы можем тестировать процессинг сообщений, изолировавшись от Kafka.
И еще одна проблема – это воркеры, работающие внутри приложения. Их необходимо контролировать, иначе тесты будут не изолированны. Мы запустили приложения со службами, а они крутятся в Go-рутинах и продолжат работать, когда тест закончится. Из-за этого мы будем ловить поведение от предыдущего теста в следующем. В таких случаях приходится добавлять к серверу функции, которые останавливают эти службы между тестами. Это решается снаружи от нашего инструмента.
ctx, ebusStop := context.WithCancel(context.Background())
busService := bstream.NewBusService(
bstream.NewBoxStorage(envCfg.BusStreamBatchSize, storageManager),
envCfg.BusStreamTimeout,
bstream.RepeaterTimeWithDelay(envCfg.BusStreamMessageTTL, envCfg.BusStreamMessageAttempts),
envCfg.EventBusEnabled,
)
go busService.Run()
return httptest.NewServer(srv), ebusStop, nil
В будущем мы хотели бы сделать поддержку XML. У нас нет сервисов, которые сами работают по SOAP, но есть сервисы, с которыми мы работаем по SOAP. Было бы неплохо проверять эти контракты.
Мы избавились от проблемы, как проверять вхождение одного json в другой, но столкнулись с необходимостью проверять отдельные поля. Например, когда сервис обмениваемся сообщениями с Kafka через шину событий, он отправляет туда запрос, где внутри json в одном из полей находится json с событием в виде строки. Это значит, что событие мы сможем проверить только на эквивалентность строке. Вместо этого было бы неплохо иметь возможность проверить отдельное поле по тем же стратегиям что мы используем для ответа на запрос
Исчезла проблема перехода на Go в плане написания автотестов. Мы дали тестировщикам возможность писать сценарий на YAML и попутно изучать Go. Чтобы локализовать баг, есть возможность запустить тесты с отладкой кода, развесить брейк-пойнты и посмотреть как это работает изнутри. Конечно, для сетапа нужно написать код на Go, но что касается всяких библиотек и проверок, все эти проблемы решены.
Инструмент позволил покрыть все микросервисы автотестами. Конечно, мы боялись ситуации, что создаем неполноценный продукт. Но оказалось, что другим стало интересно дописывать хелперы, дорабатывать и улучшать инструмент, которым все могут пользоваться.
Мы выложили Gonkey в опенсорс. Часть перечисленных в статье фич уже написаны комьюнити нашего инструмента. Поэтому, если у вас есть интерес поучаствовать в опенсорсе или вы хотите пользоваться этим инструментом – вот ссылочка [1]. Welcome!
Автор: k_claim
Источник [7]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/news/361116
Ссылки в тексте:
[1] Gonkey: https://github.com/lamoda/gonkey
[2] Cтандартная библиотека Go: https://golang.org/pkg/testing/
[3] https://github.com/stretchr/testify: https://github.com/stretchr/testify
[4] https://labix.org/gocheck: https://labix.org/gocheck
[5] https://github.com/onsi/ginkgo: https://github.com/onsi/ginkgo
[6] https://github.com/franela/goblin: https://github.com/franela/goblin
[7] Источник: https://habr.com/ru/post/539168/?utm_source=habrahabr&utm_medium=rss&utm_campaign=539168
Нажмите здесь для печати.