Юнит-тестирование на Go: структура и лучшие практики

Тестирование: от основ до продвинутых паттернов

Содержимое страницы

Встроенный пакет тестирования Go предоставляет мощный, минималистичный фреймворк для написания модульных тестов без внешних зависимостей. Вот основы тестирования, структура проекта и продвинутые паттерны для создания надежных приложений на Go.

Go Unit Testing is awesome

Почему тестирование важно в Go

Философия Go делает акцент на простоте и надежности. Стандартная библиотека включает пакет testing, делая модульное тестирование неотъемлемой частью экосистемы Go. Хорошо протестированный код на Go улучшает поддерживаемость, выявляет ошибки на ранних стадиях и служит документацией через примеры. Если вы новичок в Go, ознакомьтесь с нашим Гайдом по Go для быстрого ознакомления с основами языка.

Ключевые преимущества тестирования в Go:

  • Встроенная поддержка: Нет необходимости в внешних фреймворках
  • Быстрое выполнение: Параллельное выполнение тестов по умолчанию
  • Простая синтаксис: Минимальное количество шаблонного кода
  • Богатые инструменты: Отчеты о покрытии, бенчмарки и профилирование
  • Дружелюбность к CI/CD: Легкая интеграция с автоматизированными пайплайнами

Структура проекта для тестов Go

Тесты Go находятся рядом с производственным кодом с четкой согласованностью имен:

myproject/
├── go.mod
├── main.go
├── calculator.go
├── calculator_test.go
├── utils/
│   ├── helper.go
│   └── helper_test.go
└── models/
    ├── user.go
    └── user_test.go

Ключевые согласованности:

  • Файлы тестов заканчиваются на _test.go
  • Тесты находятся в том же пакете, что и код (или используют суффикс _test для тестирования “черного ящика”)
  • У каждого исходного файла может быть соответствующий файл теста

Подходы к тестированию пакетов

Тестирование “белого ящика” (в том же пакете):

package calculator

import "testing"
// Можно получать доступ к неэкспортируемым функциям и переменным

Тестирование “черного ящика” (внешний пакет):

package calculator_test

import (
    "testing"
    "myproject/calculator"
)
// Можно получать доступ только к экспортируемым функциям (рекомендуется для публичных API)

Базовая структура теста

Каждая тестовая функция следует этому шаблону:

package calculator

import "testing"

// Тестовая функция должна начинаться с "Test"
func TestAdd(t *testing.T) {
    result := Add(2, 3)
    expected := 5

    if result != expected {
        t.Errorf("Add(2, 3) = %d; want %d", result, expected)
    }
}

Методы testing.T:

  • t.Error() / t.Errorf(): Пометить тест как неудачный, но продолжить
  • t.Fatal() / t.Fatalf(): Пометить тест как неудачный и остановиться сразу
  • t.Log() / t.Logf(): Логирование (показывается только с флагом -v)
  • t.Skip() / t.Skipf(): Пропустить тест
  • t.Parallel(): Запустить тест параллельно с другими параллельными тестами

Табличные тесты: Способ Go

Табличные тесты - это идиоматический подход Go для тестирования множества сценариев. С генериками Go вы можете также создавать безопасные для типов тестовые хелперы, которые работают с разными типами данных:

func TestCalculate(t *testing.T) {
    tests := []struct {
        name     string
        a, b     int
        op       string
        expected int
        wantErr  bool
    }{
        {"addition", 2, 3, "+", 5, false},
        {"subtraction", 5, 3, "-", 2, false},
        {"multiplication", 4, 3, "*", 12, false},
        {"division", 10, 2, "/", 5, false},
        {"division by zero", 10, 0, "/", 0, true},
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            result, err := Calculate(tt.a, tt.b, tt.op)

            if (err != nil) != tt.wantErr {
                t.Errorf("Calculate() error = %v, wantErr %v", err, tt.wantErr)
                return
            }

            if result != tt.expected {
                t.Errorf("Calculate(%d, %d, %q) = %d; want %d",
                    tt.a, tt.b, tt.op, result, tt.expected)
            }
        })
    }
}

Преимущества:

  • Одна тестовая функция для множества сценариев
  • Легко добавлять новые тестовые случаи
  • Четкая документация ожидаемого поведения
  • Лучшая организация и поддерживаемость тестов

