34. Тестирование HTTP сервисов

Эдсгер Дейкстра однажды сформулировал фундаментальную истину: «Тестирование может показать наличие ошибок, но никогда не докажет их отсутствие».

В таких экосистемах, как Java или C#, тестирование HTTP-слоя нередко превращается в мучительный ритуал: запуск тяжелого контейнера зависимостей (Spring TestContext), поднятие встроенного сервера Tomcat или Jetty и прогон нескольких десятков тестов за полторы минуты. Разработчики перестают запускать тесты перед каждым коммитом, потому что обратная связь становится невыносимо медленной.

Язык Go демонстрирует совершенно иной уровень инженерного изящества. Обработчик HTTP в Go — это не магический контроллер, а обыкновенная функция, удовлетворяющая скромному интерфейсу http.Handler: ServeHTTP(ResponseWriter, *Request). Пакет стандартной библиотеки net/http/httptest предоставляет инструмент httptest.NewRecorder, который полностью имитирует HTTP-ответ в оперативной памяти процесса через обычный bytes.Buffer. Никаких системных вызовов сокетов, никаких сетевых задержек ядра и никаких портов: тысячи тестов контрактов выполняются за доли секунды прямо на рабочей машине.

Философия тестирования в Go

Тестирование HTTP-сервисов в Go радикально отличается от подходов в экосистемах Java, C# или PHP. Вместо тяжелых фреймворков, запускающих вложенные JVM или поднимающих mock-серверы на реальных портах, стандартная библиотека net/http/httptest предоставляет легковесные примитивы для in-memory исполнения. Это позволяет запускать тысячи тестов за секунды, полностью исключая накладные расходы на сетевой стек ядра, TCP-handshake и контекстные переключения.

Правильно написанный тест в Го — это не проверка кода, а проверка контракта. Он должен быть детерминированным, изолированным, быстро исполняться и явно демонстрировать ожидаемое поведение через таблицы данных, а не императивные блоки if/else.

1. Инструментарий: httptest и in-memory исполнение

Пакет httptest предоставляет два основных инструмента: NewRecorder и NewServer. Первый имитирует http.ResponseWriter в памяти, второй поднимает полноценный http.Server на временном порту.

Go
package handler_test

import (
    "encoding/json"
    "net/http"
    "net/http/httptest"
    "testing"
)

type UserResponse struct {
    ID   int64  `json:"id"`
    Name string `json:"name"`
}

func TestGetUserHandler(t *testing.T) {
    // 1. Создаем запрос без реального сетевого соединения
    req := httptest.NewRequest(http.MethodGet, "/api/v1/users/42", nil)

    // 2. Создаем рекордер для захвата ответа в памяти
    w := httptest.NewRecorder()

    // 3. Вызываем обработчик напрямую через интерфейс
    handler := NewUserHandler(mockService)
    handler.ServeHTTP(w, req)

    // 4. Проверяем результат
    if w.Code != http.StatusOK {
        t.Errorf("expected status 200, got %d", w.Code)
    }

    var resp UserResponse
    if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
        t.Fatalf("invalid json response: %v", err)
    }

    if resp.ID != 42 {
        t.Errorf("expected user ID 42, got %d", resp.ID)
    }
}

2. Механика исполнения без сетевого стека

Когда требуется протестировать полный цикл взаимодействия с сетевым клиентом (таймауты, редиректы, потоковое чтение тела ответа), используется httptest.NewServer. В отличие от httptest.NewRecorder, он поднимает реальный локальный http.Server, открывая TCP-слушатель на случайном свободном порту loopback-интерфейса (127.0.0.1:0 через системный вызов ядра net.Listen).

flowchart TD
    A["Тест создает httptest.NewServer"] --> B["Запуск http.Server<br>на временном порту"]
    B --> C["Клиент отправляет запрос"]
    C --> D["Входящее соединение"]
    D --> E["Обработка в горутине<br>через netpoll"]
    E --> F["Запись ответа в сокет"]
    F --> G["Клиент читает ответ"]
    G --> H["Автоматическое закрытие сервера"]

    classDef normal fill:#1e293b,stroke:#38bdf8,color:#f8fafc;
    classDef action fill:#0f172a,stroke:#10b981,color:#f8fafc;
    class A,C,D,F,G normal;
    class B,E,H action;
  

Связка httptest.NewClient() и NewServer() использует оптимизированный транспорт, который кеширует соединения и минимизирует syscall-ы. Однако для юнит-тестов хендлеров NewRecorder всегда предпочтительнее: он быстрее, не требует открытия файловых дескрипторов и не создает конкуренцию за порты в параллельных сьютах.

3. Идиоматичные паттерны: Table-Driven и Middleware

Тестирование маршрутов и промежуточных обработчиков в Го строится вокруг подтестов t.Run. Это позволяет изолировать кейсы, запускать их параллельно и получать детальный отчет по каждому сценарию.

