Долой оверхед: как мы избавились от зависимостей в интеграционном тестировании микросервисов

от автора

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

Меня зовут Степан Охорзин, я Senior Go Developer в «Лаборатории Касперского». У нас в компании уже много проектов/продуктов, которые пишутся на Go, а еще мы мигрируем на него с «плюсов» там, где это возможно. Ведь Go — отличный язык, когда речь идет о распределенных системах; в частности, мы разрабатываем на нем облачные решения.

Сегодня речь пойдет как раз об одном из таких инструментов — Kaspersky Security Center (KSC). Если коротко, то KSC — это консоль для удобного управления безопасностью на уровне предприятия, эдакий аналог ЦУПа для сложных IT-систем. Как вы уже догадались, KSC построен на микросервисной архитектуре — и именно в нем мы организовали интеграционное тестирование. Теперь наши тесты не просто не уходят в технический долг, а могут сами служить документацией. Мы же думаем только о бизнес-логике, все остальные вопросы берет на себя DI-контейнер.

В статье расскажу, как мы это реализовали, с деталями и примерами.

Прежде всего, нужно ответить на два главных вопроса: «что тестировать» и «как тестировать».

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

С ответом на вопрос «как тестировать» сложнее. Здесь могут возникать определенные проблемы:

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

  • Придется думать, как запустить сервис. И здесь я имею в виду не «внутрянку», а выполнение миграций, сбор конфигураций и т. п.

  • Придется поднять необходимую инфраструктуру для сервиса. Например, может понадобиться база данных конкретной версии или какие-то переменные окружения.

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

  • Перед запуском сервиса для некоторых тестов может потребоваться конкретная конфигурация.

  • Нужно понять, где взять клиент (HTTP, gRPC или, возможно, какой-то событийный клиент), методы которого мы будем вызывать.

Что мы хотим получить?

Теперь определимся с конечной целью наших действий — сделать так, чтобы думать только о логике теста.

Вот, к примеру, обычная тестовая функция на Go.

