
Всем привет! Меня зовут Дима, я QA-инженер по автоматизации в Туту.
В микросервисной архитектуре ошибка — это не просто баг в отдельном сервисе. Это сорванный релиз, нестабильные интеграции, потерянные заказы и часы дорогой ручной проверки. Когда сервисов десятки, а релизы идут постоянно, цена отсутствия системной автоматизации становится слишком высокой.
Уже больше полутора лет я пишу автотесты на Go. За это время мы прошли путь от «зачем вообще тестировать на Go?» до «почему мы не сделали это раньше?».
В этой статье я покажу, как внедрить автоматизацию тестирования на Go с нуля — так, чтобы она решала реальные бизнес‑проблемы, а не просто увеличивала количество тестов в репозитории.
Что разберем на практике:
почему автотесты на Go — это не просто альтернативный стек, а способ сделать тесты быстрее, стабильнее и ближе к коду;
как изолировать внешние зависимости через моки и не ломать тесты при каждом изменении API;
как корректно тестировать Kafka и БД, чтобы ловить реальные интеграционные ошибки;
как встроить всё это в CI/CD и получить быстрый, предсказуемый фидбек перед релизом.
Речь пойдёт о реальных микросервисных сценариях: gRPC, HTTP, Postgres, Kafka.
Почему выбрали Go для автотестов
Когда я только начинал, главный вопрос звучал так: зачем писать автотесты на Go, если есть Java и Python с десятками фреймворков.
Аргумент «чтобы разработчикам было проще читать код» звучит слабо. Поэтому решение должно было опираться не на удобство, а на экономику и устойчивость разработки.
Меньше кода — меньше расходов на поддержку. Если бэкенд написан на Go, тесты на Go позволяют использовать те же самые модели, protobuf-контракты и gRPC-клиенты.
Это означает, что нам не нужно поддерживать второй слой инфраструктуры на другом языке: со своими DTO, сериализаторами, клиентами и конфигурациями — обновление контрактов происходит централизованно через Go-модули без необходимости поддерживать отдельную реализацию клиентов на другом языке.
В проектах на смешанных стеках тестовый слой постепенно начинает жить собственной жизнью. Любое изменение контракта тянет за собой правки в двух местах. Это дополнительные часы работы, больше точек отказа и выше вероятность расхождения между тестами и реальностью.
Скорость сборки = скорость обратной связи. Go компилируется быстро и не требует виртуальной машины. На практике это означает короткий цикл «написал тест → запустил → получил результат». Чем быстрее команда получает обратную связь, тем быстрее исправляются дефекты.
А скорость обратной связи напрямую влияет на time-to-market. Быстрее релизы → быстрее вывод фич → быстрее получение выручки или пользовательской ценности.
Простота языка снижает риски. Go намеренно ограничивает сложность. В нём трудно перемудрить с абстракциями или построить чрезмерно запутанную архитектуру.
Для тестового кода это особенно важно. Тесты должны быть предсказуемыми, понятными и легко поддерживаемыми даже спустя год.
Чем проще код тестов, тем ниже зависимость от конкретных людей в команде. Это уже управленческий риск: если ключевой инженер уходит, бизнес не должен терять способность развивать продукт.
Архитектура, которую будем тестировать
fly-broker — gRPC-сервис бронирования
avia-service — HTTP-сервис, выпускающий билеты
PostgreSQL
Kafka (через выгрузку из Outbox)
Схема взаимодействия выглядит так:

