Как не укусить себя за хвост во время написания функциональных тестов на Go
- суббота, 15 февраля 2025 г. в 00:00:09
Залог успеха любого программного решения — хорошее покрытие его функциональными тестами. Каждая полностью покрытая функция — минус одна потенциальная ошибка в работе проекта или даже больше. Однако при написании тестов в проекте, насчитывающем тысячи строк кода и множество пакетов (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
. Не кусайте за хвост себя и своих коллег.
А проект‑пример можно найти здесь: https://gitverse.ru/little‑arsonist/taxi‑app.