func TestSomeService(t *testing.T) {     do.Run(func (Service, Client) {         // Логика теста     }) }

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

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

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

Dependency Controller

Управлять зависимостями позволяет компонент под названием Dependency Controller. Он должен уметь собирать и отдавать необходимые зависимости. То есть он должен в себя инкапсулировать:

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

  • Управление сетью. Это про выделение порта, назначение необходимого адреса.

  • Управление миграциями. У нас может быть база данных или менеджер сообщений. Перед запуском теста придется выполнить миграции, то есть нужен инструмент для этого.

  • Управление логикой запуска сервисов. Опять же, перед запуском нужно выполнить миграции, все сконфигурировать и настроить сеть, а это уже про логику запуска. Сам сервис при этом может быть «черным ящиком».

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

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

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

И еще один важный компонент — Test Controller. У него обязанностей сильно меньше: инициализация тестов, передача зависимостей, управление жизненным циклом зависимостей.

Как это работает у нас

Вот пример структуры проекта. Тесты у нас лежат примерно на том же уровне, что и сервисы:

Часто в микросервисной архитектуре бывает, что для каждого сервиса предусмотрен свой репозиторий. Но у нас монорепа, что позволяет нам писать и тесты, и код, так сказать, «в одном коммите». Подробнее об этом подходе есть вот в этой нашей хабростатье.

Шаблон теста

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

type ServiceSuite struct { // 4 usages     suite.Suite     service Service     client  Client }  func TestService(t *testing.T) {     s := &ServiceSuite{}     dc.Invoke(s.deps)     suite.Run(t, s) }  func (s *ServiceSuite) deps(service Service, client Client) {     s.service = service     s.client = client }  func (s *ServiceSuite) TestCase1(t *testing.T) {     // Логика }  func (s *ServiceSuite) TestCase2(t *testing.T) {     // Логика }

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

Было очень важно прийти к такой структуре. Потому что когда разработчик начинает думать о необходимых для теста зависимостях, желание писать сам тест отпадает 🙂

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

Еще один важный момент — это функция TestMain. Она всегда запускается в Go перед тестами и по сути инициализирует DI-контейнер. В нее мы передаем необходимые провайдеры и специфичные переменные окружения. TestMain пишется один раз и может дублироваться в разных тестовых контекстах (где лежат сьюты).

package inputequation_test  import (     "os"     "testing"     "time"     "github.com/pkg/errors"     "providers"     "containers"     "tkTracking" )  const (     intequationTestTimeout = 15 * time.Minute )  var dlcInstance providers.DIC   func TestMain(m *testing.M) {     tkTracking.EnableStrictTracer()     dic, err := providers.NewSafeUIC(         providers.WithDefaultProviders(),         providers.WithContextDeadLineTimeout(intequationTestTimeout),         providers.WithDB(containers.DBTypePostgres),     )     if err != nil {         panic(errors.Wrap(err, "can't build dic"))     }     dlcInstance = dic     exitCode := m.Run()     _ = dic.Cleanup()     os.Exit(exitCode) }

Если потребуется тестировать что-то другое, нужно будет создать свою функцию TestMain.

uber.DIG

В качестве DI-контейнера мы у себя используем DIG от Uber. Его интерфейс достаточно простой.

type DIG interface {     Provide(interface{})     Invoke(interface{}) }  func TestSomeService(t *testing.T) {     dic.Provide(func() Service {         return Service(nil)     })     dic.Provide(func(service Service) Client {         return Client(nil)     })     dic.Invoke(func(client Client) {         // do something     }) }

Здесь у нас есть функции provide и invoke: первая отвечает за объявление зависимостей, вторая — за их вызов.

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

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

Следующая функция — invoke — запускает сервис и требует, чтобы ей передали клиент в качестве зависимости.

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

В качестве примера покажу провайдер некого клиента:

type SomeServiceClient httpClient.ClientWithResponseInterface  func provideSomeService(     s services.SomeService,     networkManager common.NetworkManager, ) (SomeServiceClient, error) {     return httpClient.NewClientWithResponses(         networkManager.GetServiceURL(s.Name()),     ) }

Первой строчкой мы объявляем тип. Он нужен далее, чтобы указывать в качестве зависимости. У данного провайдера есть зависимость — это сервис, и есть NetworkManager. Возвращаемый тип — это какой-то ServiceClient.

В теле функции, по сути, обычная фабрика. Мы что-то создаем, запрашиваем у NetworkManager адрес сервиса, далее создаем клиент. Все провайдеры будут выглядеть подобным образом: в качестве возвращаемого значения будет тип, а в качестве аргументов — зависимости.

DI Container

Вот как объявляется DI-контейнер:

package providers  import (     "add"     "common"     "containers"     "helpers"     "services"     "clients" )  func DefaultProviders() (providers []interface{}) {     providers = append(         providers,         func() common.ConfigurationDefaultEnvs {             return map[common.EnvName]string{                 "PSQL_TLS_OFF": "true",             }         },     )      providers = append(providers, common.Providers()...)     providers = append(providers, containers.Providers()...)     providers = append(providers, helpers.Providers()...)     providers = append(providers, services.Providers()...)     providers = append(providers, clients.Providers()...)      return providers   }

Здесь мы объявляем набор провайдеров и базовые переменные окружения. Видим клиенты, сервисы, хелперы, контейнеры и прочие общие вещи типа NetworkManager. Все это инициализируется, и, как только будет необходимо, вызывается функция invoke.

Пример провайдера сервиса

func (     dbMigrate DBMigrate,     natsMigrate NATSMigrate,     configurator common.Configurator,     networkManager common.NetworkManager, ) (s SomeService, err error) {     if err = networkManager.OSMPServiceRegistrationAndSetFreePort(app.ServiceName); err != nil {         // Handle error     }     var dsn string     if _, dsn, err = dbMigrate(app.ServiceName, dbMigrations.NewMigrations()); err != nil {         // Handle error     }     if err = configurator.SetOSMPEnvs(map[common.EnvName]string{         EnvServiceADSN: dsn,     }); err != nil {         return nil, errors.Wrap(err, "SetOSMPEnv")     }     if err = natsMigrate(natsMigrations.NewMigrations()); err != nil {         // Handle error     }     cfg := app.NewConfig()     if err = configurator.Load(app.ServiceName, cfg); err != nil {         // Handle error     }     s = app.NewService(cfg)     return s, start(s) }

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

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

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

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

Docker

Для выполнения тестов мы также используем Docker и библиотечку Testcontainers. По сути провайдер выглядит аналогичным образом:

func (     logger log.Logger,     ctx common.TestsContext,     cleanup common.Cleanup,     dockerFixture *Fixture,     containerName NatsName,     configurator common.Configurator,     containerFactory containerFactory,     networkManager common.NetworkManager, ) (NATS, error) {     hostPort, err := networkManager.AllocateFreePort(string(containerName))     if err != nil {         return nil, errors.Wrap(err, "AllocateFreePort")     }     container := containerFactory(string(containerName), PortBinding(...))     envs := map[common.EnvName]string{...}     if err = configurator.SetOSMPEnvs(envs); err != nil {         return nil, errors.Wrap(err, "SetOSMPEnvs")     }     if err = dockerFixture.AddContainerRequests(natsContainerRequest(container)); err != nil {         // Handle error     }     if err = dockerFixture.RunContainerByName(ctx, string(containerName), enabledgstrue); err != nil {         // Handle error     }      cleanup(func() {         if err := dockerFixture.TerminateByName(ctx, string(containerName)); err != nil {             // Handle error         }     })      return container, nil }

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

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

NetworkManager

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

type NetworkManager interface {     AllocateFreePort(serviceName string) (int, error)     ServiceRegistrationAndSetFreePort(serviceName string) error     GetServiceURL(serviceName string) string     GetServiceAddress(serviceName string) string     GetServicePort(serviceName string) int }

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

LogWatcher

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

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

Когда происходит событие в сервисе, мы это логируем, а в тестах создаем Watcher, который по определенному паттерну отлавливает логи.

watcher := tracing.NewWatcher(ctx, t)(fmt.Sprintf(     "OnTenantCreated Parent tenant has no rules.*%v.*%v", childTenant, parentTenant), )  s.tenantRegistryHelper.CreateTenant(ctx, t, parentTenant, superID: "", helpers.UniqueTenantName(), description: "") s.tenantRegistryHelper.CreateTenant(ctx, t, childTenant, parentTenant, helpers.UniqueTenantName(), description: "")  msg := common.AwaitChanMsgOrDoneCtx(ctx, t, watcher) require.NoError(t, msg.Err)

И выполняя операцию, которая триггерит данное событие, мы получаем сообщения в логе.

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

Использование трассировок в тестах

В сервисах мы используем OpenTelemetry (немного про использование OpenTelemetry в наших проектах есть вот в этой статье). Когда мы пишем логи, то указываем трейсы и spanID.

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

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

Инфраструктура на пайплайнах

Для запуска можно использовать среду Docker-in-Docker, но будьте осторожны, если у вас много пользователей: можно положить runner!

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

Ну или все можно развернуть в Kubernetes.

Вместо выводов

Итак, чем хорош предложенный здесь подход:

  • Мы получаем читаемые тесты. Это ценно не только само по себе — дело еще в том, что…

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

  • Становится проще использовать TDD. То есть сначала мы можем написать тесты, а потом уже реализовать какой-то код (и не важно, монорепа у вас или множество репозиториев).

  • Ну и главное: тесты пишутся 🙂 Наш подход позволяет не задумываться о зависимостях, инфраструктуре и вот этом вот всем. Чтобы начать писать тест, достаточно понимать бизнес-логику — то есть что именно хотелось в этом сервисе реализовать. А сами тесты не уходят в технический долг и не добавляют никому боли.

На этом все 🙂 Пишите комментарии, делитесь своим опытом, задавайте вопросы и приходите к нам в разработку на Go — сможете пощупать все это своими руками 🙂


ссылка на оригинал статьи https://habr.com/ru/articles/880268/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *