В микросервисной архитектуре есть множество зависимостей от других сервисов и инфраструктуры. В результате чего возникают проблемы, которые съедают большое количество сил и времени. Приходит, например, тестировщик с описанием воспроизведения бага — а чтобы его воспроизвести, надо долго готовить данные, а потом еще дольше поднимать фронт… После 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/
Добавить комментарий