Всем привет! Меня зовут Кирилл Поляков, я QA-инженер в компании Lamoda. Мы тестируем бекэнд большой e-commerce платформы. В этой статье я расскажу, как мы пришли к автотестам на языке разметки для тестирования микросервисов и делаем это с помощью инструмента собственной разработки – Gonkey, который позволяет использовать стандартизированный набор решений и легко писать тесты на Go.
Как устроена e-commerce платформа Lamoda
Когда пользователь открывает приложение Lamoda и собирает корзину, то взаимодействует с e-commerce платформой. После его действий выполняется множество запросов. Они летят в различные системы для сбора информации о наличии товаров, доступных методах доставки и оплаты, возможных скидках. После оформления заказа пользователь будет получать уведомления об изменениях статуса.
Технический стек e-commerce платформы – это более 100 микросервисов на Go и базы данных на Postgres. У нас микросервисная архитектура с событийной моделью на базе Kafka. Continues Integrations организован на стеке Bamboo, деплоимся с помощью Kubernetes.
Тестирование микросервисов: как мы это делали
С ростом числа микросервисов мы задались вопросом о новом подходе к тестированию. К этому моменту у нас уже был стандартизирован процесс проектирования сервисов, их описания при помощи Swagger и автогенерация кода на основе swagger-спецификации. Также мы обновили свой CI/CD-пайплайн под нужды go-шных проектов. Следующим шагом было определиться с тестированием.
Традиционно тесты принято делить на три уровня: unit, integration, e2e. При этом чем выше по пирамиде располагается тест, тем он сложнее, медленнее и дороже. Но поскольку мы тестируем микросервисы, пришлось по-своему определить эти уровни:
- Юнит-тесты все так же проверяют отдельные функции. Здесь мы стараемся максимально покрыть сценарии с высокой комбинаторной сложностью.
- Интеграционные тесты проверяют компоненты нашего микросервиса и их взаимодействия с внешними системами. Например, API записывает результаты в базу данных, Consumer вычитывает сообщения из Kafka, Scheduler обращается во внешнее API по расписанию. На эти тесты мы делаем основной упор.
- E2E-тесты проверяют взаимодействие микросервисов между собой. Мы делаем это вручную: раскатываем сервисы в кластер Kubernetes и проверяем необходимые сценарии.
Если поднять несколько микросервисов в тестовом окружении легко, то поднять всю инфраструктуру Lamoda, чтобы выполнить полный регресс, крайне непросто. Но мы и не видели в этом смысла, поскольку микросервисы должны быть отказоустойчивыми. Как говорится, Design for failure. Мы собираем множество метрик с наших сервисов с помощью Prometeus и отображаем их на дашбордах Grafana. Если метрики показывают проблемы во время выкатки микросервиса, то мы его откатываем.
Таким образом, у нас разработчики пишут юнит-тесты, а E2E-тестирование проводится вручную. Оставалось лишь придумать, как будем реализовывать интеграционные тесты.
Выбор языка для автотестов
Когда мы решили писать интеграционные автотесты, то выбирали между Python и Go. Критерии были такими:
- Распространенность языка среди тестировщиков и разработчиков. Python – один из самых популярных языков разработки. Go, наоборот, достаточно молодой язык. Будет сложно искать тестировщиков со знанием Go, а тех, что есть, нужно будет переучивать. В то же время разработчики пишут код на Go, а значит, смогут помочь с написанием тестов и вспомогательных библиотек.
- Поддержка комьюнити. В комьюнити Go не так много инструментов для автотестов. Поэтому нам пришлось бы написать свои инструменты и вспомогательные библиотеки.
- Отладка кода: когда мы пишем тесты на языке разработки, то получаем возможность отладки кода при выполнении тестов.
- Запуск тестов: если мы выберем Go, тесты будут храниться и запускаться вместе с кодом приложения в одном окружении. То есть нам не нужно создавать отдельное окружение для прогона тестов (как если бы это был Python) и встраивать все это в CI.
В конечном итоге мы склонились к тому, чтобы использовать язык Go. Существующие инструменты тестирования на Go не дают нам готового решения, поэтому каждая команда может по-своему решать проблемы в тестировании. Нам требовался единый стандарт, который бы могли использовать все. В идеале мы хотели также максимально упростить процесс написание тестов, и тем самым снизить порог входа в автотесты на Go.
Самыми популярными инструментами для написания автотестов являются:
- Cтандартная библиотека Go
- [Библиотеки, предоставляющие больше возможностей с асертами и моками](https://github.com/stretchr/testify и https://labix.org/gocheck)
- А также BDD фреймворки: https://github.com/onsi/ginkgo и https://github.com/franela/goblin, которые предоставляют возможность писать тесты в BDD стиле, использовать готовые setup/teardown функции, поддержку асинхронности и все это из коробки.
Для начала мы решили написать стандартный автотест на go testing. В BDD фреймворки нужно глубоко погружаться, а нам хотелось побыстрее и на деле понять, с какими проблемами предстоит столкнуться.
“Простой” автотест на Go
Для эксперимента мы взяли сервис менеджмента заказов, у которого есть база на Postgres и зависимость в виде сервиса платежей.
Для проведения теста создания заказа нужно загрузить фикстуры в базу данных, засетапить мок нашей зависимости, запустить тестируемый сервис, выполнить запрос, проверить ответ и убедиться, что мок отработал корректно. И неплохо бы сформировать красивый отчет в Allure.
Наш первый автотест на Go выглядел так:
- Написали библиотеку моков, которая стартовала сервер и создавала в нем нужные поведения для используемого метода.
- Написали хелпер, который загружал фикстуры (для простоты это выполнялось через SQL-запросы).
- Запустили приложение (при этом оно должно знать про переменные окружения, а для этого нужен еще один хелпер).
- Загрузили json с ожидаемым результатом.
- Использовали сгенерированный по swagger-спецификации клиент. Альтернативный путь — это написать еще один хелпер с клиентом, который будет принимать на вход json.
- Использовали стандартную библиотеку assert. Но она позволяет выполнять проверки только на эквивалентность, чего может быть недостаточно.
- Написали адаптер для формирования отчетов для 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())
}
В результате мы получаем следующие выводы:
- Мы протестировали API, но готового набора решений не получилось.
- Каждая команда, которая работает со своим паком микросервисов, будет тестировать их так, как считает нужным: писать свои вспомогательные библиотеки и инструменты. Тестировщику из одного проекта будет сложно переключиться на другой.
Наше решение Gonkey: основы
Мы пришли к идее собственного инструмента, которой помог бы нам стандартизировать набор библиотек для тестирования и упростить процесс написания тестов. Любой тест в нашем случае – это setup, выполнение запроса, проверка ответа, teardown.
Мы задумались, как написать тест в виде нотации. У нас уже был опыт генерации Swagger, поэтому решили использовать для тестирования все наработки парсинга YAML.
Задаем структуру в виде YAML:
- называем тест;
- определяем, какой API-метод хотим использовать;
- какой json хотим передать;
- какой json и код ответа ожидаем получить.
- 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 и на его основе сгенерить фикстуры.
Теперь наша тестовая функция немного изменилась:
- Запускаем мок-сервер и задаем ему имя.
- Передаем адрес мока в переменные окружения.
- Создаем подключение к базе данных, чтобы наш инструмент знал, куда загружать фикстуры.
- Запускаем приложение.
- Сообщаем Test Runner, из какой папки взять 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-методам и по названиям.
В результате мы добились того, что хотели:
- Тестируем API, используя стандартизированный набор решений.
- Писать тесты стало легко. Мы зафиксировали основную рутину в виде сценариев в YAML. Если нужны какие-то дополнительные подготовительные действия, это решается на этапе сетапа.
Сложности и доработки
Активное использование 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 в опенсорс. Часть перечисленных в статье фич уже написаны комьюнити нашего инструмента. Поэтому, если у вас есть интерес поучаствовать в опенсорсе или вы хотите пользоваться этим инструментом – вот ссылочка. Welcome!
Flosya
Так на языке разметки или все же разработки? Название повергло в легкий шок)))
k_claim Автор
Согласен, название немного провокационное))
Тем не менее, сценарий теста, конфигурация моков и фикстуры описываются именно на языке разметки.