Как создать систему backend-тестов на Golang
- среда, 26 ноября 2025 г. в 00:00:09
Привет, Хабр! Меня зовут Александр Кувакин, я backend-инженер в команде Engineering Excellence в Авито. В этой статье разберём, как backend-разработчикам выстраивать систему тестов на бэкенде и разберём основные проблемы. Речь пойдет прежде всего о тестах, которые проверяют бизнес-логику.
Проблем с тестированием у backend-разработчиков хватает: кто-то считает, что тесты отнимают много времени, и не понимает, зачем они вообще нужны; у кого-то нет мотивации их писать; кто-то ограничивается unit-тестами ради красивого процента покрытия, а кто-то просто откладывает всё «на потом».
Из-за всего этого сервисы постепенно теряют качество и усложняют поддержку.
Попробуем разобраться с данными проблемами:
определим, как выстраивать систему тестов и зачем;
разберём, как сделать так, чтобы тесты не отнимали время, а экономили его;
рассмотрим тесты, которые пишут backend-разработчики: unit, интеграционные, api-тесты;
разберем тесты на примерах, определим плюсы и минусы каждого вида;
найдем лучшие способы для внедрения тестов.
Backend бывает разный – именно поэтому адекватные тестовые системы к нему тоже бывают разные. В данной статье остановимся на стандартном микросервисе на Golang, с кодом со слабой связанностью и хранилищем в виде реляционной БД. Полагаем, что в сервисе присутствует важная бизнес-логика.
Disclaimer: выводы, в целом, применимы к любым backend-сервисам.

Одной из основных причин разработки единой системы тестов является переход к непрерывным деплоям (Continuous Deployment или CD). В данной модели мы отказываемся от сбора релизных веток, долгих регрессионных тестов, багфиксов – и переходим к выпуску обновлений по мере их поступлений, что сильно снижает время выход на рынок (Time to Market или TTM) проектов.
Другими словами: автотесты должны помочь быстрее и стабильнее выкатывать новые фичи.
Так же грамотно выстроенная система тестов – это основное средство достижения качества ПО и основной инструмент QA.
CD требует наличия высокой автоматизации пайплайнов CI/CD. Например, для интеграционных тестов требуется возможность подключить контейнер с тестовой базой данных в пайплайне. Переход к CD невозможен без выстроенной тестовой системы на бэкенде, поскольку именно там заложена бизнес-логика, а тесты более высокого уровня более дорогие в разработке и поддержке и не способны полностью покрыть логику на бэкенде.
По сути, система тестов – это набор правил: где, когда и как писать автотесты, чтобы избежать споров на ревью и выстроить устойчивую практику в команде.
К такой системе можно предъявить несколько ключевых требований:
Максимальное покрытие бизнес-логики.
Основная цель — покрывать именно бизнес-логику на уровне репозитория. Верхнеуровневые e2e-тесты хотя и полезны, но дороже в разработке и не всегда отлавливают ошибки глубоко в коде.
Эффективность.
Система тестов не должна удорожать и тормозить разработку. Напротив — со временем она должна снижать TTM, ускоряя релизы и повышая надёжность за счет СD.
Тесты как документация.
Всю логику и «костыли» сложно вынести в отдельную документацию – гораздо эффективнее оформить их в виде читаемых тестов. Это сильно ускоряет онбординг новых разработчиков.
Развитый инструментарий.
Все автотесты должны запускаться одной командой в локальном окружении. Важно иметь возможность отказаться от ручного тестирования, изучать код через дебаг тестов и при необходимости генерировать данные, используя тесты.
Пишем тесты сразу.
Максимальная эффективность тестов достигается когда они пишутся сразу, тестируя новую функциональность. Мало смысла и мотивации писать тесты на работающий и отлаженный продукт, когда мы все баги уже отловили руками.
Каждая система выстраивается, исходя из здравого смысла, и для разных проектов она может быть разной.
Конечно, можно игнорировать написание тестов или писать бесполезные тесты ради процента покрытия. Однако в таком случае рано или поздно возрастает технический долг, переход к CD становится невозможным и навсегда проседает TTM.
Помимо этого, невозможно серьёзно менять продукт – рефакторинг становится рискованным и долгим. Легче будет написать новый продукт с нуля, чем вносить изменения в старый проект.
Да, внедрение тестов потребует времени и усилий. Многие пользуются этим утверждением, чтобы не заниматься грамотным тестовым покрытием.
Но это временно: в среднесрочной перспективе грамотная система тестов существенно сокращает время разработки. Опыт создания грамотной тестовой системы универсален – последующие внедрения будут гораздо быстрее.
Чаще всего QA-инженеры подключаются на более поздних этапах и пишут верхнеуровневые е2е-тесты – мы их рассматривать не будем. Сосредоточимся на тех, что создаются разработчиками и разберем один из вариантов классификации.
В Golang (и других языках) часто код состоит из небольших функций, связанных между собой интерфейсами. Unit-тесты проверяют изолированные участки кода с подменой зависимостей на моки – это базовый уровень автоматического тестирования.
Это интеграционный тест, где в каждом кейсе проверяется одиночный вызов API-обработчика: HTTP, gRPC, обработчик очереди и др.
Мы, backend-разработчики, отдаём в пользование ручки API – логично покрыть их тестами, чтобы спать спокойно.
Что делать с базой данных – моки или реальная БД?
Здесь важно правило: реляционная БД не мокается.
Вместо этого в пайплайне поднимается реальная тестовая база. Почему? Писать моки для вызова базы данных = фактически писать эмулятор БД, что убивает соотношение «цена/качество».
Однако тестируется не интеграция с БД, а функционал API в целом. Тест пишется внутри репозитория, а все внешние зависимости мокаются. Сервис или API-обработчик выступает как черный ящик: мы подаем запрос и получаем ответ.
Перед тестом необходимо инициализировать нужные сущности в базе данных.
Это «ленивая», но очень эффективная форма тестирования: в одном кейсе дёргается несколько API-ручек, реализующих реальный пользовательский сценарий. Как правило такие тесты уместны в системах, где нужно последовательно менять состояние сущности.
Например, в интернет-магазине алгоритм таков:
положить товар в корзину;
оформить заказ;
оплатить заказ и т. д.
В таком случае тестируются не только отдельные ручки, но и взаимодействие между ними.
Для лучшего понимания рассмотрим примеры разных видов тестов. Как правило с unit-тестами проблем не возникает, поэтому остановимся на интеграционных.
Возьмём для примера простое приложение: сервис заказов в интернет-магазине “N”, архитектура – микросервисная.
Возьмем сервис сheckout, ответственный за создание корзины и оформление заказа. Кроме него есть ещё два связанных сервиса:
loms – отвечает за учёт товаров и логиcтику;
product-service – предоставляет информацию о товарах и их ценах.
С4 диаграмма выглядит так:

