2. Тестирование handler функций

Граница между HTTP и Бизнес-логикой

В предыдущей главе 1. net/http/httptest пакет мы исследовали низкоуровневые строительные блоки пакета net/http/httptest, научившись порождать входящие сетевые запросы и собирать ответы в буферах оперативной памяти. Теперь настало время подняться на следующий архитектурный ярус — к уровню HTTP-обработчиков (Handlers), которые в классической терминологии MVC и Clean Architecture часто называют контроллерами или первичными адаптерами.

Главная ошибка начинающих разработчиков при тестировании хэндлеров — попытка возложить на них проверку всей бизнес-логики сервиса.

Опытный инженер четко осознает границы ответственности: HTTP-хэндлер — это всего лишь транспортный переводчик. Его задача сводится к четырем базовым операциям:

  1. Десериализовать и валидировать входящие транспортные данные (разобрать параметры URL-пути, query-параметры, заголовки, тело JSON).
  2. Перевести транспортные структуры (DTO) в доменные типы и передать их в сервисный слой (Use Case / Service Layer).
  3. Принять результат или доменную ошибку и транслировать её в семантически корректный HTTP-статус (например, доменная ошибка ErrNotFound трансформируется в статус 404 Not Found, а ErrConflict — в 409 Conflict).
  4. Сериализовать ответ в байты нужного формата (JSON, XML или Protocol Buffers) и выставить необходимые HTTP-заголовки (Content-Type: application/json).

Юнит-тест хэндлера обязан тестировать только этот транспортный шлюз. Бизнес-логика, транзакции базы данных и обращение к дискам должны оставаться за пределами его внимания.


Архитектура: Инъекция зависимостей (DI)

Чтобы обработчик можно было тестировать в строгой изоляции за микросекунды, код обязан следовать принципам 8. Dependency injection для тестируемости. Если обработчик обращается к глобальным переменным базы данных или напрямую создает экземпляры клиентов внешних служб внутри тела функции, изолированное тестирование становится невозможным.

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

Go
package api

import (
    "context"
    "encoding/json"
    "errors"
    "net/http"
)

// Ошибки предметной области
var (
    ErrNotFound      = errors.New("пользователь не найден")
    ErrAlreadyExists = errors.New("пользователь с таким email уже зарегистрирован")
)

// UserService — узкий интерфейс бизнес-логики, необходимый хэндлеру
type UserService interface {
    Create(ctx context.Context, email string) (int, error)
}

// UserHandler инкапсулирует зависимость от бизнес-сервиса
type UserHandler struct {
    service UserService
}

func NewUserHandler(s UserService) *UserHandler {
    return &UserHandler{service: s}
}

// CreateUser обрабатывает запрос на создание пользователя
func (h *UserHandler) CreateUser(w http.ResponseWriter, r *http.Request) {
    var req struct {
        Email string `json:"email"`
    }

    // 1. Потоковое чтение и парсинг входящего JSON
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        w.Header().Set("Content-Type", "application/json")
        w.WriteHeader(http.StatusBadRequest)
        _, _ = w.Write([]byte(`{"error": "bad request"}`))
        return
    }

    // 2. Вызов бизнес-логики с пробросом контекста запроса
    id, err := h.service.Create(r.Context(), req.Email)
    if err != nil {
        w.Header().Set("Content-Type", "application/json")
        switch {
        case errors.Is(err, ErrAlreadyExists):
            w.WriteHeader(http.StatusConflict)
            _, _ = w.Write([]byte(`{"error": "conflict"}`))
        default:
            w.WriteHeader(http.StatusInternalServerError)
            _, _ = w.Write([]byte(`{"error": "internal"}`))
        }
        return
    }

    // 3. Формирование успешного ответа
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(http.StatusCreated)
    _ = json.NewEncoder(w).Encode(map[string]int{"id": id})
}

Анатомия Table-Driven Теста

Один HTTP-эндпоинт всегда обладает разветвленной сетью граничных сценариев: успешное создание (Happy Path), битый JSON-пакет, дубликат email, падение нижележащего хранилища. Это делает хэндлеры идеальными кандидатами для паттерна табличных тестов — 4. Table driven tests.

flowchart TD
    classDef process  fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
    classDef success  fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
    classDef error    fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
    classDef warning  fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
    classDef entry    fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
    classDef aux      fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
    classDef system   fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
    classDef data     fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
    classDef network  fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
    classDef accent   fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
    Start["Запуск Table-Driven Test"]:::entry --> InitCtrl["Инициализация gomock.Controller"]
    InitCtrl --> Loop["Итерация по тест-кейсам: t.Run(tc.name)"]
    Loop --> MockConf["Настройка ожиданий мока (setupMock)"]
    MockConf --> CreateReq["httptest.NewRequest + httptest.NewRecorder"]
    CreateReq --> Execute["Вызов handler.CreateUser(rec, req)"]
    Execute --> AssertStatus["Проверка res.StatusCode"]
    AssertStatus --> AssertBody["Семантическая проверка тела: require.JSONEq"]
    AssertBody --> Finish["t.Cleanup -> ctrl.Finish()"]
  

Для генерации мок-реализации интерфейса UserService воспользуемся gomock (актуальная библиотека: go.uber.org/mock/gomock):

Go
package api_test

import (
    "bytes"
    "context"
    "net/http"
    "net/http/httptest"
    "testing"

    "github.com/stretchr/testify/require"
    "go.uber.org/mock/gomock"

    "yourproject/internal/api"
    "yourproject/internal/mocks" // Пакет со сгенерированным MockUserService
)

