
Всем привет! Меня зовут Дима, я 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:
Спасибо за внимание!
Комментарии (2)

dim_dimyych_02 Автор
06.05.2026 05:09Спасибо за обратную связь!
С отсутствием ретраев согласен. Это действительно один из недостатков данного фреймворка, который приходится обходить кастомными решениями.
Что касается привязки к конкретному инструменту репортинга, в данном случае — Allure TestOps, — мне сложно однозначно назвать это недостатком, учитывая, что за всё время моей работы я нигде не сталкивался с альтернативами.
А про фреймворк Axiom я обязательно почитаю, спасибо большое!
cashloan
Спасибо за статью - опыт интересный и знакомый, как в целом у любой команды, которая решает внедрить автотесты на Go в микросервисной архитектуре. Потому что тестовых фреймворков тут не так уж и много...
Хочу поделиться своим опытом, потому что мы прошли очень похожий путь. Изначально тоже смотрели в сторону allure-go от ozontech, попробовали на нем MVP и довольно быстро от него отказались.
Что не понравилось по порядку:
provider.T вместо testing.T. Это полная подмена стандартного интерфейса. Ты теряешь совместимость с нативным
testing, сtestify, с любыми хелперами, которые ожидают*testing.T. По сути, ты начинаешь писать тесты "под фреймворк", а не "на Go". Плюс не нравится, что везде приходится таскать этотprovider.TПараметризация через naming conventions. Функция должна начинаться с
TableTest, поле сьюта сParam, структура данных должна совпадать по имени - и все это на рефлексии. Одна опечатка, и тест молча не запускается. Для Go, где принято быть explicit, это ощущается чужеродно.Нет встроенных retries. В интеграционных тестах flaky - это база. В allure-go ретраев нет, приходится городить обертки руками.
Нет плагинов. Хочешь добавить логирование, метрики, покрытие, фильтрацию по тегам, любое кастомное поведение - пиши руками, вшивай в тесты, строй свой фреймворк над фреймворком. Нет архитектуры расширения
Но все это еще можно пережить. Главная проблема, которую мы увидели в другом.
В allure-go смешаны две принципиально разные вещи: тестовый фреймворк и Allure-репортинг. И они смешаны так, что неразрывны.
provider.Tодновременно и управляет тестовым движком, и формирует Allure-отчет. Шаги, метаданные, сьюты, по факту все завязано на AllureИ по сути, на практике это означает: если в какой-то момент тебе не нужен Allure (а далеко не все им пользуются), то на этом фреймворк заканчивается. Ты не можешь взять "только execution engine" без Allure. Ты не можешь переехать на другой репортер (QASE, TestIT, просто JSON). Ты не можешь отключить репортинг и оставить фреймворк. И это выглядит как "фиаско братан". Как будто изначально в нем не было это продумано
А у нас именно такая ситуация и случилась - часть команд использует Allure, часть нет, часть планирует переезжать. И завязываться на конкретный репортер на уровне test runtime - это архитектурный тупик.
В итоге мы пришли к другому подходу. Я долго рылся и искал нормальное решение, аля pytest / junit like. Нашел только вот это https://github.com/Nikita-Filonov/axiom. Также советую посмотреть вот эту статью https://habr.com/ru/articles/975478/ , по сути там автор подробно разбирает и проблему, и решение. Если коротко:
*testing.Tостается нетронутым,testifyработает как естьAllure - это плагин, который подключается одной строкой и так же отключается. Тесты про него не знают
Есть встроенные retries с изоляцией попыток
Fixtures с lazy-инициализацией и автоматическим cleanup, это прям божественная фича. Вроде пишешь тесты на go, а такое ощущение, что на pytest :)
Параметризация через типизированные
GetParams[T], без магииПлагинная архитектура - можно расширять что угодно, не трогая тесты. Пока у нас получилось встроить все через плагины, репортинг, запуск по тегам, логи, аналитику, кастомные нейминги для тестов
В целом, Axiom решает те же задачи, что и allure-go, но архитектурно он сильно чище: execution engine отделен от репортинга, и ты не попадаешь в ситуацию, когда смена репортера == переписывание всех тестов