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