Как не укусить себя за хвост во время написания функциональных тестов на Go

Публикации в СМИ
14.02.2025
Опубликовано на Хабре

Залог успеха любого программного решения — хорошее покрытие его функциональными тестами. Каждая полностью покрытая функция — минус одна потенциальная ошибка в работе проекта или даже больше. Однако при написании тестов в проекте, насчитывающем тысячи строк кода и множество пакетов (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:

python
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) должен размещаться там же, где и тестируемый файл:

python
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:

javascript
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.

javascript
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 выглядит так:

javascript
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./... и видите... ошибку:

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.

0c5e718f8743be0175e74cc528d88158.png

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

3242a1a771e62ab1a55538741c1c7832.png

По такой логике нужно переместить ConnectToDb в другой новый пакет. Однако в этом и заключается проблема тестов: они должны лежать в той же директории, что и тестируемый файл. Поэтому перемещение файла повлечёт перемещение соответствующего тестового файла, что будет фактически равносильно простому переименованию директории (или перекладыванию между карманами).

Что делать с хвостом?

Одним из вариантов решения проблемы будет отказ от использования SetupDbConfiguration внутри ConnectToDb и дублирование её функциональности внутри тестирующей функции:

swift
func TestConnectToDb(t *testing.T) { //taxiDb := testutil.SetupDbConfiguration(t) port := createDbContainer(t) taxiDb := ConnectToDb(port) ... }

Конкретно здесь это можно оправдать тем, что тестируемая функция явно должна быть указана в тестирующей. Однако обычно такой вариант не только противоречит принципам SOLID, бритве Оккама и ещё дюжине негласных правил, но и создаёт дополнительные трудности при разработке. Если у вас таких функций будет несколько (например, для генерации тестовых данных), то их придётся дублировать во все пакеты, а за такое обычно не хвалят.

Ещё одним вариантом будет использование build‑флагов. Однако они предназначены не совсем для таких проблем, а скорее для разделения использования тестов — чтобы не собирать весь проект, а только часть. К тому же, тут есть определённые трудности.

На субъективный и единственно правильный взгляд автора, лучшим решением будет перемещение тестового файла внутри директории в другой пакет <package>_test. Да, вам не показалось. Обычно за такое Go даёт по шапке понять, что вы неправы:

26d81876344d3a6ca19d24e071db053f.png

Однако в случае тестовых файлов это возможно без проблем:

python
package db_test import ( "taxi-app/main/testutil" "reflect" "slices" "testing" ) func TestConnectToDb(t *testing.T) { ... }

Пакет обязательно должен называться <package>_test, иначе появится ошибка Multiple packages. Также нужно будет импортировать тестируемый пакет, если функции оттуда используются в тесте. В таком случае не будет проблем с зависимостями, поскольку теперь <package>_test импортирует <package> и не может создать циклов в дереве зависимостей.

Хвостатые выводы

Если вам нужно написать модульные тесты, для которых требуется общее окружение или функции из других пакетов, и это может вызвать создание циклических зависимостей, то лучшим решением будет перемещение теста в пакет _test. Не кусайте за хвост себя и своих коллег.

А проект‑пример можно найти здесь.