Тестирование: табличные тесты и мокирование интерфейсов

В Node.js вы привыкли к экосистеме Jest, Mocha и Sinon. В Go подход другой: инструменты тестирования встроены в стандартную библиотеку и поставляются вместе с компилятором. Это часть философии языка: код готов только тогда, когда он протестирован.

Основы: go test и объект testing.T

Для тестов не нужно скачивать пакеты. Просто создайте файл с суффиксом _test.go. Компилятор проигнорирует его при сборке приложения, но использует при запуске команды go test.

Правила оформления:

  • Функция начинается с префикса Test.
  • Принимает аргумент *testing.T.
  • Находится в том же пакете, что и основной код.

В Go нет expect или should. Мы используем обычные условия if. Объект testing.T управляет процессом: сообщает об ошибках или останавливает тест.

// math_test.go
func TestSum(t *testing.T) {
    result := Sum(2, 2)
    expected := 4

    if result != expected {
        // t.Errorf фиксирует ошибку, но не прерывает выполнение теста
        t.Errorf("Sum(2, 2) = %d; want %d", result, expected)
    }
}

Табличные тесты (Table-driven tests)

Если нужно проверить функцию на разных входных данных (например, валидацию email), не копируйте функции. Используйте Table-driven tests.

Вы создаете слайс структур со сценариями, итерируетесь по нему и запускаете подтесты через t.Run. Это делает тесты наглядными, как спецификация.

func TestValidateEmail(t *testing.T) {
    tests := []struct {
        name  string
        email string
        want  bool
    }{
        {"valid email", "user@example.com", true},
        {"missing @", "userexample.com", false},
        {"empty string", "", false},
        {"multiple @", "user@@example.com", false},
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            if got := ValidateEmail(tt.email); got != tt.want {
                t.Errorf("ValidateEmail(%s) = %v, want %v", tt.email, got, tt.want)
            }
        })
    }
}

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

Юнит-тесты не должны зависеть от реальной базы данных — это долго и нестабильно. Здесь помогает неявная реализация интерфейсов в Go (Duck Typing).

Чтобы подменить реальный объект на «заглушку», достаточно создать структуру, которая реализует те же методы, что и интерфейс репозитория. Mocking в Go — это не магия библиотек, а архитектурный прием.

Как показано на Схеме 1, интерфейс работает как универсальный разъем. Бизнес-логике неважно, что за ним: драйвер PostgreSQL или ваш мок.

Пример ручного мока для сервиса оплаты:

type OrderRepository interface {
    GetBalance(userID int) (int, error)
}

type mockRepo struct {
    balance int
    err     error
}

func (m mockRepo) GetBalance(userID int) (int, error) {
    return m.balance, m.err
}

func TestProcessPayment(t *testing.T) {
    // Настраиваем нужное состояние мока
    myMock := mockRepo{balance: 100, err: nil}
    
    // Внедряем мок в сервис
    service := NewPaymentService(myMock)
    
    err := service.Pay(1, 50)
    if err != nil {
        t.Errorf("ожидалась успешная оплата, получена ошибка: %v", err)
    }
}

Анализ покрытия (Test coverage)

Инструмент Test coverage встроен в Go и показывает, какие строки кода задействованы в тестах.

  • Посмотреть процент в консоли: go test -cover ./...
  • Открыть детальный HTML-отчет с подсветкой строк: go test -coverprofile=cover.out ./... && go tool cover -html=cover.out

💡 Не гонитесь за 100% покрытием. В бэкенде важнее протестировать сложную логику и обработку ошибок, чем простые конструкторы.

Безопасность и конкурентность

Go часто запускает тесты параллельно (через t.Parallel()). Чтобы избежать скрытых багов при работе с памятью, используйте флаг -race. Это активирует Race Detector — он найдет «состояния гонки», когда несколько потоков одновременно меняют одни и те же данные. 🏎️

Теперь вы умеете писать надежный код. В следующем уроке мы упакуем наше приложение в легковесный контейнер и подготовим его к деплою.