Сценарий оформления заказа можно увидеть на схеме:

Так как есть последовательный бизнес-сценарий, логично проверить его целиком.
Для этого напишем сценарный тест, который пошагово выполняет все действия пользователя.
func (s *ServiceSuite) TestScenario() {
var userId int64
var err error
s.Run("Success - добавить товары в корзину, оформить заказ", func() {
/*
Инициализируем БД и переменные
*/
// создаём юзера в БД
userId, err = seed.SeedUser(s.ctx, s.Pool)
s.Require().Nil(err)
// генерируем элементы корзины
var items = seed.GenerateItems(userId, 5, 10)
/*
Положим в корзину первый товар
*/
// external mocks
s.LomsClient.EXPECT().
Stocks(mock.Anything, &loms.StocksRequest{Sku: int32(items[0].Sku)}).
Return(&loms.StocksResponse{Stocks: []*loms.StockItem{{WarehouseId: 1, Count: 40}}}, nil).
Once()
// action
_, err = s.Grpc.AddToCart(context.Background(), items[0])
s.Require().Nil(err)
/*
Положим в корзину второй товар
*/
// external mocks
s.LomsClient.EXPECT().
Stocks(mock.Anything, &loms.StocksRequest{Sku: int32(items[1].Sku)}).
Return(&loms.StocksResponse{Stocks: []*loms.StockItem{{WarehouseId: 1, Count: 40}}}, nil).
Once()
// action
_, err = s.Grpc.AddToCart(context.Background(), items[1])
s.Require().Nil(err)
/*
Оформим заказ
*/
// external mocks
s.LomsClient.EXPECT().
CreateOrder(mock.Anything, &loms.CreateOrderRequest{User: userId, Items: []*loms.OrderItem{{Sku: items[0].Sku, Count: items[0].Count}, {Sku: items[1].Sku, Count: items[1].Count}}}).
Return(&loms.CreateOrderResponse{OrderId: 10}, nil).
Once()
s.ProductClient.EXPECT().
GetProduct(mock.Anything, &product.GetProductRequest{Sku: items[0].Sku, Token: "testtoken"}).
Return(&product.GetProductResponse{Name: "Product 1", Price: 1000}, nil).
Once()
s.ProductClient.EXPECT().
GetProduct(mock.Anything, &product.GetProductRequest{Sku: items[1].Sku, Token: "testtoken"}).
Return(&product.GetProductResponse{Name: "Product 2", Price: 2000}, nil).
Once()
// action
_, err = s.Grpc.Puchase(context.Background(), &checkout.PuchaseRequest{User: userId})
s.Require().Nil(err)
/*
Проверим что корзина пуста
*/
cart, err := s.Grpc.ListCart(context.Background(), &checkout.ListCartRequest{User: userId})
s.Require().Nil(err)
s.Require().Equal(len(cart.Items), 0)
})
}
Тест состоит из последовательного вызова API-ручек и легко читается. Сам код и комментарии заменяют диаграмму последовательностей приведенную выше, хорошо документирует бизнес логику.
Структура каждого шага:
Мокаем вызовы внешних сервисов.
Делаем вызов API.
Проверяем, что состояние базы или ответ соответствует ожиданиям.
Если система использует очереди, воркеры или кроны, их тоже можно встроить в эту цепочку.
Сценарный тест покрывает бизнес-цепочку, но есть ручки, которые требуют отдельной проверки.
Например, GET /ListCart – возвращает товары в корзине с фильтрацией. Прогонять сценарий на каждый фильтр неразумно, поскольку создает много вычислительных операций и время выполнения тестов сильно вырастет.
Значит, пишем изолированный API-тест.
func (s *ServiceSuite) TestApi() {
var userId int64
var err error
s.BeforeEach(func() {
// создаём юзера в БД
userId, err = seed.SeedUser(s.ctx, s.Pool)
s.Require().Nil(err)
})
s.Run("Success - получить корзину", func() {
/*
Инициализируем БД и константы
*/
var items = seed.GenerateItems(userId, 5, 10)
err = seed.SeedCart(s.ctx, s.Pool, userId, []seed.CartItem{
{Sku: uint32(items[0].Sku), Count: uint16(items[0].Count)},
{Sku: uint32(items[1].Sku), Count: uint16(items[1].Count)},
})
s.Require().Nil(err)
/*
Мокаем внешние вызовы
*/
s.ProductClient.EXPECT().
GetProduct(mock.Anything, &product.GetProductRequest{Sku: items[0].Sku, Token: "testtoken"}).
Return(&product.GetProductResponse{Name: "Product 1", Price: 1000}, nil).
Once()
s.ProductClient.EXPECT().
GetProduct(mock.Anything, &product.GetProductRequest{Sku: items[1].Sku, Token: "testtoken"}).
Return(&product.GetProductResponse{Name: "Product 2", Price: 2000}, nil).
Once()
/*
Вызов API
*/
cart, err := s.Grpc.ListCart(context.Background(), &checkout.ListCartRequest{User: userId})
s.Require().Nil(err)
/*
checks
*/
for _, item := range cart.Items {
obj, ok := lo.Find(items, func(i *checkout.AddToCartRequest) bool {
return i.Sku == item.Sku
})
s.Require().Equal(ok, true)
s.Require().Equal(obj.Count, item.Count)
}
})
s.Run("Success - получить корзину с фильтром по productName", func() {
// ...
})
s.Run("Fail - неверный пользователь", func() {
// ...
})
}
В этом примере перед каждым тестом создаются сущности в базе (SeedCart). В отличие от сценарного теста, здесь состояние в БД не создается предыдущими вызовами API.
Чтобы не дублировать код, используется хук s.BeforeEach, который выполняется перед каждым кейсом и создает юзера в БД.
Исходя из примеров оценим плюсы и минусы разных подходов.
Вид теста | Плюсы | Минусы |
Unit-тест | 1. Простота и дешевизна написания (если мало моков). 2. Не требуют сложной настройки пайплайнов, быстрый старт. 3. Легко генерируются нейросетью. | 1. Часто тестирует строчки кода, а не бизнес-логику, так как бизнес-логика нередко распределена на несколько функций/сервисов. 2. Сильно связан с внутренней реализацией: изменения в коде требуют рефакторинг тестов. 3. Если после изменений тест ломается, часто нет возможности понять – сломалась ли бизнес-логика или просто нужен рефакторинг. Таким образом тест часто не выполняет свою главную функцию. 4. Низкая эффективность: несколько строк теста на одну строку кода. |
API-тест | 1. В отличие от unit-тестов тестирует все стыки функций и взаимодействие сервиса с БД. 2. Практически независим от внутреннего кода: рефакторинг не ломает тесты. 3. API документируется через тесты: можно понять логику API без кода, а тесты генерируют данные. | 1. Требует настройки CI/CD: конфиги, БД, миграции, глобальных моков и т.д. 2. Перед вызовом API требует создания некоторого состояния в БД, что занимает время (особенно на этапе внедрения тестов). 3. Работают медленнее и параллелятся сложнее, чем unit-тесты. |
Сценарный API-тест | 1. Все плюсы API-тестов. 2. Тестирует слаженность работы последовательных вызовов API. 3. Не требует инициализации сущности в БД между вызовами – они инициализированы предыдущими вызовами, что ускоряет создание теста. 4. Документирует бизнес-логику приложения в целом, отражает порядок вызова API-хэндлеров. 5. Лучшее соотношение «цена/качество» – небольшой по объёму тест покрывает множество строчек кода. | 1. Все минусы API-тестов. 2. Нерационально тестировать негативные сценарии: делать цепочку API-вызовов ради каждого кейса очень дорого. |
Теперь, когда мы разобрали типы тестов и их особенности, перейдём к тому, как выстроить систему тестов на практике.
Сделаем это на примере сервиса, который разбирали ранее. Напомню, интернет-магазин “N”, архитектура микросервисная. Представим, что нужно быстро разработать MVP backend-приложения.
Времени на идеальные тесты с 99%-покрытием нет, но важно собрать систему, которая даст баланс между скоростью, качеством и затратами и удовлетворит все требования.
Разделим функции по категориям, чтобы понять, где unit-тесты действительно полезны.
Функции-прослойки. В них мало бизнес-логики – они просто вызывают другие функции или внешние зависимости через интерфейсы. Составляют большую часть в современном сервисе на Go. Писать для них изолированные unit-тесты с моками неэффективно: лучше проверить эти зависимости интеграционно.
Функции со сложной бизнес-логикой. Здесь юнит-тесты эффективны. Можно применять TDD-подход. Однако, таких функций обычно немного.
Функции репозитория. Эти функции выполняют SQL-запросы. Их логично тестировать с реальной базой данных, а не через моки.
Итого: пишем unit-тесты на функции со сложной бизнес-логикой. Именно в таких случаях они эффективны.
Сценарные API-тесты – самый выгодный вариант по соотношению «цена/качество». Их стоит писать везде, где применимы, тем самым документируя бизнес-логику. Напомню, что их выгода в том, что не нужно инициализировать состояние в БД перед каждым вызовом API, а сами тесты гарантируют слаженную работу API в целом.
Данные тесты пишем по остаточному принципу – для всего, что не покрыто сценарными тестами, но требует проверки.
Например:
GET-ручки с множеством фильтров. Прогонять целый сценарий для каждого фильтра слишком дорого – пишем отдельные API-тесты.
Неуспешные вызовы API (по той же причине).
Теперь, когда понятно, какие тесты писать и где, важнее всего задать принципы, по которым система будет реально работать. Здесь не будет TDD – не везде этот подход имеет смысл.
Вот 4 базовых принципа:
Тесты пишутся сразу, до того как мы отдебажим прод руками.
Ретроспектива. На каждый найденный баг обязательно пишем тест, а не просто его фиксим. Это помогает понять эффективность и ограничения имеющейся системы тестов.
Никаких ручных тестов локально. Вся разработка и отладка – через интеграционные API-тесты. Поначалу будет тяжело, но с развитием хелперов и готовых тестов станет проще чем ручное тестирование. Особенно это помогает при длинных сценариях: достаточно скопировать готовый тест или добавить параметры.
Ответственность. Разработчик несёт ответственность за автотесты. Не нужно перекладывать ответственность на QA, так как только разработчик сможет написать эффективные тесты на уровне кода репозитория, не откладывая это на потом.
Система тестов должна быть частью разработки. Она существует не ради покрытия, а ради уверенности в качестве кода.
Классическая пирамида тестирования выглядит примерно так:

