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

от автора

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


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *