Как правильно настроить тестовое окружение (setup) в Go с помощью пакета testing?
Решение: используем 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. В таких случаях лучше использовать транзакции БД с откатом.