Залог успеха любого программного решения — хорошее покрытие его функциональными тестами. Каждая полностью покрытая функция — минус одна потенциальная ошибка в работе проекта или даже больше. Однако при написании тестов в проекте, насчитывающем тысячи строк кода и множество пакетов (packages), можно столкнуться с различными трудностями.
Я Роман Соловьев, ведущий ИТ‑инженер в отделе RnD и готовых решений управления развития продукта в СберТехе. Сегодня расскажу, с какими проблемами мы столкнулись при написании тестов к проекту на Go, активно использующему Docker‑контейнеры, и как нам удалось их решить.
Эта статья будет полезна тем, кто пишет модульные тесты на Go, особенно для проектов, использующих Docker‑контейнеры. Я постараюсь просто и понятно объяснить официальный code‑style для модульных тестов, а также подсветить подводные камни, с которыми можно столкнуться при их написании.
Сказ о приложении для такси
Предположим, вы работаете в компании инженером‑разработчиком, и вам поступил заказ от таксопарка: написать backend‑приложение на Go для распределения таксистов по клиентам. Приложение должно уметь принять заказ от клиента, назначить водителя и отправить водителю уведомление. Самое простое решение — микросервис из HTTP‑сервера, принимающего запросы, и базы данных, куда можно складывать данные клиентов и водителей, а также миграции, заказы и всё остальное.
И вот вы приступили к разработке. Семь чашек кофе и семь гранёных стаканов чая — и вы представляете миру новое чудо. Структура проекта примерно такая:
taxi-app/ |-cmd/ |--main.go # главный файл приложения |-handler/ |--handler.go # функции обработки http-эндпоинтов |-db/ |--migrations/ # папка с файлами миграций (.sql) |--db.go # функции соединения с БД и выполнения миграций |-models/ |--driver.go # модель для таблицы водителей |--client.go # модель для таблицы клиентов |--... # еще модели |-service/ |--driver.go # сервисные функции для таблицы водителей (выбрать всех водителей, обновить данные и т.д.) |--... # сервисные функции для других моделей |-docker-compose.yml |-Makefile
В качестве БД (без ограничения общности) выбрана PostgreSQL, а для доступа к ней из проекта — GORM. docker compose up
запускает контейнер с HTTP‑сервером и контейнер с БД.
Надоедливые вопросы
Казалось бы, всё работает и диалог с заказчиком идёт хорошо, но однажды вам задают каверзный вопрос: «А какой у вас coverage?». Хорошо же общались, зачем так? И проект с вашими 0 % возвращают на доработку.
За очередной чашкой кофе вы приступаете к написанию тестов, сначала для лёгких файлов, например, models/driver.go:
pythonpackage model import "fmt" type Driver struct { ID uint `gorm:"id"` Name string `gorm:"name; not null"` Age int `gorm:"age"` } func (d Driver) String() string { return fmt.Sprintf("Driver name: %s, age: %d", d.Name, d.Age) }
Тут нужно покрыть одну функцию String()
. Тестовый файл (по project‑layout) должен размещаться там же, где и тестируемый файл:
pythonpackage model import ( "github.com/stretchr/testify/assert" "testing" ) func TestDriver_String(t *testing.T) { a := Driver{ ID: 1, Name: "ANTON", Age: 44, } expected := "Driver name: ANTON, age: 44" actual := a.String() assert.Equal(t, expected, actual) }
Обычно хорошей практикой считается использование библиотек assert и require. Первая от второй отличается только тем, что вторая завершает тест сразу же после неверного результата, тогда как первая помечает тест как провалившийся и продолжает работу.
И вот уже покрытие тестами ненулевое. Дело за малым: написать тесты для всех остальных файлов. Например, для db.go:
javascriptpackage db import ( "fmt" "gorm.io/driver/postgres" "gorm.io/gorm" "gorm.io/gorm/logger" "log" ) var TaxiDb *gorm.DB // ConnectToDb выполняет соединение с БД. Присваивает глобальной переменной TaxiDb значение полученной БД func ConnectToDb(port string) (*gorm.DB, error) { dbUser := "user" //todo: hardcode dbPassword := "password" dbAddress := "localhost" dbPort := port dbName := "postgres" dbUrl := fmt.Sprintf("postgres://%s:%s@%s:%d/%s", dbUser, dbPassword, dbAddress, dbPort, dbName) db, err := gorm.Open(postgres.Open( dbUrl), &gorm.Config{ Logger: logger.Default.LogMode(logger.Silent), QueryFields: true, }, ) if err != nil { log.Fatalf("Error connect to postgres url: %s, err: %v", dbUrl, err) return nil, err } return db, nil }
Для тестирования этой функции уже нужен поднятый контейнер с БД, так как иначе соединяться будет не с чем. Хорошим инструментом для таких задач будет библиотека testcontainers. Она позволяет поднимать как одиночные контейнеры для ваших задач или весь проект целиком через модуль compose.
javascriptpackage db import ( "reflect" "slices" "testing" ) // Тест проверяет, что подключение к БД происходит успешно. // Шаги: // 1. Создание контейнера БД // 2. Соединение с БД // 3. Проверка того, что все таблицы созданы func TestConnectToDb(t *testing.T) { db := setupDbConfiguration(t) var actual []string if err := db.Table("information_schema.tables").Where("table_schema = ?", "taxi"). Pluck("table_name", &actual).Error; err != nil { t.Fatalf("Error getting schema tables, err: %v", err) } expected := []string{ "migrations", "driver", "client", "orders", "...", } slices.Sort(expected) slices.Sort(actual) // неважен порядок таблиц assert.Equal(t, expected, actual) } // createDbContainer создает контейнер с БД. Возвращает mapped-порт, на котором развернута БД func createDbContainer(t *testing.T) string { ctx := context.Background() dbName := "postgres" dbUser := "user" dbPassword := "password" postgresContainer, err := postgres.Run(ctx, "docker.io/postgres:16-alpine", postgres.WithDatabase(dbName), postgres.WithUsername(dbUser), postgres.WithPassword(dbPassword), testcontainers.CustomizeRequest(testcontainers.GenericContainerRequest{ ContainerRequest: testcontainers.ContainerRequest{ Name: "db", }, }), testcontainers.WithWaitStrategy( wait.ForLog("database system is ready to accept connections"). WithOccurrence(2). WithStartupTimeout(5*time.Second)), ) t.Cleanup(func() { if err := postgresContainer.Terminate(ctx); err != nil { t.Errorf("Failed to terminate container, err: %s", err) } }) if err != nil { t.Fatalf("Failed to start container, err: %s", err) } a, err := postgresContainer.MappedPort(ctx, "5432/tcp") if err != nil { t.Fatalf("Error getting db port, err: %v", err) } return strconv.Itoa(a.Int()) } // setupDbConfiguration поднимает один контейнер с БД func setupDbConfiguration(t *testing.T) *gorm.DB { port := createDbContainer(t) return ConnectToDb(port) }
К сожалению, пока фреймворк testcontainers
не предоставляет возможности использовать кастомный порт для БД напрямую, поэтому его нужно маппить через MappedPort
. Таким образом, createDbContainer
создаёт контейнер с БД и возвращает mapped‑порт, а setupDbConfiguration
дополнительно соединяется с БД через ConnectToDb
.
Вот и тест для пакета db
написан. И пока ничего страшного не произошло. Осталась одна директория до полного покрытия — это пакет service
. И тут возникает проблема: для этого пакета тоже нужно поднять БД. А функция setupDbConfiguration
лежит в пакете db
и не экспортирована. «Без проблем», — говорите вы и переносите её в отдельный пакет testutil
. Структура проекта теперь выглядит так:
taxi-app/ |-cmd/ |--main.go # главный файл приложения |-handler/ |--handler.go # функции обработки http-эндпоинтов |-db/ |--migrations/ # папка с файлами миграций (.sql) |--db.go # функции соединения с БД и выполнения миграций |-models/ |--driver.go # модель для таблицы водителей |--client.go # модель для таблицы клиентов |--... # еще модели |-service/ |--driver.go # сервисные функции для таблицы водителей (выбрать всех водителей, обновить данные и т.д.) |--... # сервисные функции для других моделей |-testutil/ |--testunit.go # функции подъема тестового окружения |-docker-compose.yml |-Makefile
А тестовый файл db_test выглядит так:
javascriptpackage db import ( "taxi-app/main/testutil" "reflect" "slices" "testing" ) // Тест проверяет, что подключение к БД происходит успешно. // Шаги: // 1. Создание контейнера БД // 2. Соединение с БД // 3. Проверка того, что все таблицы созданы func TestConnectToDb(t *testing.T) { taxiDb := testutil.SetupDbConfiguration(t) // эта функция теперь использует db.ConnectToDb()! var actual []string if err := taxiDb.Table("information_schema.tables").Where("table_schema = ?", "taxi"). Pluck("table_name", &actual).Error; err != nil { t.Fatalf("Error getting schema tables, err: %v", err) } expected := []string{ "migrations", "driver", "client", "orders", "...", } slices.Sort(expected) slices.Sort(actual) // неважен порядок таблиц assert.Equal(t, expected, actual) }
Вы пишете тест для service
, запускаете, наконец, тесты через go test./
... и видите... ошибку:
python# taxi-app/db package taxi-app/db imports taxi-app/testutil imports taxi-app/db: import cycle not allowed in test FAIL taxi-app/db [setup failed] ? taxi-app [no test files] ? taxi-app/testutil [no test files] ok taxi-app/model 0.422s ok taxi-app/service 0.220s FAIL
Что же произошло? Вы ведь только сделали небольшой рефакторинг: переместили функцию в пакет, подходящий ей по смыслу. В этом и проблема: Go, как и многие другие языки, не допускает возникновения циклических зависимостей. Так что, переместив эту функцию, вы вызвали import cycle и укусили себя за хвост.
Что такое циклические зависимости и с чем их есть
Зависимости в Go определяются на стадии построения графа зависимостей
и анализа исходного кода. Если в графе есть циклы, то выдаётся ошибка.
Например, если есть пакеты А и B, и пакет А использует функцию beta()
из B, которая уже использует alpha()
из А. В нашем примере — TestConnectToDb
из пакета db
использует функцию SetupDbConfiguration
из testutil
, а эта функция в свою очередь использует ConnectToDb
из db
.

