В микросервисной архитектуре есть множество зависимостей от других сервисов и инфраструктуры. В результате чего возникают проблемы, которые съедают большое количество сил и времени. Приходит, например, тестировщик с описанием воспроизведения бага — а чтобы его воспроизвести, надо долго готовить данные, а потом еще дольше поднимать фронт… После N-й итерации повторять такое вы, конечно, не будете это, мягко говоря, утомляет. Так интеграционные тесты становятся определенным оверхедом вместо того, чтобы упрощать жизнь разработчикам.
Меня зовут Степан Охорзин, я Senior Go Developer в «Лаборатории Касперского». У нас в компании уже много проектов/продуктов, которые пишутся на Go, а еще мы мигрируем на него с «плюсов» там, где это возможно. Ведь Go — отличный язык, когда речь идет о распределенных системах; в частности, мы разрабатываем на нем облачные решения.
![](https://habrastorage.org/getpro/habr/upload_files/4d0/ad4/590/4d0ad45909f137b27ba2f28a9e407243.png)
Сегодня речь пойдет как раз об одном из таких инструментов — Kaspersky Security Center (KSC). Если коротко, то KSC — это консоль для удобного управления безопасностью на уровне предприятия, эдакий аналог ЦУПа для сложных IT-систем. Как вы уже догадались, KSC построен на микросервисной архитектуре — и именно в нем мы организовали интеграционное тестирование. Теперь наши тесты не просто не уходят в технический долг, а могут сами служить документацией. Мы же думаем только о бизнес-логике, все остальные вопросы берет на себя DI-контейнер.
В статье расскажу, как мы это реализовали, с деталями и примерами.
Прежде всего, нужно ответить на два главных вопроса: «что тестировать» и «как тестировать».
На первый вопрос… ответит проджект-менеджер :) Как правило, он и приводит требования бизнеса к той бизнес-логике, которую необходимо реализовать.
С ответом на вопрос «как тестировать» сложнее. Здесь могут возникать определенные проблемы:
Нам нужно создать какой-то шаблон для теста, чтобы тест был линейным, т. е. чтобы мы могли реализовать только бизнес-логику и поменьше думать о зависимостях.
Придется думать, как запустить сервис. И здесь я имею в виду не «внутрянку», а выполнение миграций, сбор конфигураций и т. п.
Придется поднять необходимую инфраструктуру для сервиса. Например, может понадобиться база данных конкретной версии или какие-то переменные окружения.
Могут потребоваться сетевые ресурсы — чтобы поднять сервис, нужно как минимум выделить свободный порт.
Перед запуском сервиса для некоторых тестов может потребоваться конкретная конфигурация.
Нужно понять, где взять клиент (HTTP, gRPC или, возможно, какой-то событийный клиент), методы которого мы будем вызывать.
Что мы хотим получить?
Теперь определимся с конечной целью наших действий — сделать так, чтобы думать только о логике теста.
Вот, к примеру, обычная тестовая функция на Go.
func TestSomeService(t *testing.T) {
do.Run(func (Service, Client) {
// Логика теста
})
}
Внутри есть еще одна функция, в которой содержится логика теста. В аргументах этой функции указаны зависимости — сервис и клиент.
Там могут быть и другие зависимости, у сервиса их достаточно — миграции, база данных, брокер сообщений. Также среди них могут быть другие сервисы, а еще, как правило, для тестирования необходим какой-то клиент или интерфейс (вплоть до утилиты в терминале).
![](https://habrastorage.org/getpro/habr/upload_files/06d/5ce/51d/06d5ce51d068ef6b473cc0ae9e848bae.png)
Было бы идеально, если бы при написании теста вся подготовка сводилась к указанию зависимостей. Как раз с управления зависимостями мы и начнем.
Dependency Controller
Управлять зависимостями позволяет компонент под названием Dependency Controller. Он должен уметь собирать и отдавать необходимые зависимости. То есть он должен в себя инкапсулировать:
Управление конфигурацией. Как я уже писал, перед запуском сервиса необходимо собрать его конфигурацию. Там могут быть ссылки на другие сервисы, переменные окружения — все это нужно собрать воедино.
Управление сетью. Это про выделение порта, назначение необходимого адреса.
Управление миграциями. У нас может быть база данных или менеджер сообщений. Перед запуском теста придется выполнить миграции, то есть нужен инструмент для этого.
Управление логикой запуска сервисов. Опять же, перед запуском нужно выполнить миграции, все сконфигурировать и настроить сеть, а это уже про логику запуска. Сам сервис при этом может быть «черным ящиком».
Управление инфраструктурой. Перед тестированием может потребоваться поднять базу, и в нее надо заранее накатить данные.
В итоге мы получаем большой граф зависимостей. Который хочется не держать в голове, потому что зависимостей у каждого сервиса достаточно много (а ведь еще сервисы зависят в том числе друг от друга).
Также важно понимать, какой сервис запустить первым. Например, у нас может быть сервис конфигурации, который важно стартануть первым, а после него уже следует запускать сервис, который от него зависит. Думаю, все, что я описал, на самом деле у каждого ассоциируется с DI-контейнером, речь об этом пойдет чуть позже.
И еще один важный компонент — Test Controller. У него обязанностей сильно меньше: инициализация тестов, передача зависимостей, управление жизненным циклом зависимостей.
Как это работает у нас
Вот пример структуры проекта. Тесты у нас лежат примерно на том же уровне, что и сервисы:
![](https://habrastorage.org/getpro/habr/upload_files/fc0/07f/444/fc007f44448ae24061589d0fbf3a5734.png)
Часто в микросервисной архитектуре бывает, что для каждого сервиса предусмотрен свой репозиторий. Но у нас монорепа, что позволяет нам писать и тесты, и код, так сказать, «в одном коммите». Подробнее об этом подходе есть вот в этой нашей хабростатье.
Шаблон теста
Итак, выше представлен самый простой вариант теста с одной функцией, но часто при тестировании одного сервиса возникает несколько тестовых кейсов. И чтобы все это поднять, нужны дополнительные сервисы — как правило, примерно одни и те же зависимости для разных тест-кейсов. Для таких ситуаций мы используем 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.
![](https://habrastorage.org/getpro/habr/upload_files/0f1/e75/e53/0f1e75e53d2fda00d47611b452207a1a.png)
При запуске теста мы создаем контекст, в котором trace id статичен. Поэтому даже если сообщение пролетает через несколько сервисов, trace id остается один и тот же. Так можно определить, какие логи относятся к данному тесту.
Второстепенный плюс — мы проверяем, что у нас корректно работают трассировки в тестах; что когда у нас события создает один сервис, они спокойно проходят через три-четыре сервиса.
Инфраструктура на пайплайнах
Для запуска можно использовать среду Docker-in-Docker, но будьте осторожны, если у вас много пользователей: можно положить runner!
Также можно складывать бинарники и поднимать инстансы с инфраструктурными зависимостями (например, базой данных). Это могут быть сервисы, которые пишет не ваша команда, и они целиком завернуты в контейнер. А можно поднять это где-то на стейдже и после просто получать то, что необходимо: то есть запускать бинарник и подкладывать туда конфигурацию с необходимыми адресами — в принципе, получится то же самое.
Ну или все можно развернуть в Kubernetes.
Вместо выводов
Итак, чем хорош предложенный здесь подход:
Мы получаем читаемые тесты. Это ценно не только само по себе — дело еще в том, что…
…тесты могут служить документацией. Когда новый разработчик приходит на проект, ему не придется читать много доков. Он может по каждому сервису посмотреть тесты и понять, что конкретно там происходит, в частности, какие используются входные данные для тестирования. Так он может составить представление о полной картине происходящего.
Становится проще использовать TDD. То есть сначала мы можем написать тесты, а потом уже реализовать какой-то код (и не важно, монорепа у вас или множество репозиториев).
Ну и главное: тесты пишутся :-) Наш подход позволяет не задумываться о зависимостях, инфраструктуре и вот этом вот всем. Чтобы начать писать тест, достаточно понимать бизнес-логику — то есть что именно хотелось в этом сервисе реализовать. А сами тесты не уходят в технический долг и не добавляют никому боли.
На этом все :-) Пишите комментарии, делитесь своим опытом, задавайте вопросы и приходите к нам в разработку на Go — сможете пощупать все это своими руками :-)