Go
func TestAuthMiddleware(t *testing.T) {
    tests := []struct {
        name           string
        token          string
        expectedStatus int
    }{
        {"valid token", "Bearer valid_token_123", http.StatusOK},
        {"missing token", "", http.StatusUnauthorized},
        {"invalid prefix", "Basic token", http.StatusUnauthorized},
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            t.Parallel() // Параллельный запуск кейсов

            req := httptest.NewRequest(http.MethodGet, "/protected", nil)
            if tt.token != "" {
                req.Header.Set("Authorization", tt.token)
            }
            w := httptest.NewRecorder()

            // Оборачиваем заглушку в мидлвару
            mw := AuthMiddleware(func(w http.ResponseWriter, r *http.Request) {
                w.WriteHeader(http.StatusOK)
            })
            mw.ServeHTTP(w, req)

            if w.Code != tt.expectedStatus {
                t.Errorf("got %d, want %d", w.Code, tt.expectedStatus)
            }
        })
    }
}

4. Стратегии моков и управление зависимостями

В Go мокирование реализуется через интерфейсы, а не через рефлексию или генерацию байт-кода. Ручные моки часто предпочтительнее gomock, так как они проще, быстрее и не требуют этапа go generate.

Go
// Ручной мок для тестов
type mockUserRepo struct {
    findErr   error
    foundUser *User
}

func (m *mockUserRepo) FindByID(ctx context.Context, id int64) (*User, error) {
    return m.foundUser, m.findErr
}

func TestHandlerWithDependency(t *testing.T) {
    repo := &mockUserRepo{
        foundUser: &User{ID: 1, Email: "test@example.com"},
    }

    svc := service.NewUserService(repo)
    handler := NewHandler(svc)

    req := httptest.NewRequest(http.MethodGet, "/users/1", nil)
    w := httptest.NewRecorder()
    handler.ServeHTTP(w, req)

    // assert...
}

5. Производительность тестов и Mechanical Sympathy

Большой набор тестов создает давление на сборщик мусора. Каждый httptest.NewRequest аллоцирует структуру Request, заголовки и bytes.Buffer. NewRecorder аллоцирует внутренний буфер ответа.

Оптимизации для high-coverage проектов:

  • Переиспользование рекордеров: httptest.NewRecorder не потоко-безопасен, но в синхронных табличных тестах можно инициализировать его один раз и очищать w.Body.Reset() между кейсами, экономя на аллокациях кучи.
  • Избегание реальных сериализаторов в тестах: Если тестируется только логика маршрутизации или мидлвары, заменяйте json.NewEncoder(w) на прямой w.Write([]byte({"ok":true})). Это убирает аллокации reflect и парсинга.
  • t.Parallel() и I/O блокировки: t.Parallel() выносит тест в пул горутин. Если тест упирается в мьютекс базы данных или файл, планировщик будет тратить циклы на переключение _Gwaiting → _Grunnable. Используйте t.Parallel() только для изолированных in-memory тестов. Интеграционные тесты с реальным Postgres/Redis должны запускаться последовательно или через testcontainers-go с ограниченным пулом воркеров.

6. Ловушки production-тестирования

  • Незакрытые тела ответов: При использовании httptest.NewServer и http.DefaultClient.Do() всегда вызывайте resp.Body.Close(). Иначе файловые дескрипторы утекут, и на CI-раннере закончатся ulimit -n.
  • Тестирование приватных методов через export_test.go: В Го допустимо создавать файл handler_export_test.go в том же пакете, экспортирующий приватные функции для тестов. Это чище, чем рефлексия, но должно применяться умеренно. Лучше тестировать через публичный контракт.
  • Хардкод портов: Никогда не используйте фиксированные порты вроде :8080 в тестах. Это приводит к address already in use в параллельных пайплайнах. httptest.NewServer(":0") или NewServer("") автоматически выделяет свободный порт.
  • Различие сред: Тесты должны работать одинаково в Linux, macOS и CI. Избегайте зависимостей от специфичных путей файловой системы или os.UserHomeDir.

7. Итог

  1. httptest из стандартной библиотеки покрывает 95% потребностей. Внешние фреймворки для HTTP-тестов в Го избыточны.
  2. NewRecorder для юнит-тестов хендлеров и мидлвар, NewServer для интеграции с http.Client и сетевыми сценариями.
  3. Всегда используйте table-driven tests с t.Run и t.Parallel() для изоляции и ускорения сьютов.
  4. Мокируйте зависимости через интерфейсы и ручные структуры, избегая тяжелой рефлексии.
  5. Контролируйте аллокации и t.Parallel() гонки данных с помощью -race флага и переиспользования буферов.
  6. Тестирование паник, таймаутов и закрытия resp.Body — обязательные атрибуты надежного сьюта.

Следующая статья: 35. Интеграционные тесты