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

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