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

  1. Создается единая сеть fly-broker-network, к которой будут подключаться контейнеры

  2. Поднимаются контейнеры fly-broker, mock-service, fly-broker-tests

  3. Выполняется проверка healthcheck: проверяем подключение к БД, доступность кафки, готовность приложения

  4. Сохранение allure-артефактов с тестами

  5. Джоба допускает падение

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

Спасибо за внимание!

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