Запуск тестов

Базовые команды

# Запуск тестов в текущей директории
go test

# Запуск тестов с подробным выводом
go test -v

# Запуск тестов во всех поддиректориях
go test ./...

# Запуск конкретного теста
go test -run TestAdd

# Запуск тестов по шаблону
go test -run TestCalculate/addition

# Запуск тестов параллельно (по умолчанию GOMAXPROCS)
go test -parallel 4

# Запуск тестов с таймаутом
go test -timeout 30s

Покрытие тестами

# Запуск тестов с покрытием
go test -cover

# Генерация профиля покрытия
go test -coverprofile=coverage.out

# Просмотр покрытия в браузере
go tool cover -html=coverage.out

# Показать покрытие по функциям
go tool cover -func=coverage.out

# Установить режим покрытия (set, count, atomic)
go test -covermode=count -coverprofile=coverage.out

Полезные флаги

  • -short: Запуск тестов, помеченных с if testing.Short() проверками
  • -race: Включение детектора гонок (находит проблемы с параллельным доступом)
  • -cpu: Указание значений GOMAXPROCS
  • -count n: Запуск каждого теста n раз
  • -failfast: Остановка при первом неудачном тесте

Хелперы тестов и настройка/разборка

Хелперные функции

Помечайте хелперные функции с t.Helper() для улучшения отчетности об ошибках:

func assertEqual(t *testing.T, got, want int) {
    t.Helper() // Эта строка отображается как вызывающая
    if got != want {
        t.Errorf("got %d, want %d", got, want)
    }
}

func TestMath(t *testing.T) {
    result := Add(2, 3)
    assertEqual(t, result, 5) // Линия ошибки указывает сюда
}

Настройка и разборка

func TestMain(m *testing.M) {
    // Код настройки здесь
    setup()

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

    // Код разборки здесь
    teardown()

    os.Exit(code)
}

Тестовые фикстуры

func setupTestCase(t *testing.T) func(t *testing.T) {
    t.Log("setup test case")
    return func(t *testing.T) {
        t.Log("teardown test case")
    }
}

func TestSomething(t *testing.T) {
    teardown := setupTestCase(t)
    defer teardown(t)

    // Код теста здесь
}

Мокирование и инъекция зависимостей

Мокирование на основе интерфейсов

Когда тестируется код, взаимодействующий с базами данных, использование интерфейсов упрощает создание мок-реализаций. Если вы работаете с PostgreSQL в Go, ознакомьтесь с нашим сравнением ORM для Go для выбора подходящей библиотеки базы данных с хорошей тестируемостью.

// Производственный код
type Database interface {
    GetUser(id int) (*User, error)
}

type UserService struct {
    db Database
}

func (s *UserService) GetUserName(id int) (string, error) {
    user, err := s.db.GetUser(id)
    if err != nil {
        return "", err
    }
    return user.Name, nil
}

// Код теста
type MockDatabase struct {
    users map[int]*User
}

func (m *MockDatabase) GetUser(id int) (*User, error) {
    if user, ok := m.users[id]; ok {
        return user, nil
    }
    return nil, errors.New("user not found")
}

func TestGetUserName(t *testing.T) {
    mockDB := &MockDatabase{
        users: map[int]*User{
            1: {ID: 1, Name: "Alice"},
        },
    }

    service := &UserService{db: mockDB}
    name, err := service.GetUserName(1)

    if err != nil {
        t.Fatalf("unexpected error: %v", err)
    }
    if name != "Alice" {
        t.Errorf("got %s, want Alice", name)
    }
}

Популярные библиотеки тестирования

Testify

Самая популярная библиотека тестирования Go для утверждений и моков:

import (
    "github.com/stretchr/testify/assert"
    "github.com/stretchr/testify/mock"
)

func TestWithTestify(t *testing.T) {
    result := Add(2, 3)
    assert.Equal(t, 5, result, "they should be equal")
    assert.NotNil(t, result)
}

// Пример мока
type MockDB struct {
    mock.Mock
}

func (m *MockDB) GetUser(id int) (*User, error) {
    args := m.Called(id)
    return args.Get(0).(*User), args.Error(1)
}

