3. Middleware тестирование

Первая линия обороны

В предыдущей главе 2. Тестирование handler функций мы рассматривали тестирование прикладных обработчиков в предположении, что входящий запрос уже благополучно преодолел сетевой периметр и доставлен прямо к порогу бизнес-логики. Однако в архитектуре реальных production-систем хэндлер — это лишь внутренняя сердцевина слоеной луковицы. Прежде чем поток управления коснется контроллера, входящий HTTP-пакет проходит через эшелонированную цепочку промежуточного программного обеспечения — Middleware.

Мидлвари реализуют классический архитектурный паттерн Chain of Responsibility (Цепочка обязанностей). На их плечи ложится вся сквозная инфраструктурная забота:

  • Проверка аутентификации и декодирование JWT-токенов.
  • Авторизация и разграничение прав доступа (RBAC).
  • Структурированное логирование и распределенный трейсинг (OpenTelemetry / W3C TraceContext).
  • Защита от перегрузок и атак типа DDoS (Rate Limiting).
  • Восстановление после аварийных ситуаций (Panic Recovery).
  • Обогащение контекста запроса метаданными (Correlation ID, профиль пользователя).

Масштаб ответственности колоссален: ошибка в прикладном хэндлере затронет один конкретный сценарий одного пользователя. Ошибка в слое Auth-мидлвари приведет к катастрофической утечке конфиденциальных данных или полному отказу (Downtime) всей платформы. Поэтому изоляция и строгое модульное тестирование каждого звена этой цепочки — первостепенная инженерная обязанность.


Архитектура Middleware в Go

Канонический мидлварь в стандартной библиотеке Go представляет собой функцию высшего порядка с предельно выразительной сигнатурой: она принимает следующий по цепочке интерфейс http.Handler и возвращает новый обернутый http.Handler (как правило, используя адаптер http.HandlerFunc):

Go
type Middleware func(http.Handler) http.Handler
flowchart LR
    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
    Client["Клиентский HTTP Запрос"]:::entry -->|"Транспорт"| MW["Middleware (Auth / Logging)"]
    MW -->|"Успех: next.ServeHTTP(w, r)"| Handler["Прикладной Handler"]
    MW -.->|"Ошибка 401: Short-circuit (return)"| Client
    Handler -->|"HTTP 200 OK"| MW
    MW -->|"Финальный ответ"| Client
  

Любой промежуточный обработчик реализует один из двух сценариев поведения:

  1. Сквозной пропуск (Pass-through): Запрос полностью валиден. Мидлварь обогащает контекст (r.WithContext(ctx)) и делегирует выполнение следующему звену вызовом next.ServeHTTP(w, r).
  2. Короткое замыкание (Short-circuit): Запрос не прошел проверку (отсутствует токен, превышена квота запросов). Мидлварь фиксирует код ошибки в объекте w, отправляет ответ клиенту и немедленно прерывает исполнение оператором return, не позволяя запросу дойти до бизнес-кода.

Стратегия тестирования: Dummy Handler

Как протестировать отдельный мидлварь в строгой изоляции — без подключения реальной базы данных, без развертывания роутера и без вызова тяжелых прикладных сервисов?

Для этого применяется проверенный паттерн Dummy Handler (Фиктивный обработчик-шпион).

Идея заключается в создании минимальной заглушки для аргумента next. Этот фиктивный хэндлер выполняет единственную задачу: он фиксирует факт своего вызова, изменяя булеву переменную (wasCalled = true), и при необходимости сохраняет данные из контекста для последующей проверки.

Обернув эту тестовую заглушку в наш мидлварь, мы запускаем его через httptest и математически строго проверяем:

  • Был ли передан контроль дальше при валидных данных?
  • Была ли цепочка жестко разорвана при ошибочном запросе?

Реализация: Auth Middleware

Спроектируем компактный, но строгий мидлварь проверки Bearer-токена:

Go
package api

import (
    "context"
    "net/http"
    "strings"
)

// Кастомный строковый тип для ключа контекста исключает коллизии в сторонних пакетах
type contextKey string

const UserIDKey contextKey = "userID"

