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

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

Комментарии (0)