Всем привет! Меня зовут Александр Голубь, и я пишу на Go уже 7 лет. Когда я только начинал, казалось, что юнит-тестов достаточно. Функции проверены, сборка зелёная — можно релизить. Но первый же боевой проект показал, что это иллюзия. В продакшене всё падает, хотя юниты сияют зелёным. Почему? Потому что реальный сервис — это не только код, но и PostgreSQL, Redis, Kafka, внешние API. Данные проходят через цепочку зависимостей, и любая несовместимость ломает систему. Юнит-тесты этого не ловят. Тут нужны интеграционные.

Александр Голубь
Руководитель группы разработки в Сима-ленде, соавтор курса по Golang в Нетологии
Проблема в том, что интеграционные тесты редко пишут по умолчанию. Многие команды откладывают их на потом, считая лишними затратами. На деле именно они показывают, как микросервисы разговаривают друг с другом, как ведут себя транзакции в реальной базе и выдержит ли очередь сообщений нагрузку. И если таких проверок нет, то команда становится заложником случайностей: что-то работает на локальной машине, но падает в CI или, что хуже, у клиента.
Именно поэтому разговор об интеграционных тестах в Go — это не про теорию, а про выживание проекта. Они помогают убрать флаки, снизить хаос и превратить CI в надёжный инструмент, а не лотерею.
Почему Go подходит для интеграционных тестов
В Go нет магии вроде @Transactional
или готовых фикстур, как в Java или Python. Контейнеры, миграции и очистку приходится поднимать руками. Язык даёт минималистичный, но мощный инструментарий из коробки — стандартный пакет testing
и удобную работу с ошибками. Всё остальное выстраивается вокруг этого. В отличие от фреймворков, которые скрывают детали за аннотациями или DSL, в Go вы видите весь процесс: где именно поднимается PostgreSQL, как накатываются миграции, каким образом очищается база между тестами. Это может казаться дополнительной работой, но именно такая явность делает тесты предсказуемыми, прозрачными и надёжными. Если база не готова, это видно. Если данные не сохранились — тоже видно.
Есть и другие плюсы:
Единый инструмент запуска. Всё гоняется через
go test
, без внешних раннеров и конфигурационных слоёв.Прозрачный параллелизм. С помощью
t.Parallel()
легко масштабировать тесты, если каждое окружение изолировано.Гибкость инфраструктуры. Вы сами решаете, использовать ли dockertest, Testcontainers или встроенные mock-серверы. Нет жёсткой привязки к конкретному фреймворку.
Читаемость вдолгую. Тесты в Go пишутся так же, как и остальной код: без скрытых генераторов, аннотаций и хитрых обёрток. Любой новичок в команде открывает файл и сразу видит логику проверки.
Именно поэтому интеграционные тесты на Go зачастую живут дольше и устойчивее, чем в языках вроде Python, JavaScript или Ruby, где для тестов обычно есть десятки фреймворков и обёрток, которые «делают автоматизацию удобнее». На старте писать тесты в них быстрее: много готовых инструментов, много сокращений. Но минус — такие тесты потом чаще ломаются при апгрейде библиотек, зависят от магических аннотаций, сложных моков и костылей. Так что с Go вы платите временем на старте, но экономите его на поддержке в будущем.
Главная боль интеграционных тестов — флаки
Флаки убивают доверие к тестам. Представьте: билд падает не из-за бага, а потому что база не успела подняться или тесты пересеклись по данным. Разработчики видят красный пайплайн и уже не думают о проблеме в своём коде — скорее, решают, что CI опять шалит. Постепенно вырабатывается иммунитет к красному: реальные ошибки тонут в шуме и критический баг может неделю висеть в мастере, пока все уверены, что дело в тестах.
Кроме того, флаки напрямую бьют по скорости разработки. Время сборки растёт за счёт повторных прогонов, разработчики тратят часы на выяснение, «почему именно сейчас оно упало», а менеджеры начинают сомневаться в надёжности тестовой инфраструктуры. Итог — тесты перестают восприниматься как защита и превращаются в обузу.
Наконец, постоянные флаки разрушают культуру качества в команде. Если тесты считаются ненадёжными, то и мотивация писать новые падает. А дальше — прямой путь к регрессиям, которые можно было бы поймать заранее. Поэтому с флаками борются в первую очередь ради того, чтобы тесты реально защищали проект.
Далее рассмотрим типичные причины флаков.
1. База не готова
Контейнер запустился, но PostgreSQL ещё не принимает соединения. Решение: не time.Sleep
, а цикл с Ping + retry
. Если просто вставить time.Sleep(10 * time.Second)
, то иногда всё будет работать, а иногда — падать. База может подниматься и за 2 секунды, и за 20, в зависимости от нагрузки и железа.
func waitForPostgres(dsn string, timeout time.Duration) error {
deadline := time.Now().Add(timeout)
ticker := time.NewTicker(500 * time.Millisecond)
defer ticker.Stop()
var lastErr error
for range ticker.C {
if time.Now().After(deadline) {
break
}
db, err := sql.Open("postgres", dsn)
if err != nil {
lastErr = err
continue
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
pingErr := db.PingContext(ctx)
cancel()
_ = db.Close()
if pingErr == nil {
return nil
}
lastErr = pingErr
}
return fmt.Errorf("Postgres не поднялся за %s: %w", timeout, lastErr)
}
Функция waitForPostgres
решает задачу: дождаться готовности PostgreSQL к приёму соединений в пределах заданного тайм-аута.
Она запускает цикл с тикером — каждые 500 мс.
На каждой итерации пытается открыть соединение и выполнить
PingContext
с отдельным тайм-аутом в 2 секунды.Если база отвечает, функция сразу возвращает
nil
.Если время ожидания истекло, возвращается ошибка с последней причиной сбоя.
В отличие от time.Sleep
, здесь проверяем фактическую готовность PostgreSQL и выходим ровно тогда, когда он стал доступен (или когда тайм-аут вышел).
2. Грязные данные
В интеграционных тестах с базой данных часто возникает так называемое грязное состояние. Например, один тест создал запись в таблице заказов, а другой ожидал, что таблица будет пустой. Если они запускаются параллельно, второй тест то проходит, то падает — всё зависит от того, успел ли первый уже что-то записать. Из-за такой непредсказуемости тест считается флейковым (flaky, или нестабильным, флаки-тестом) и доверие к результатам быстро теряется.
Проблему можно решить двумя подходами:
Ограничение теста транзакцией с откатом (rollback) |
Отдельная база для каждого теста |
• Каждый тест запускается в своей транзакции. • В конце выполняется ROLLBACK, и база возвращается к чистому состоянию. • Плюс: быстро, не требует пересоздания базы, работает в большинстве случаев. |
• Каждый тест получает свою пустую базу, часто через миграции и Docker. • Гарантированно исключает пересечения между тестами. • Минус: медленнее, требует больше ресурсов. |
Вот, например, возможный код для отката транзакции после завершения теста:
func runInTx(t *testing.T, fn func(tx *sql.Tx)) {
t.Helper()
tx, err := db.Begin()
if err != nil {
t.Fatalf("Не удалось начать транзакцию: %v", err)
}
defer func() { _ = tx.Rollback() }()
fn(tx)
}
func TestCreateUser(t *testing.T) {
runInTx(t, func(tx *sql.Tx) {
_, err := tx.Exec("INSERT INTO users(name) VALUES($1)", "Alice")
if err != nil {
t.Fatalf("insert: %v", err)
}
var count int
if err := tx.QueryRow("SELECT COUNT(*) FROM users").Scan(&count); err != nil {
t.Fatalf("query: %v", err)
}
if count != 1 {
t.Errorf("Ожидали 1 пользователя, а получили %d", count)
}
})
}
3. Асинхронщина
Когда тестируем системы с асинхронной доставкой событий — Kafka, Redis Pub/Sub, RabbitMQ и подобные, — сообщения не приходят мгновенно. Один тест может отправить событие, но потребитель получит его через десятки или сотни миллисекунд. Если проверять результат сразу, тест будет падать, хотя в реальности система работает правильно. Это классическая ситуация race condition в интеграционных тестах. Как предлагаю действовать:
вместо того чтобы требовать «условие выполнено прямо сейчас», сказать: «дай системе время, но не больше N секунд»;
использовать функцию
eventually
, которая вызывает проверку (cond
) каждые 200 мс и выходит сразу, как только та вернётtrue
.
Если за отведённое время условие так и не выполнилось, тест падает с ошибкой. Ниже — пример кода с eventually
:
func eventually(t *testing.T, cond func() bool, timeout time.Duration) {
t.Helper()
ticker := time.NewTicker(200 * time.Millisecond)
defer ticker.Stop()
timeoutC := time.After(timeout)
for {
select {
case <-ticker.C:
if cond() {
return
}
case <-timeoutC:
t.Fatalf("Условие не выполнено за %s", timeout)
}
}
}
func TestMessageDelivered(t *testing.T) {
sendMessage("hello")
eventually(t, func() bool {
msg, ok := tryReceiveMessage()
return ok && msg == "hello"
}, 5*time.Second)
}
4. Shared state
Когда тесты используют одно и то же окружение — например, одну БД, один контейнер с Kafka, один Redis, — они начинают непредсказуемо влиять друг на друга:
Данные пересекаются. Один тест вставил пользователя "Alice", другой ожидал пустую таблицу — и получил неожиданные результаты. Или два теста добавляют данные с одинаковым ключом, и один из них падает с ошибкой duplicate key.
Сбои из-за параллельности. Тесты запускаются одновременно (go test -parallel), и в то время как один удаляет данные, другой их читает. В результате получаем флейковые тесты: иногда зелёные, иногда красные.
Конфликты по ресурсам. Несколько тестов одновременно используют один и тот же топик Kafka или один Redis-канал. Сообщения утекают между тестами, и тесты получают чужие данные.
Неочищенное состояние. Один тест забыл очистить за собой данные или оставил транзакцию открытой. Следующие тесты стартуют не с нуля, и поведение становится непредсказуемым.
Изоляция окружения гарантирует, что каждый тест видит только свои данные и не зависит от соседей. Это делает тесты повторяемыми, надёжными и быстрыми для отладки. Например:
Можно выделить отдельный контейнер для каждого теста — как вариант, через dockertest. Так каждый тест получит свою свежую базу или брокера.
Использовать отдельную схему (schema) в одной базе — тесты не мешают друг другу, потому что работают в логически изолированных пространствах имён.
func TestMain(m *testing.M) {
pool, err := dockertest.NewPool("")
if err != nil {
log.Fatalf("docker pool: %v", err)
}
resource, err := pool.Run("postgres", "15", []string{
"POSTGRES_USER=test",
"POSTGRES_PASSWORD=secret",
"POSTGRES_DB=testdb",
})
if err != nil {
log.Fatalf("start postgres: %v", err)
}
pool.MaxWait = 2 * time.Minute
dsn := fmt.Sprintf("postgres://test:secret@localhost:%s/testdb?sslmode=disable", resource.GetPort("5432/tcp"))
if err := waitForPostgres(dsn); err != nil {
log.Fatalf("db not ready: %v", err)
}
db, _ = sql.Open("postgres", dsn)
code := m.Run()
_ = db.Close()
_ = pool.Purge(resource)
os.Exit(code)
}
func TestInsert(t *testing.T) {
_, err := db.Exec("CREATE TABLE IF NOT EXISTS users (id SERIAL PRIMARY KEY, name TEXT)")
if err != nil {
t.Fatalf("create table: %v", err)
}
_, err = db.Exec("INSERT INTO users(name) VALUES($1)", "Alice")
if err != nil {
t.Fatalf("insert: %v", err)
}
var count int
if err := db.QueryRow("SELECT COUNT(*) FROM users").Scan(&count); err != nil {
t.Fatalf("query: %v", err)
}
if count != 1 {
t.Errorf("Ожидали 1 пользователя, а получили %d", count)
}
}
Что здесь происходит:
В TestMain мы через
dockertest
поднимаем свежий контейнер с PostgreSQL.pool.Retry
крутит попытки подключиться, пока база не станет доступна.В
defer
гарантируем, что контейнер удаляется после завершения тестов.Каждый тест работает с чистым окружением и не мешает другим.
Таким образом мы получаем реалистичную интеграцию с PostgreSQL и при этом избегаем проблем c shared state (разделяемым состоянием).
Резюмируем
Вдолгую именно интеграционные тесты реально экономят компании деньги. Они ловят те баги, которые в проде вылились бы в простои, недовольных клиентов и срочные хотфиксы. Каждая такая история — это не только деньги на ветер, но и репутация, которую потом с трудом восстанавливают. Гораздо проще один раз отладить инфраструктуру для тестирования, чем потом объяснять бизнесу, почему сервис вдруг лёг.
Если завтра нужно резко адаптировать систему под нового клиента или регуляторные требования, команда не работает вслепую. Она знает: тесты покажут, где всё ломается, и это даёт конкурентное преимущество. Там, где без тестов компания тратит недели на ручные проверки, с тестами достаточно пары прогонов пайплайна.
Ну и про культуру. В командах, где есть привычка писать интеграционные тесты, инженеры быстрее принимают смелые технические решения. Не страшно экспериментировать или делать рефакторинг: тесты прикроют от регрессий. В итоге команда становится более зрелой и работает продуктивнее.
В Go интеграционные тесты — это честный и предсказуемый инструмент. Тут нет магии, язык подталкивает к работе с реальными процессами и окружением. Да, писать чуть дольше, зато потом код прозрачнее и поддержка проще. Вот эта честность и становится фундаментом зрелого инженерного процесса.
Какие практические приёмы разобрали в статье:
Один тест = одна база. В CI это медленнее, но надёжнее. Такой подход полностью убирает пересечения между тестами: каждый сценарий работает в своём изолированном окружении, данные не конфликтуют, и результат всегда воспроизводим. Вы получаете гарантию, что один упавший тест не заразит все остальные. Да, за это приходится платить временем на запуск контейнеров, но на практике эта цена куда меньше, чем стоимость отладки плавающих падений. Кроме того, отдельная база делает тесты более предсказуемыми в параллельном запуске. Если вы используете
t.Parallel()
, то без изоляции это почти всегда приводит к гонкам и грязному состоянию. Отдельная база решает проблему в корне: каждый тест работает так, словно он единственный.Транзакции с откатами. Если по каким-то причинам нельзя поднимать отдельную базу на каждый тест, второй по надёжности вариант — запускать тест внутри транзакции и всегда делать rollback. Это дешёвый способ гарантировать чистое состояние: база возвращается к исходной точке автоматически. Даже если тест упал посередине, откат всё подчистит.
Такой подход особенно удобен для тестов, которые часто пишут и запускают локально. Разработчик получает предсказуемый результат без лишних действий. А главное — не нужно вручную чистить таблицы или писать костыли сtruncate
: транзакция решает это за вас.Eventually
вместоsleep
. Жёсткиеsleep
— враги стабильных тестов. Они или тратят время зря, или не успевают дождаться события и валят тест. Гораздо надёжнее крутиться в цикле до тайм-аута и проверять условие. Так тест завершается сразу, как только событие произошло, а если не произошло — падает честно.
В результате ваши тесты становятся быстрее и стабильнее. Они перестают быть лотереей и начинают отражать реальное поведение системы. Этот приём особенно важен для асинхронных сценариев — например, при работе с Kafka, Redis Pub/Sub или вебхуками.t.Cleanup()
иdefer
. В интеграционных тестах ресурсы поднимаются часто: базы, контейнеры, вре́менные файлы. Если их не чистить аккуратно, окружение постепенно зарастает мусором и тесты начинают падать уже по сторонним причинам. Использованиеdefer
иt.Cleanup()
решает эту проблему: всё, что вы создали, будет удалено даже при панике или фейле.
Этот подход даёт команде уверенность, что тесты не оставляют за собой грязных следов. На CI это критично: иначе контейнеры и БД накапливаются и со временем система становится нестабильной. А локально это просто экономит нервы разработчику, который не должен вручную убивать процессы после каждого прогона.
Чтобы расти, нужно выйти из привычной зоны и сделать шаг к переменам. Можно изучить новое, начав с бесплатных материалов:
записи мастер-класса «Искусственный интеллект в разработке»;
гайда «Искусство самопрезентации»;
записи открытой встречи «Информационная безопасность: как построить карьеру в цифровом будущем» совместно с УрФУ;
экспресс-консультации по выбору профессии в IT.
Или можно стать востребованным сотрудником и открыть бóльшие перспективы в карьере с профессиональным обучением:
на курсе «Go-разработчик: курс для IT-специалистов» с программой трудоустройства;
на курсе «DevSecOps: практика безопасной разработки» с НИУ ВШЭ;
на программе для действующих специалистов «DevOps-инженер» с Yandex Cloud;
на расширенном курсе «1C‑программист» с программой трудоустройства.
Комментарии (0)
Sly_tom_cat
17.09.2025 11:20Ниже — пример кода с
eventually
:Никогда бы не пришло в голову самому прописывать
eventually
- есть же готовые вариантыreqire/assert.Eventually
mih-kopylov
17.09.2025 11:20Тесты, которые работают только на чистой базе - слишком искусственные. В реальности приложение всегда работает на базе с какими-то данными.
Чтобы изолировать тесты друг от друга, есть хороший и простой способ - мультитенантность.
Каждый тест генерирует случайное название тенанта, создаёт в нём предварительные данные и выполняет условия проверок. После теста можно ничего не очищать, потому что остальные тесты точно работают в других тенантах.Кроме описанных плюсов, бизнес может продать мультитенантность клиентам как отдельную фичу.
user-book
Счастлив тот разработчик, где разрабатываемое приложение можно полностью завернуть локально в эмуляторы или классы-заглушки)
Статья интересная, добавлю только что прописывать контейнеры на уровне кода можно, но нежелательно.
Лучшая практика для сложных систем - запускать локальные тесты в контейнере с базой которая является дампом с боевого бекапа.
Иметь скрипты что проверяют базу на валидность, то есть вы прогнали тесты а потом проверили саму базу - не сломало ли где-то поле или еще что то
В идеале (если это что то что прокидывается через интернет) делать подключения к тестовым средам через контейнер который может эмулировать перебои с сетью, шакалить пакеты и тд
У меня есть опыт с реальным банковским серверным приложением и кроме всей криптографии прочего (что успешно завернулось в контейнеры для тестирования) есть еще физические устройства (как serial так и просто input/output) и там только живые тесты руками с реальными устройствами((