func TestUserHandler_CreateUser(t *testing.T) {
    t.Parallel()

    // 1. Декларативное описание схемы сценария
    type testCase struct {
        name           string
        requestBody    string
        setupMock      func(m *mocks.MockUserService)
        expectedStatus int
        expectedJSON   string
    }

    // 2. Набор тест-кейсов
    testCases := []testCase{
        {
            name:        "Happy Path: Успешное создание",
            requestBody: `{"email": "test@example.com"}`,
            setupMock: func(m *mocks.MockUserService) {
                // Проверяем факт вызова метода с ожидаемым аргументом ровно 1 раз
                m.EXPECT().
                    Create(gomock.Any(), "test@example.com").
                    Return(42, nil).
                    Times(1)
            },
            expectedStatus: http.StatusCreated,
            expectedJSON:   `{"id": 42}`,
        },
        {
            name:        "Bad Request: Невалидный JSON",
            requestBody: `{"email": "test...`, // Синтаксически сломанный JSON
            setupMock: func(m *mocks.MockUserService) {
                // При ошибке парсинга сервис НЕ должен вызываться вовсе
            },
            expectedStatus: http.StatusBadRequest,
            expectedJSON:   `{"error": "bad request"}`,
        },
        {
            name:        "Conflict: Пользователь уже зарегистрирован",
            requestBody: `{"email": "exist@example.com"}`,
            setupMock: func(m *mocks.MockUserService) {
                m.EXPECT().
                    Create(gomock.Any(), "exist@example.com").
                    Return(0, api.ErrAlreadyExists).
                    Times(1)
            },
            expectedStatus: http.StatusConflict,
            expectedJSON:   `{"error": "conflict"}`,
        },
        {
            name:        "Internal Error: Сбой сервисного слоя",
            requestBody: `{"email": "crash@example.com"}`,
            setupMock: func(m *mocks.MockUserService) {
                m.EXPECT().
                    Create(gomock.Any(), "crash@example.com").
                    Return(0, api.ErrNotFound).
                    Times(1)
            },
            expectedStatus: http.StatusInternalServerError,
            expectedJSON:   `{"error": "internal"}`,
        },
    }

    // 3. Параллельное исполнение сценариев
    for _, tc := range testCases {
        tc := tc // Защитный захват переменной замыкания (актуально для Go < 1.22)
        t.Run(tc.name, func(t *testing.T) {
            t.Parallel()

            // Инициализируем контроллер моков
            ctrl := gomock.NewController(t)

            mockSvc := mocks.NewMockUserService(ctrl)
            tc.setupMock(mockSvc)

            handler := api.NewUserHandler(mockSvc)

            // Создаем искусственный входящий запрос и перехватчик ответа
            req := httptest.NewRequest(http.MethodPost, "/users", bytes.NewBufferString(tc.requestBody))
            rec := httptest.NewRecorder()

            // Act: Прямой вызов обработчика в памяти
            handler.CreateUser(rec, req)

            // Assert: Проверяем транспортный контракт
            res := rec.Result()
            defer res.Body.Close()

            require.Equal(t, tc.expectedStatus, res.StatusCode)

            // Семантическая валидация JSON-ответа
            require.JSONEq(t, tc.expectedJSON, rec.Body.String())
        })
    }
}

Ловушки и лучшие практики

1. require.JSONEq вместо require.Equal


2. Передача Контекста (Mechanical Sympathy)

В строке m.EXPECT().Create(gomock.Any(), "test@example.com") мы использовали обобщенный матчер gomock.Any() для проверки контекста. В простых тестах это допустимо, но в критических компонентах это скрывает опасную ошибку: потерю контекста.

Если невнимательный разработчик внутри хэндлера передаст в сервис context.Background() вместо r.Context(), то при обрыве HTTP-соединения клиентом дорогостоящий SQL-запрос в базе данных продолжит выполняться, растрачивая процессорные ресурсы сервера впустую.

Для строгой верификации проброса контекста запроса используется проверка типов или инспекция значений:

Go
m.EXPECT().
    Create(gomock.AssignableToTypeOf(context.Background()), "test@example.com").
    DoAndReturn(func(ctx context.Context, email string) (int, error) {
        // Убеждаемся, что контекст не был подменен на пустой Background
        require.NotNil(t, ctx.Done(), "Контекст обязан быть отменяемым контекстом HTTP-запроса")
        return 42, nil
    })

3. Производительность: Decoder vs Unmarshal

В теле обработчика мы применили конструкцию:

Go
json.NewDecoder(r.Body).Decode(&req)

Итог

Тестирование хэндлеров — это самый быстрый, дешевый и точный способ верификации контрактов API на уровне модульных тестов:

  1. Хэндлеры не должны тестировать бизнес-логику; их задача — адаптация транспортного протокола HTTP.
  2. Внедрение зависимостей через интерфейсы позволяет изолировать транспортный слой с помощью моков gomock.
  3. Подход Table-Driven Tests дает возможность компактно выразить десятки граничных сценариев в единой декларативной таблице.
  4. Сравнение JSON-ответов обязано производиться через семантический require.JSONEq, а не через посимвольный require.Equal.

Однако в реальных приложениях входящий запрос редко попадает в хэндлер напрямую: путь ему преграждает цепочка промежуточных перехватчиков (Middleware), отвечающих за авторизацию, аудит, сжатие и защиту от перегрузок. В следующей главе мы разберем, как элегантно тестировать этот критически важный слой: 3. Middleware тестирование.