Если взглянуть на наш пример, то вырисовывается некоторый ромб тестирования, про который уже много раз писали на Хабре (1, 2 и др.):

Основную роль в такой системе играют API-тесты, и это абсолютно логично. Тестировать API целиком гораздо полезнее, чем «кусочно» через unit-тесты. Второй способ не гарантирует, что API в целом будет работать корректно.
Unit-тесты мы пишем только для функций или модулей со сложной бизнес-логикой — там, где это действительно дешевле, чем гонять их через API.
Но таких функций немного: некоторые принципы программирования говорят, что сложные функции нужно делить на более простые. В итоге бизнес-логика оказывается размазана по нескольким функциям или сервисам, а для её тестирования нужны более высокоуровневые тесты.
Проблема пирамиды тестирования в том, она помечает unit-тесты как некоторый фундамент тестовой системы и называет их «самыми дешёвыми», что не соответствует действительности.
Можно придумать тестовую систему, состоящую только из API-тестов или только из e2e (бывают и такие). Однако, система, основанная только на unit-тестах, никогда не будет полноценной.
Unit-тесты хорошо подстраиваются под метрики: процент покрытия растёт, мутационное тестирование проходит успешно. Но есть один нюанс – все эти метрики никак не отражают эффективность системы тестов, она может оставаться нулевой.
Поэтому пирамида тестирования, как дефолтная система тестов на backend, часто используется не по назначению – для обоснования бесполезного покрытия unit-тестами.
Разберём, как технически устроить запуск и организацию интеграционных тестов на Golang. Основной код есть по ссылке, здесь – основные моменты.
Для организации тестов удобно использовать testify/suite. Это основной пакет, который содержит в себе набор хуков и методов.
Пример:
type ServiceSuite struct {
suite.Suite
// global variables
ctx context.Context
Pool *pgxpool.Pool
// server - то, что будем тестировать
Grpc checkout.CheckoutV1Client
// external mocks
LomsClient *mock_loms_v1.LomsV1Client
ProductClient *mock_product_v1.ProductServiceClient
// hooks
beforeEach func()
}
ServiceSuite.Grpc – это инстанс нашего сервиса, который тестируется как «чёрный ящик». Как вариант – можно не делать черный ящик, а тестировать API ручки в отдельности.
Хук из пакета testify/suite, срабатывает один раз перед прогоном тестов – в нём инициализируются БД и конфиг.
Хук SetupSubTest срабатывает перед каждым тест-кейсом (перед s.Run).
Основные моменты:
1. Интеграционные тесты в Go запускаются последовательно, поэтому я выставляю лок pg_advisory_lock в postgres перед каждым запуском тест кейса, а потом снимаю его, чтобы запуск интеграционных тестов был строго последовательным.
// устанавливаем лок, чтобы тесты выполнялись последовательно, дабы избежать конфликтов
if _, err := s.Pool.Exec(s.ctx, "SELECT pg_advisory_lock(0)"); err != nil {
s.T().Fatal(err)
}
// cleanup - close connections and release lock
s.T().Cleanup(func() {
if _, err := s.Pool.Exec(s.ctx, "SELECT pg_advisory_unlock(0)"); err != nil {
s.T().Fatal(err)
}
})
2. Собираю сервис, инициализирую моки внешних зависимостей и прокидываю моки в качестве зависимостей. Приходится так делать из-за невозможности скинуть счётчики моков после вызова тест-кейса, поэтому необходимо «пересобирать» всё.
// external mocks
s.LomsClient = mock_loms_v1.NewLomsV1Client(s.T())
s.ProductClient = mock_product_v1.NewProductServiceClient(s.T())
// init server
server, _ := server.Server(server.Externals{Log: log, Metrics: nil, LomsClient: s.LomsClient, ProductClient: s.ProductClient, PgPool: s.Pool})
…По идее, такой код должен выдать ошибку, если есть внешний вызов и мы не сделали мок на него.
3. Очищаю таблицы в базе данных после прогона каждого тест-кейса, чтобы не делать это в каждом тест-кейсе:
// зачищаем таблицы от предыдущих кейсов
s.cleanUpTables(getTableNames()...)Это самописный хук, который срабатывает перед каждым тест-кейсом и задается в самом тесте (см. пример API-теста). По аналогии можно реализовать и хук AfterEach.
В данной статье я подробно разобрал разные виды тестов, их применение, а также плюсы и минусы.
Зафиксируем основные выводы:
Грамотная система тестов помогает существенно повысить качество сервиса, понизить TTM и выстроить эффективное взаимодействие между QA и разработчиками.
API-тесты хорошо работают в качестве ядра тестовой системы, а unit-тесты в качестве инструмента точечной проверки сложных участков кода.
Тестовые системы выстраиваются индивидуально под особенности сервиса и проекта (в данной статье один из понятных и универсальных подходов).
Надеюсь, что статья поможет закрыть основные проблемы, связанные с написанием тестов у backend разработчиков.
А какие подходы вы используете при разработке системы автотестов? Делитесь мнением в комментариях.
А если хотите вместе с нами адаптироваться в мире стремительно развивающихся технологий — присоединяйтесь к командам. Свежие вакансии есть на нашем карьерном сайте.