Обычно такие проблемы решаются переносом общей функции или нужной
для использования функциональности в другой пакет. На примере А и B
достаточно переместить функцию alpha()
в пакет С:

По такой логике нужно переместить ConnectToDb
в другой
новый пакет. Однако в этом и заключается проблема тестов: они должны
лежать в той же директории, что и тестируемый файл. Поэтому перемещение
файла повлечёт перемещение соответствующего тестового файла, что будет
фактически равносильно простому переименованию директории (или
перекладыванию между карманами).
Что делать с хвостом?
Одним из вариантов решения проблемы будет отказ от использования SetupDbConfiguration
внутри ConnectToDb
и дублирование её функциональности внутри тестирующей функции:
swiftfunc TestConnectToDb(t *testing.T) { //taxiDb := testutil.SetupDbConfiguration(t) port := createDbContainer(t) taxiDb := ConnectToDb(port) ... }
Конкретно здесь это можно оправдать тем, что тестируемая функция явно должна быть указана в тестирующей. Однако обычно такой вариант не только противоречит принципам SOLID, бритве Оккама и ещё дюжине негласных правил, но и создаёт дополнительные трудности при разработке. Если у вас таких функций будет несколько (например, для генерации тестовых данных), то их придётся дублировать во все пакеты, а за такое обычно не хвалят.
Ещё одним вариантом будет использование build‑флагов. Однако они предназначены не совсем для таких проблем, а скорее для разделения использования тестов — чтобы не собирать весь проект, а только часть. К тому же, тут есть определённые трудности.
На субъективный и единственно правильный взгляд автора, лучшим решением будет перемещение тестового файла внутри директории в другой пакет <package>_test
. Да, вам не показалось. Обычно за такое Go даёт по шапке понять, что вы неправы:

Однако в случае тестовых файлов это возможно без проблем:
pythonpackage db_test import ( "taxi-app/main/testutil" "reflect" "slices" "testing" ) func TestConnectToDb(t *testing.T) { ... }
Пакет обязательно должен называться <package>_test
, иначе появится ошибка Multiple packages.
Также нужно будет импортировать тестируемый пакет, если функции оттуда
используются в тесте. В таком случае не будет проблем с зависимостями,
поскольку теперь <package>_test
импортирует <package>
и не может создать циклов в дереве зависимостей.
Хвостатые выводы
Если вам нужно написать модульные тесты, для которых требуется общее
окружение или функции из других пакетов, и это может вызвать создание
циклических зависимостей, то лучшим решением будет перемещение теста
в пакет _test
. Не кусайте за хвост себя и своих коллег.
А проект‑пример можно найти здесь.