Avatar ·

Как правильно настроить тестовое окружение (setup) в Go с помощью пакета testing?

Всем привет! Я начал изучать Go и пытаюсь писать unit-тесты для своего проекта. У меня есть функция, которая работает с базой данных и выполняет несколько запросов. Для тестирования мне нужно перед каждым тестом подготавливать данные: создавать таблицы, вставлять тестовые записи, а после теста — очищать всё. Я знаю о функциях TestMain и t.Run, но не могу понять, как правильно их использовать для setup/teardown. В других языках (Java, Python) я привык к аннотациям @Before/@After или фикстурам pytest. В Go же такого нет, и мне кажется, что я копирую один и тот же код в каждый тест. Вот пример моего текущего подхода (который мне не нравится): ```go func TestGetUser(t *testing.T) { db := setupDB() // создаю БД, таблицы, вставляю данные defer teardownDB(db) // удаляю таблицы user, err := GetUser(db, 1) if err != nil { t.Fatal(err) } if user.Name != "Alice" { t.Errorf("expected Alice, got %s", user.Name) } } func TestCreateUser(t *testing.T) { db := setupDB() defer teardownDB(db) err := CreateUser(db, "Bob") if err != nil { t.Fatal(err) } // проверки... } ``` Проблема в том, что setupDB() — тяжёлая операция (создание таблиц, миграции), и она повторяется в каждом тесте. Я слышал про TestMain, но не понимаю, как его применить для моей задачи. Подскажите, как организовать подготовку тестового окружения в Go правильно и эффективно? <<>>

Решение: используем TestMain для глобального setup/teardown и t.Cleanup для локального

В Go действительно нет встроенных аннотаций @Before/@After, но пакет testing предоставляет гибкие механизмы для организации setup/teardown. Рассмотрим два уровня: глобальный (один раз на весь пакет) и локальный (для каждого теста или подтеста).

1. Глобальный setup/teardown через TestMain

TestMain — это специальная функция, которая запускается один раз для всего пакета. Она идеально подходит для тяжёлых операций, таких как создание БД, миграции или запуск тестового сервера.

package mypackage

import (
    "database/sql"
    "os"
    "testing"
)

var testDB *sql.DB

func TestMain(m *testing.M) {
    // Глобальный setup — выполняется один раз
    var err error
    testDB, err = setupDatabase()
    if err != nil {
        os.Exit(1) // завершаем тесты с ошибкой
    }

    // Запуск всех тестов в пакете
    code := m.Run()

    // Глобальный teardown — выполняется после всех тестов
    teardownDatabase(testDB)

    os.Exit(code)
}

func setupDatabase() (*sql.DB, error) {
    // Подключение к тестовой БД, создание таблиц, миграции
    db, err := sql.Open("sqlite3", ":memory:")
    if err != nil {
        return nil, err
    }
    _, err = db.Exec(`CREATE TABLE users (id INT, name TEXT)`)
    return db, err
}

func teardownDatabase(db *sql.DB) {
    db.Close()
}

2. Локальный setup/teardown через t.Cleanup (Go 1.14+)

Для подготовки данных перед каждым тестом используйте t.Cleanup — это современная замена defer в тестах. Она гарантирует выполнение cleanup даже если тест упадёт с t.Fatal.

func TestGetUser(t *testing.T) {
    // Локальный setup — очищаем таблицу и вставляем тестовые данные
    _, err := testDB.Exec("DELETE FROM users")
    if err != nil {
        t.Fatal(err)
    }
    _, err = testDB.Exec("INSERT INTO users (id, name) VALUES (1, 'Alice')")
    if err != nil {
        t.Fatal(err)
    }

    // Регистрируем cleanup — выполнится после теста
    t.Cleanup(func() {
        testDB.Exec("DELETE FROM users")
    })

    // Тело теста
    user, err := GetUser(testDB, 1)
    if err != nil {
        t.Fatal(err)
    }
    if user.Name != "Alice" {
        t.Errorf("expected Alice, got %s", user.Name)
    }
}

3. Группировка тестов с общим setup через t.Run

Если несколько тестов используют одинаковые данные, объедините их в подтесты:

func TestUserOperations(t *testing.T) {
    // Общий setup для всей группы
    _, err := testDB.Exec("DELETE FROM users")
    if err != nil {
        t.Fatal(err)
    }
    _, err = testDB.Exec("INSERT INTO users (id, name) VALUES (1, 'Alice'), (2, 'Bob')")
    if err != nil {
        t.Fatal(err)
    }

    t.Cleanup(func() {
        testDB.Exec("DELETE FROM users")
    })

    // Подтесты
    t.Run("GetUser", func(t *testing.T) {
        user, err := GetUser(testDB, 1)
        if err != nil {
            t.Fatal(err)
        }
        if user.Name != "Alice" {
            t.Errorf("expected Alice, got %s", user.Name)
        }
    })

    t.Run("CreateUser", func(t *testing.T) {
        err := CreateUser(testDB, "Charlie")
        if err != nil {
            t.Fatal(err)
        }
        // доп. проверки
    })
}

4. Рекомендации

  • Тяжёлый setup (БД, сервер) — выносите в TestMain
  • Подготовка данных для каждого теста — используйте t.Cleanup
  • Группировка тестов с общими данными — используйте t.Run
  • Избегайте defer в тестах, если cleanup должен выполниться даже при t.Fatal

Такой подход делает тесты чистыми, изолированными и эффективными — тяжёлые операции выполняются один раз, а данные подготавливаются только для нужных тестов.

<<>>

Альтернативный подход: вспомогательные функции с замыканиями

Если вам не нравится TestMain или вы хотите более явно контролировать lifecycle тестов, можно использовать паттерн с функциями, возвращающими cleanup.

func setupUserTest(t *testing.T, userID int, userName string) {
    t.Helper() // помечаем как вспомогательную — при ошибке покажет строку теста, а не setup
    _, err := testDB.Exec("DELETE FROM users")
    if err != nil {
        t.Fatal(err)
    }
    _, err = testDB.Exec("INSERT INTO users (id, name) VALUES ($1, $2)", userID, userName)
    if err != nil {
        t.Fatal(err)
    }

    t.Cleanup(func() {
        testDB.Exec("DELETE FROM users")
    })
}

func TestGetUser(t *testing.T) {
    setupUserTest(t, 1, "Alice")
    // ... тест
}

func TestCreateUser(t *testing.T) {
    setupUserTest(t, 2, "Bob")
    // ... тест
}

Плюсы этого подхода:

  • Явная передача параметров для setup
  • Удобно для разных сценариев (разные тесты — разные данные)
  • Cleanup автоматически привязывается к тесту через t.Cleanup

Минус: если в тесте нужно изменить данные после setup, придётся делать дополнительный cleanup. В таких случаях лучше использовать транзакции БД с откатом.