Другие инструменты

  • gomock: Фреймворк мокирования от Google с генерацией кода
  • httptest: Стандартная библиотека для тестирования HTTP-хендлеров
  • testcontainers-go: Интеграционное тестирование с Docker-контейнерами
  • ginkgo/gomega: Фреймворк тестирования в стиле BDD

При тестировании интеграций с внешними сервисами, такими как модели ИИ, вам нужно будет мокировать или стабилизатор эти зависимости. Например, если вы используете Ollama в Go, рассмотрите создание оберток интерфейсов, чтобы сделать ваш код более тестируемым.

Бенчмарк-тесты

Go включает встроенную поддержку бенчмарков:

func BenchmarkAdd(b *testing.B) {
    for i := 0; i < b.N; i++ {
        Add(2, 3)
    }
}

// Запуск бенчмарков
// go test -bench=. -benchmem

Вывод показывает итерации в секунду и выделения памяти.

Лучшие практики

  1. Пишите табличные тесты: Используйте паттерн среза структур для множества тестовых случаев
  2. Используйте t.Run для подтестов: Лучшая организация и возможность запускать подтесты выборочно
  3. Сначала тестируйте экспортируемые функции: Сосредоточьтесь на поведении публичного API
  4. Держите тесты простыми: Каждый тест должен проверять одну вещь
  5. Используйте осмысленные имена тестов: Описывайте, что тестируется и ожидаемый результат
  6. Не тестируйте детали реализации: Тестируйте поведение, а не внутренности
  7. Используйте интерфейсы для зависимостей: Упрощает мокирование
  8. Стремитесь к высокому покрытию, но качество важнее количества: 100% покрытие не означает отсутствие ошибок
  9. Запускайте тесты с флагом -race: Выявляйте проблемы с параллелизмом на ранних стадиях
  10. Используйте TestMain для дорогой настройки: Избегайте повторения настройки в каждом тесте

Пример: Полный набор тестов

package user

import (
    "errors"
    "testing"
)

type User struct {
    ID    int
    Name  string
    Email string
}

func ValidateUser(u *User) error {
    if u.Name == "" {
        return errors.New("name cannot be empty")
    }
    if u.Email == "" {
        return errors.New("email cannot be empty")
    }
    return nil
}

// Файл тестов: user_test.go
func TestValidateUser(t *testing.T) {
    tests := []struct {
        name    string
        user    *User
        wantErr bool
        errMsg  string
    }{
        {
            name:    "valid user",
            user:    &User{ID: 1, Name: "Alice", Email: "alice@example.com"},
            wantErr: false,
        },
        {
            name:    "empty name",
            user:    &User{ID: 1, Name: "", Email: "alice@example.com"},
            wantErr: true,
            errMsg:  "name cannot be empty",
        },
        {
            name:    "empty email",
            user:    &User{ID: 1, Name: "Alice", Email: ""},
            wantErr: true,
            errMsg:  "email cannot be empty",
        },
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            err := ValidateUser(tt.user)

            if (err != nil) != tt.wantErr {
                t.Errorf("ValidateUser() error = %v, wantErr %v", err, tt.wantErr)
                return
            }

            if err != nil && err.Error() != tt.errMsg {
                t.Errorf("ValidateUser() error message = %v, want %v", err.Error(), tt.errMsg)
            }
        })
    }
}

Полезные ссылки

Заключение

Фреймворк тестирования Go предоставляет все необходимое для комплексного модульного тестирования с минимальными настройками. Следуя идиомам Go, таким как табличные тесты, использование интерфейсов для моков и применение встроенных инструментов, вы можете создавать поддерживаемые, надежные наборы тестов, которые растут вместе с вашей базой кода.

Эти практики тестирования применимы ко всем типам приложений на Go, от веб-сервисов до CLI приложений, созданных с Cobra & Viper. Тестирование командных инструментов требует аналогичных паттернов с дополнительным акцентом на тестирование ввода/вывода и разбора флагов.

Начните с простых тестов, постепенно добавляйте покрытие и помните, что тестирование - это инвестиция в качество кода и уверенность разработчиков. Акцент сообщества Go на тестировании облегчает долгосрочное поддержание проектов и эффективное сотрудничество с членами команды.