// AuthMiddleware проверяет авторизационный заголовок и внедряет UserID в контекст
func AuthMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        authHeader := r.Header.Get("Authorization")
        if authHeader == "" {
            http.Error(w, "missing token", http.StatusUnauthorized)
            return // КРИТИЧЕСКИ ВАЖНО: немедленный возврат!
        }

        parts := strings.Split(authHeader, " ")
        if len(parts) != 2 || parts[0] != "Bearer" || parts[1] != "secret-token" {
            http.Error(w, "invalid token", http.StatusUnauthorized)
            return // Прерываем цепочку
        }

        // Создаем новый контекст, содержащий извлеченный идентификатор
        ctx := context.WithValue(r.Context(), UserIDKey, 42)

        // Делегируем выполнение дальше, передавая запрос с обновленным контекстом
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

Пишем Table-Driven Test

Воспользуемся инструментарием httptest (детально изученным в 1. net/http/httptest пакет) и организуем исчерпывающий табличный сьют (4. Table driven tests):

Go
package api_test

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

    "github.com/stretchr/testify/require"
    "yourproject/internal/api"
)

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

    // 1. Описание таблицы тестовых сценариев
    type testCase struct {
        name           string
        authHeader     string
        expectedStatus int
        expectedNext   bool // Ожидается ли передача управления следующему звену?
    }

    testCases := []testCase{
        {
            name:           "Успешная авторизация по валидному Bearer токену",
            authHeader:     "Bearer secret-token",
            expectedStatus: http.StatusOK,
            expectedNext:   true,
        },
        {
            name:           "Отказ: Заголовок Authorization полностью отсутствует",
            authHeader:     "",
            expectedStatus: http.StatusUnauthorized,
            expectedNext:   false,
        },
        {
            name:           "Отказ: Передан некорректный секретный токен",
            authHeader:     "Bearer hacker-token",
            expectedStatus: http.StatusUnauthorized,
            expectedNext:   false,
        },
        {
            name:           "Отказ: Неверная схема авторизации (Basic вместо Bearer)",
            authHeader:     "Basic secret",
            expectedStatus: http.StatusUnauthorized,
            expectedNext:   false,
        },
    }

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

            // 1. Инициализируем Dummy Handler-шпион
            var nextCalled bool
            var contextUserID any

            nextHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
                nextCalled = true
                // Извлекаем значение из контекста для строгой верификации
                contextUserID = r.Context().Value(api.UserIDKey)
                w.WriteHeader(http.StatusOK)
            })

            // 2. Оборачиваем шпиона в тестируемый мидлварь
            handlerToTest := api.AuthMiddleware(nextHandler)

            // 3. Формируем входящий запрос и рекордер
            req := httptest.NewRequest(http.MethodGet, "/secure-resource", nil)
            if tc.authHeader != "" {
                req.Header.Set("Authorization", tc.authHeader)
            }
            rec := httptest.NewRecorder()

            // 4. Act: Запускаем исполнение цепочки в памяти
            handlerToTest.ServeHTTP(rec, req)

            // 5. Assert: Верифицируем сетевой контракт
            res := rec.Result()
            defer res.Body.Close()

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

            // КЛЮЧЕВАЯ ПРОВЕРКА: сработал ли Short-circuit?
            require.Equal(t, tc.expectedNext, nextCalled,
                "Поведение передачи управления в next.ServeHTTP не соответствует спецификации")

            // Проверяем корректность инъекции контекста при успешном сценарии
            if tc.expectedNext {
                require.NotNil(t, contextUserID, "UserID обязан присутствовать в контексте")
                require.Equal(t, 42, contextUserID)
            }
        })
    }
}

Порядок мидлварей (Chain Ordering)

В реальных приложениях мидлвари собираются в длинные конвейеры с помощью мультиплексоров (например, метод chi.Use в роутере Chi) или вложенных функциональных композиций вида Log(Recover(Auth(handler))).

Хотя юнит-тесты изолированных мидлварей подтверждают надежность каждого блока по отдельности, они не гарантируют целостности собранной воедино цепочки. Чтобы убедиться, что роутер, стек мидлварей и контроллеры взаимодействуют гармонично, необходимо переходить к тестированию всего REST API в сборе.

В следующей лекции мы объединим все компоненты воедино и разберем сквозное тестирование контрактов: 4. Тестирование REST API.