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

Комментарии (0)