Автоматизация тестирования на Go: стратегия и реализация с нуля

Страницы:  1

Ответить
 

Professor Seleznov


pic
Всем привет! Меня зовут Дима, я 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)
Схема взаимодействия выглядит так:
pic
Эта схема показательна тем, что сочетает все ключевые источники сложности в тестировании микросервисов: синхронные вызовы между сервисами, работу с БД и асинхронную доставку событий через 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
pic
Интеграция в 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:  Kafka-go
Go-Restful
Спасибо за внимание!-Источник
 
Loading...
Error