Эта схема показательна тем, что сочетает все ключевые источники сложности в тестировании микросервисов: синхронные вызовы между сервисами, работу с БД и асинхронную доставку событий через Kafka.
На таком примере можно разобрать и компонентные, и полноценные интеграционные тесты.
Пишем тесты с Allure-Go
После прочтения у вас появится представление, как писать и запускать тесты на Go: расскажу про структуру тестов, их запуск, параметризацию, особенности фреймворка для интеграции тестов в аллюр-отчет.
Установим зависимости:
go get github.com/ozontech/allure-go/pkg/allure go get github.com/ozontech/allure-go/pkg/framework
Базовая структура теста выглядит так:
type CreateOrderSuite struct { setup.TestSuite ParamCreateOrderPositiveTestData []CreateOrderPositiveTestData ParamCreateOrderNegativeTestData []CreateOrderNegativeTestData } func (s *CreateOrderSuite) BeforeAll(t provider.T) { s.Setup(t) s.ParamCreateOrderPositiveTestData = GetCreateOrderPositiveTestData s.ParamCreateOrderNegativeTestData = GetCreateOrderNegativeTestData } // SuiteRunner запускает все тесты из сьюта CreateOrderSuite func TestCreateOrderSuiteRunner(t *testing.T) { suite.RunNamedSuite(t, constants.CreateOrderSuite, new(CreateOrderSuite)) } // Параметризованные тесты func (s *CreateOrderSuite) TableTestCreateOrderPositiveTestData(t provider.T, data CreateOrderPositiveTestData) { t.Parallel() t.Title(data.testName) t.Description("Создаем заказ. После сверяем статус заказа с ожидаемым результатом") t.AddSubSuite(constants.PositiveSubSuite) t.WithNewStep("Создаем заказ", func(sCtx provider.StepCtx) { ctx := t.Context() ctx = metadata.AppendToOutgoingContext(ctx, constants.AccountIDHeader, data.accountID) resp, err := s.FlyBrokerClient.CreateOrder(ctx, data.request) s.AssertGrpcResponseSuccess(sCtx, resp, err) sCtx.Require().Equal(data.bookingStatus.String(), resp.GetBookingStatus().String(), "Проверяем, что статус заказа соответствует ожидаемому результату") }) }
Про параметризованные тесты
Для корректной работы параметризованных тестов и их правильного отображения в Allure необходимо придерживаться единых правил именования и инициализации.
Функция с тестами
Функция, выполняющая параметризованные тесты:
Должна начинаться с префикса TableTest
Должна заканчиваться названием структуры с тестовыми данными
Вторым аргументом должна принимать структуру с тестовыми данными
Пример:
func TableTestCreateOrderPositiveTestData(t provider.T, data CreateOrderPositiveTestData)
Структура сьюта
Структура сьюта для параметризованных тестов:
Должна начинаться с префикса Param
Должна заканчиваться названием структуры с тестовыми данными
Пример: ParamCreateOrderPositiveTestData
Инициализация тестовых данных
Структуре сьюта необходимо присвоить список тестовых данных
Пример:
s.ParamCreateOrderPositiveTestData = GetCreateOrderPositiveTestData
Переменная GetCreateOrderPositiveTestData должна возвращать срез тестовых данных
var GetCreateOrderPositiveTestData = []CreateOrderPositiveTestData{ //Данные теста }
Что даёт provider.T
t.Parallel() // параллельный запуск тестовt.Skip() // Скип тестаt.Assert() / t.Require() // Софт / хард - ассертыt.WithTestSetup() / t.WithTestTeardown() // сетап / завершение тестаt.Title(), t.Description(), t.Epic() // более подробное описание тестов
Важно понимать, что provider.T — это не просто прокси-обертка над *testing.T, а фреймворк, который управляет тестом и формированием Аllure-отчета.
Написание моков
На тестовой среде avia-service работает медленно и периодически падает.
Каждый такой сбой:
ломает прогон автотестов,
заставляет команду перезапускать пайплайны,
тратит часы на разбор «это баг или сбоит среда?»,
замедляет релизы.
Если тестовый прогон занимает 30–40 минут и падает из‑за нестабильного внешнего сервиса хотя бы 2–3 раза в день, команда теряет несколько человеко‑часов ежедневно. В пересчёте на месяц это уже десятки часов разработки, потраченных не на фичи, а на борьбу с инфраструктурой.
Именно здесь появляется реальная боль: тесты должны давать быстрый и предсказуемый фидбек, но зависимость от нестабильного сервиса превращает их в источник шума.
Нужно:
тестировать позитивные сценарии
проверять таймауты
проверять ошибки провайдера
Решение — собственный mock-service, позволяющий управлять сценариями.
Для написания mock-service будем использовать библиотеку go-restful.
Создаем хэндлер:
type AviaHandler struct{} func NewAviaHandler() *AviaHandler { return &AviaHandler{} } func (h *AviaHandler) IssueTicket(req *restful.Request, resp *restful.Response) { if req.Request.Body == http.NoBody { _ = resp.WriteError(http.StatusBadRequest, errors.New("request body is required")) return } defer func() { _ = req.Request.Body.Close() }() bb, err := io.ReadAll(req.Request.Body) if err != nil { _ = resp.WriteError(http.StatusBadRequest, fmt.Errorf("read body err: %w", err)) return } var requestBody model.IssueTicketRequest if err = json.Unmarshal(bb, &requestBody); err != nil { _ = resp.WriteError(http.StatusBadRequest, fmt.Errorf("parse body err: %w", err)) return } var data model.IssueTicketResponse orderID := requestBody.OrderID switch orderID { case "success": data = model.IssueTicketResponse{ // заполнение успешного ответа } case "timeout": time.Sleep(5 * time.Second) case "error": _ = resp.WriteError( http.StatusInternalServerError, fmt.Errorf("provider error"), ) return } if err := resp.WriteEntity(data); err != nil { _ = resp.WriteError(http.StatusInternalServerError, err) } }
Делаем роут:
func InitAviaRoutes() *restful.WebService { webService := &restful.WebService{} aviaHandler := avia.NewAviaHandler() webService. Path("/avia"). Produces(restful.MIME_JSON). Route(webService.POST("/tickets/issue").To(aviaHandler.IssueTicket)) return webService }
Теперь мы можем контролировать поведение провайдера через order_id.
Это даёт команде не просто набор сценариев, а управляемую тестовую среду:
Тестировать happy path — проверять основной бизнес‑сценарий без влияния внешней нестабильности.
Проверять ошибки от провайдера — 4xx/5xx, некорректные ответы.
Эмулировать таймауты и проверять обработку деградаций.
В результате команда получает:
предсказуемые и воспроизводимые тесты;
отсутствие флаки‑падений из‑за внешней среды;
быстрое прохождение тестов в CI;
возможность покрыть редкие и аварийные сценарии, которые сложно воспроизвести на реальном стенде.
Тестирование Kafka и БД
Покрывая связку БД + Kafka, мы проверяем:
что события действительно публикуются;
что они публикуются корректно;
что интеграция между слоями не ломается при изменениях;
что бизнес‑процесс гарантированно доходит до конца;
Сервис fly-broker пишет события в таблицу outbox, а отдельный воркер отправляет их в Kafka.
Установим зависимости:
gorm.io/driver/postgres для взаимодействия с БД
github.com/segmentio/kafka-go для тестирования кафки
Подключаемся к БД Postgres через gorm:
db, err := gorm.Open(postgres.Open(dbDSN), &gorm.Config{})
dbDSN — переменная, по которой задается конфигурация БД.
Настройка БД завершена. Можно с ней взаимодействовать.
func (r *Repository) AddOutbox(ctx context.Context, outbox model.Outbox) error { if err := r.db.WithContext(ctx).Create(&outbox).Error; err != nil { return fmt.Errorf("failed to add outbox record: %w", err) } return nil } func (r *Repository) DeleteOutbox(ctx context.Context, id string) error { if err := r.db.WithContext(ctx).Where("id = ?", id).Delete(&model.Outbox{}).Error; err != nil { return fmt.Errorf("failed to delete outbox record: %w", err) } return nil }
Kafka-consumer для теста:
type Consumer struct { reader *kafka.Reader } func NewConsumer(topic, consumerGroup string, brokers ...string) *Consumer { return &Consumer{ reader: kafka.NewReader(kafka.ReaderConfig{ Brokers: brokers, Topic: topic, GroupID: consumerGroup, CommitInterval: 0, }), } } func (c *Consumer) Close() error { return c.reader.Close() } func (c *Consumer) ReadMessage(ctx context.Context) (kafka.Message, error) { msg, err := c.reader.ReadMessage(ctx) if err != nil { return kafka.Message{}, fmt.Errorf("failed to read message: %w", err) } if err := c.reader.CommitMessages(ctx, msg); err != nil { return kafka.Message{}, fmt.Errorf("failed to commit message: %w", err) } return msg, nil }
Важно
CommitInterval: 0 — отключение автокоммита, где сообщение считается успешно обработанным только после успешного коммита reader.CommitMessages(ctx, msg)
reader.Close(): завершаем работу с топиком, чтобы избежать утечек
Тест на кафку:
// Уже единичный тест, без параметризации func (s *KafkaSuite) TestKafka(t provider.T) { t.Parallel() t.Title("Проверяем отправку сообщений в кафку из таблицы outbox") t.Description("Добавляем запись в таблицу outbox и проверяем, что запись по тикеру отправится в кафку") t.AddSubSuite(constants.PositiveSubSuite) outbox := createNewOutbox(model.StateProcessing, uuid.New().String(), faker.Sentence(), 10000) t.WithTestSetup(func(setupProvider provider.T) { err := s.Repo.AddOutbox(setupProvider.Context(), outbox) setupProvider.Require().NoError(err, "Проверяем, что запись в таблицу outbox успешно добавлена") }) // Этот блок гарантированно выполнится, даже если дальнейшие шаги завершатся ошибкой defer func() { t.WithTestTeardown(func(teardownProvider provider.T) { err := s.Repo.DeleteOutbox(t.Context(), outbox.ID) teardownProvider.Require().NoError(err, "Проверяем, что запись из таблицы outbox успешно удалена") }) }() t.WithNewStep("Проверяем отправку outbox в кафку", func(sCtx provider.StepCtx) { found, kafkaMessage := s.GetMessageFromOutboxTopic(t, outbox.ID, 15*time.Second) sCtx.Assert().True(found, fmt.Sprintf("Проверяем, что сообщение из топика outbox по id %v найдено", outbox.ID)) }) }
Запуск тестов
Тесты запускаем через команду:
rm -rf tests/allure-results && go clean -testcache && godotenv -f .env gotestsum --format testname – -p 1 ./…
Из особенностей этой команды:
-rf tests/allure-results— перед запуском тестов удаляется папка allure-results, чтобы очистить старые результаты и сформировать отчет зановоgo clean -testcache— в Go кэшируются успешные тесты. И если код не менялся, то такие тесты заново не запускаются. Эта команда очищает кэш тестов, чтобы они гарантированно выполнялись-format testname— простой формат по именам тестов. В консоли будут отображаться названия тестов- p 1— ограничение по параллельности: запускаем 1 пакет за раз. Бывает полезна, чтобы, например, избежать race condition
Чтобы указать путь allure-results, нужно задать переменную окружения ALLURE_OUTPUT_FOLDER для которой указываем абсолютный путь.
После прохождения тестов сформируем аллюр-отчет:
allure generate tests/allure-results --clean -o tests/allure-report allure open tests/allure-report

Интеграция в CI/CD
В качестве CI/CD системы будем использовать Gitlab CI. Тесты запускаются в Docker-окружении, что позволяет максимально приблизить выполнение к реальной среде.
Для запуска тестов нам потребуется полноценная тестовая инфраструктура. В пайплайне поднимаются следующие контейнеры.
fly-broker — контейнер состоит из:
PostgreSQL
Kafka
запущенное приложение fly-broker
mock-service — контейнер включает:
запущенный http-сервис, который эмулирует поведение внешнего сервиса avia-service
fly-broker-tests:
контейнер, в котором выполняется запуск тестов
Сетевая конфигурация
Все контейнеры объединяются в единую docker-сеть, что позволяет полностью изолировать тестовое окружение и обращаться к тестам по имени контейнера.
Такой подход делает запуск тестов:
независимым от тестовой инфраструктуры
пригодным для локального запуска через docker-compose
Подробная реализация описана в Dockerfile и docker-compose.yml (ссылки приводятся в репозитории).
Настройка .gitlab.ci.yml
Интеграционные тесты выполняются внутри джобы integration-tests.
Что происходит в самой job:
Создается единая сеть fly-broker-network, к которой будут подключаться контейнеры
Поднимаются контейнеры fly-broker, mock-service, fly-broker-tests
Выполняется проверка healthcheck: проверяем подключение к БД, доступность кафки, готовность приложения
Сохранение allure-артефактов с тестами
Джоба допускает падение
before_script: - docker network create fly-broker-network || true script: - docker-compose up -d --build - | echo "Waiting for fly-broker app (migrations + gRPC) to be ready..." for i in $(seq 1 60); do status=$(docker inspect fly-broker-app --format '{{.State.Health.Status}}' 2>/dev/null || echo "starting") [ "$status" = "healthy" ] && echo "Broker is ready." && break [ $i -eq 60 ] && echo "Broker did not become healthy in time." && exit 1 sleep 2 done - echo "Starting integration tests (fly-broker-tests)..." - cd fly-broker-tests && docker-compose build --no-cache tests && docker-compose run --rm tests artifacts: when: always paths: - fly-broker-tests/tests/allure-results after_script: allow_failure: true
Публикация Allure-отчета
Формирование Allure-отчета выполняется внутри джобы Allure-report:
allure-report: stage: allure needs: [integration-tests] script: - mkdir -p fly-broker-tests/tests/allure-report - allure generate fly-broker-tests/tests/allure-results --clean -o fly-broker-tests/tests/allure-report - echo "Allure-report link - https://${CI_PROJECT_NAMESPACE}.${CI_PAGES_DOMAIN}/-/${CI_PROJECT_NAME}/-/jobs/${CI_JOB_ID}/artifacts/fly-broker-tests/tests/allure-report/index.html" artifacts: when: always paths: - fly-broker-tests/tests/allure-report allow_failure: false
CI_PROJECT_NAMESPACE, CI_PAGES_DOMAIN, CI_PROJECT_NAME, CI_JOB_ID — стандартные предопределенные переменные Gitlab, про них можно почитать по этой ссылке.
Пример ссылки, которая будет сформирована: https://dimyych_02-group.gitlab.io/-/fly-broker/-/jobs/13491558366/artifacts/fly-broker-tests/tests/allure-report/index.html
Заключение
В этой статье я рассказал про автоматизацию тестирования на Go и как это проектировать с нуля и интегрировать в CI/CD.
Переход на Go дал не только технические преимущества, но и организационный эффект: тесты перестали жить отдельно от разработки. Один язык, общие модели, понятный и быстрый CI сократили время поддержки и уменьшили риск архитектурного рассинхрона.
Ссылки
Полезные ссылки по фреймворку Allure-go:
Спасибо за внимание!