---
title: "Тестирование HTTP-хендлеров"
description: "Тестирование gin: httptest.NewRequest и NewRecorder, свой PerformRequest, мок-Store через интерфейс, table-driven, multipart-тело и CreateTestContext для звеньев."
course: "Gin: самый популярный веб-фреймворк Go"
url: "https://golangmaster.ru/courses/gin/lessons/21-testirovanie/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 20
---

# Тестирование HTTP-хендлеров

*Курс «Gin: самый популярный веб-фреймворк Go», урок 21 из 25. ~20 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/gin/lessons/21-testirovanie/*

Урок [WebSocket из прошлого шага](/courses/gin/lessons/20-websocket/) мы закрыли обещанием: даже апгрейд-хендлер тестируется без браузера — через httptest.NewServer и Dialer из той же gorilla. Пришло время выполнить и его: тестирование gin строится на том, что Engine реализует http.Handler — это мы заметили ещё в [уроке про первый сервер](/courses/gin/lessons/02-pervyj-server/), и теперь извлечём из факта всю пользу. Никаких портов, goroutine-диспетчеров и ожиданий: httptest gin собирает запрос в памяти, подсовывает его роутеру через ServeHTTP и читает ответ из рекордера. Разберём пару NewRequest/NewRecorder и RemoteAddr 192.0.2.1, соберём свой PerformRequest по образцу внутреннего хелпера самого gin, пропишем мок-зависимости через интерфейс Store, накроем API таблицей кейсов, добьёмся 401 от гварда с просроченным токеном из [урока 15-jwt](/courses/gin/lessons/15-jwt/) и соберём multipart-тело из [урока про файлы](/courses/gin/lessons/13-fajly/) — обещание, которое мы там дали. Отдельный инструмент — gin.CreateTestContext для звеньев и c.Set/c.Get. К концу урока хендлеры будут проверяться быстрее, чем поднимается сервер, а конверт ошибок из [урока 17-oshibki](/courses/gin/lessons/17-oshibki/) станет ассершном.

### Тестирование gin: сервер не нужен

Классическая ошибка новичка — тестировать API через запуск сервера: поднять на :8080, дернуть http.Get, надеяться, что порт свободен, ждать, пока gin проснётся, убивать горутину. Всё это не нужно, потому что gin.Engine — обычный http.Handler. ServeHTTP(w, r) — точка входа, в которую r.Run передаёт каждый настоящий запрос; ничто не мешает вызвать её руками с поддельными Writer и Request, и вся цепочка — звенья, роутинг, биндинг, хендлер — отработает в тестовой горутине как есть. TCP-стек, планировщик соединений, порты, таймауты_accept — всё это лежит в слое http.Server, который мы в тест просто не включаем. Отсюда три свойства, которые делают httptest gin-золотым стандартом: детерминированность (нет сети — нет флаков), скорость (тысячи запросов в секунду в одном процессе) и изоляция (каждый тест собирает свой роутер с нужными зависимостями). Полезная оговорка: httptest-тесты проверяют хендлеры и звенья, но не транспорт — заголовки Transfer-Encoding, поведение TLS и gzip-сжатие реального сервера остаются за кадром. Для этого есть httptest.NewServer, но о нём ближе к концу урока: девять из десяти тестов хендлеров в recorder-подходе не нуждаются.

### httptest.NewRequest и NewRecorder

Пакет net/http/httptest из стандартной библиотеки даёт две детали конструктора. httptest.NewRequest(method, target, body) собирает *http.Request без сети: target — путь с query, как в curl; method — константы http.MethodGet и компания; body — io.Reader или nil. Ошибок метод не возвращает: кривой метод или путь — это паника на месте, что в тесте даже честнее, чем ветка с t.Fatal. Две важные мелочи. Первая: RemoteAddr у такого запроса — 192.0.2.1:1234, адрес из диапазона TEST-NET, зарезервированного под документацию, — значит c.ClientIP() из тестов всегда один и тот же, и per-IP ведра из [урока 18](/courses/gin/lessons/18-rate-limit/) во всех тестах делят одно и то же ведро; переписать RemoteAddr — одна строка в хелпере, увидим ниже. Вторая: Host равен хосту из абсолютного URL, а для относительного target — константе example.com: запрос в тесте всегда приходит с этим хостом, и звенья, сверяющие домен, увидят именно его. httptest.NewRecorder() возвращает *httptest.ResponseRecorder — писатель, который ничего не отправляет, а накапливает: поле Code — финальный статус, Body — bytes.Buffer с телом, Header() — карта заголовков, а метод Result() собирает всё в готовую *http.Response, если удобнее проверять через неё. Пара Request+Recorder и есть весь ритуал: собрали запрос, прокрутили через router.ServeHTTP, прочитали рекордер.

### Первый тест: /ping в четырёх строках

```go
package main

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

        "github.com/gin-gonic/gin"
)

func TestMain(m *testing.M) {
        gin.SetMode(gin.TestMode) // тишина вместо [GIN-debug]
        os.Exit(m.Run())
}

func setupRouter() *gin.Engine {
        r := gin.New()
        r.GET("/ping", func(c *gin.Context) {
                c.JSON(http.StatusOK, gin.H{"message": "pong"})
        })
        return r
}

func TestPing(t *testing.T) {
        router := setupRouter()

        w := httptest.NewRecorder()
        req := httptest.NewRequest(http.MethodGet, "/ping", nil)
        router.ServeHTTP(w, req)

        if w.Code != http.StatusOK {
                t.Fatalf("статус: получен %d, нужен %d", w.Code, http.StatusOK)
        }
        if got, want := w.Body.String(), `{"message":"pong"}`; got != want {
                t.Fatalf("тело: получено %s, нужно %s", got, want)
        }
}
```

TestMain включает gin.SetMode(gin.TestMode) один раз на весь пакет — вместо [GIN-debug]-шума чистый вывод go test. setupRouter — конструктор роутера, который потом обрастёт зависимостями; каждый тест зовёт его и получает свежий Engine. Ритуал в тесте: рекордер, запрос, ServeHTTP — и проверки по w.Code и w.Body. Ни порта, ни goroutine, ни sleep: тест выполняется за микросекунды и не может зависнуть.

### Проверка: куда уходит запрос

**Вопрос:** Почему тест с router.ServeHTTP(w, req) не занимает порт 8080 и не мешает параллельному запуску сервера на той же машине?

- Рекордер слушает случайный свободный порт и пересылает запрос на него
- ✅ ServeHTTP вызывает цепочку звеньев и хендлер напрямую в тестовой горутине — TCP-стек и http.Server вообще не участвуют
- gin распознаёт тестовый режим и перенаправляет запросы в заглушку
- Запрос уходит на localhost, но перехватывается httptest-перехватчиком на уровне ядра

ServeHTTP — обычный метод интерфейса http.Handler: он находит маршрут, прогоняет звенья, зовёт хендлер и пишет в переданный ResponseWriter — здесь в буфер рекордера. Всё, что связано с портами и соединениями, живёт в http.Server, который в тесте не создаётся вовсе. Поэтому параллельно работающий реальный сервер, занятые порты и файрволы тестам безразличны — и именно поэтому сотни таких тестов проходят за секунды.

### PerformRequest: свой хелпер вместо копипасты

Три строки «рекордер, запрос, ServeHTTP» в каждом тесте быстро надоедают. В тестах самого gin живёт хелпер PerformRequest(router, method, path) — но он определён в _test-файлах пакета и наружу не экспортируется, так что в своих проектах вы пишете его аналог сами, и это правильно: хелпер на двадцать строк, который знает все ваши привычки. Что он должен уметь? Тело и заголовки: POST с JSON и Content-Type — самый частый кейс, и вкладывать их удобнее опциями вида func(*http.Request) — вариадик хвост, каждая опция доделывает запрос под себя. RemoteAddr: опция перезаписывает его перед ServeHTTP — и тесты per-IP лимитов из урока 18 перестают делить ведро 192.0.2.1. Токен: опция с Authorization: Bearer для защищённых групп. Заметьте, как связан Content-Type и биндинг из [урока 04](/courses/gin/lessons/04-binding/): ShouldBindJSON выбирает JSON-биндер именно по заголовку, забытый Content-Type даст не 201, а ошибку привязки — половина «загадочных» фейлов тестов живёт здесь. Мы назовём хелпер performRequest со строчной — из уважения к настоящему PerformRequest из исходников gin, на который теперь можно смотреть как на эталон: у него тот же скелет.

### performRequest и тест POST с биндингом

```go
func performRequest(r http.Handler, method, path string,
        opts ...func(*http.Request)) *httptest.ResponseRecorder {
        req := httptest.NewRequest(method, path, nil)
        for _, opt := range opts {
                opt(req)
        }
        w := httptest.NewRecorder()
        r.ServeHTTP(w, req)
        return w
}

func withJSONBody(s string) func(*http.Request) {
        return func(r *http.Request) {
                r.Header.Set("Content-Type", "application/json")
                r.Body = io.NopCloser(strings.NewReader(s))
        }
}

func TestCreateTodo(t *testing.T) {
        router := setupRouter()

        w := performRequest(router, http.MethodPost, "/api/v1/todos",
                withJSONBody(`{"title":"купить кофе"}`))

        if w.Code != http.StatusCreated {
                t.Fatalf("статус: %d, нужен 201; тело: %s", w.Code, w.Body.String())
        }

        var todo Todo
        if err := json.Unmarshal(w.Body.Bytes(), &todo); err != nil {
                t.Fatalf("тело не JSON: %v", err)
        }
        if todo.Title != "купить кофе" {
                t.Fatalf("title: %q", todo.Title)
        }
}
```

performRequest принимает http.Handler, а не *gin.Engine — хелпер пригодится и для чистых http.Handler. withJSONBody — именованная опция: Content-Type и io.NopCloser поверх strings.NewReader, потому что биндер читает тело и закрывает reader. В проверке тела — json.Unmarshal в структуру: сравнивать сырую строку хрупко (порядок полей, пробелы), а данные, декодированные обратно, проверять надёжно. Тело ошибки в Fatal — закон науки о тестах: не угадывай, смотри, что реально вернулось.

### Статус, если хендлер молчит

```go
r := gin.New()
r.GET("/quiet", func(c *gin.Context) {
        _ = c.Writer.Write([]byte("ok")) // WriteHeader не звали
})

w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/quiet", nil)
r.ServeHTTP(w, req)

fmt.Println(w.Code)
```

**Что выведет программа?**

- 0: статус никто не выставлял, рекордер пуст
- ✅ 200: первый Write неявно вызывает WriteHeader со статусом по умолчанию
- 500: net/http требует явного WriteHeader и карает 500-й
- 404: без WriteHeader маршрут не считается совпавшим

Правило net/http, а значит и gin: если WriteHeader не вызван явно, первая запись в тело автоматически фиксирует статус 200 OK. Рекордер честно это отражает: Code станет 200 даже без явного WriteHeader. Для тестов вывод один: «тихий» успех и явный успех неотличимы — если контракт эндпоинта требует 201 или 202, статус обязан выставляться руками, а тест должен проверять именно контракт, а не дефолт.

### Заголовки и JSON-проверки

Рекордер копит не только тело. w.Header() даёт карту заголовков ответа: проверять Content-Type после c.JSON стоит хотя бы один раз — gin пишет «application/json; charset=utf-8», и это знание спасает, когда фронтенд жалуется на «text/plain». Для куки из урока 14 — w.Result().Cookies(): рекордер отдаёт настоящую http.Response со всеми печеньками, которые хендлер поставил через SetCookie. При проверке тела JSON есть два уровня. Строковое сравнение — быстрое и приемлемое для коротких ответов, но падает на любом косметическом изменении. Декодирование в структуру или map — прочнее: вы проверяете данные, а не их формат. Отдельный приём — частичные проверки конверта ошибок: не сравнивайте весь JSON с ошибкой, ищите код. strings.Contains(w.Body.String(), "VALIDATION_FAILED") достаточно, чтобы убедиться, что сработала ветка валидации из словаря урока 17, и не сломаться при добавлении поля Details. Для настоящей строгости — распаковка в структуру Envelope и сравнение полей по одному: код точно NOT_FOUND, Details пуст, Message не раскрывает внутренностей. Так тесты документируют контракт: прочитавший их разработчик знает формат ответа лучше, чем читавший README.

### Content-Type после c.JSON

```go
r := gin.New()
r.GET("/who", func(c *gin.Context) {
        c.JSON(http.StatusOK, gin.H{"name": "анна"})
})

w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/who", nil)
r.ServeHTTP(w, req)

fmt.Println(w.Header().Get("Content-Type"))
```

**Что выведет программа?**

- application/json — как и в любом JSON-ответе
- ✅ application/json; charset=utf-8 — gin добавляет кодировку сам
- text/plain; charset=utf-8 — Content-Type ставится только для c.String
- Пусто: Content-Type ставит http.Server, а не хендлер

Рендеры gin прописывают Content-Type сами, без участия http.Server: у c.JSON это application/json; charset=utf-8 (строка jsonContentType в исходниках render/json.go), у c.String — text/plain; charset=utf-8, у XML — свой. Проверка заголовка в тесте ловит случай, когда хендлер случайно пишет сырой Write или отдаёт JSON через c.Data с кривым типом — фронтенд узнает об этом позже и с куда менее понятной ошибкой.

### Table-driven: вся API в одной таблице

HTTP-хендлер — идеальный пациент для табличных тестов: вход — метод, путь, тело, заголовки; выход — статус, подстрока тела, заголовок. Таблица с анонимной структурой описывает кейсы, t.Run даёт каждому имя и изолированный вывод, а go test -run "TestTodosAPI/битое_тело" запускает один кейс по имени — отладка по regexp. Два вопроса дисциплины. Первый — состояние: если хендлеры пишут в общий стор, кейсы внутри одной таблицы влияют друг на друга — «создание» наполняет список для «списка». Либо учитывайте порядок сознательно, либо собирайте свежий роутер в каждом t.Run: конструктор дешёвый, а независимость кейсов дороже микросекунд. Второй — t.Parallel: рекордер у каждого кейса свой, но общий стор — общий; параллельте только тесты, которые не пишут состояние, или давайте каждому свой экземпляр хранилища. Ошибки в проверках формулируйте парой «получено — ожидалось»: t.Fatalf("статус: %d, нужен %d", w.Code, tt.want) — из такого вывода причина видна без запуска дебаггера. И держите в таблице хотя бы один несчастный случай: пустое тело, битый JSON, отсутствующее поле — именно они, а не счастливый путь, ломают прод.

### Таблица для /api/v1/todos

```go
func TestTodosAPI(t *testing.T) {
        tests := []struct {
                name    string
                method  string
                path    string
                body    string
                want    int
                wantErr string // код из словаря урока 17
        }{
                {"список", "GET", "/api/v1/todos", "", 200, ""},
                {"нет задачи", "GET", "/api/v1/todos/42", "", 404, "NOT_FOUND"},
                {"битое тело", "POST", "/api/v1/todos", "{oops", 400, "VALIDATION_FAILED"},
                {"пустой title", "POST", "/api/v1/todos", `{"title":""}`, 400, "VALIDATION_FAILED"},
                {"создание", "POST", "/api/v1/todos", `{"title":"кофе"}`, 201, ""},
        }

        for _, tt := range tests {
                t.Run(tt.name, func(t *testing.T) {
                        router := setupRouter() // свежий стор на каждый кейс

                        var opts []func(*http.Request)
                        if tt.body != "" {
                                opts = append(opts, withJSONBody(tt.body))
                        }

                        w := performRequest(router, tt.method, tt.path, opts...)

                        if w.Code != tt.want {
                                t.Fatalf("статус: %d, нужен %d; тело: %s",
                                        w.Code, tt.want, w.Body.String())
                        }
                        if tt.wantErr != "" &&
                                !strings.Contains(w.Body.String(), tt.wantErr) {
                                t.Fatalf("нет кода %s в теле: %s",
                                        tt.wantErr, w.Body.String())
                        }
                })
        }
}
```

Структура кейса — method, path, body, want, wantErr: всё, что отличает один HTTP-проверяемый случай от другого. Свежий setupRouter в каждом t.Run изолирует состояние — «создание» не зависит от порядка кейсов. Конверт ошибок проверяется поиском кода в теле: NOT_FOUND и VALIDATION_FAILED — ветки словаря из урока 17, и тест защищает договорённость с клиентом о формате ошибок. Имя кейса в t.Run даёт go test -run точку входа для отладки одного случая.

### Мок-зависимости: шов через интерфейс

Хендлер, который внутри сам открывает базу, протестировать изолированно нельзя — и это сигнал архитектуре. Шов делается интерфейсом: type Store interface { Get(ctx, id) (Todo, error); List(ctx) ([]Todo, error); Create(ctx, input) (Todo, error) }. Прод-реализация ходит в базу, тестовая — возвращает заготовку. Хендлер получает Store через конструктор NewHandler(s Store) и не знает, кто там под капотом — а тест собирает роутер функцией setupRouterWithStore(stubStore{...}), подставив заглушку. Заглушка в три строки: структура с полями-ответами и методы, которые их возвращают. Никаких фреймворков для моков не нужно, пока интерфейс маленький; когда методов станет десятки — посмотрите на mockery, но сначала подумайте, не слишком ли толстый интерфейс вы придумали. Что даёт заглушка? Детерминизм и все ветки ошибок: sentinel ErrNotFound из урока 17 превращается в 404 с конвертом, ErrDeadline — в 503, пустой список — в 200 с пустым массивом, а не null. В реальной базе такие сценарии пришлось бы устраивать настоящими данными и транзакциями — это уже интеграционные тесты, отдельный жанр. Здесь же — единицы хендлера: маршрутизация, биндинг, права, статус-коды, конверты. База — не его забота.

### StubStore и ветка 404

```go
type Store interface {
        Get(ctx context.Context, id int64) (Todo, error)
}

type stubStore struct {
        todo Todo
        err  error
}

func (s stubStore) Get(ctx context.Context, id int64) (Todo, error) {
        return s.todo, s.err
}

func setupRouterWithStore(s Store) *gin.Engine {
        r := gin.New()
        h := NewHandler(s) // инъекция зависимости руками
        r.GET("/api/v1/todos/:id", h.get)
        return r
}

func TestGetTodoNotFound(t *testing.T) {
        router := setupRouterWithStore(stubStore{err: ErrNotFound})

        w := performRequest(router, http.MethodGet, "/api/v1/todos/42")

        if w.Code != http.StatusNotFound {
                t.Fatalf("статус: %d, нужен 404; тело: %s", w.Code, w.Body.String())
        }
        if !strings.Contains(w.Body.String(), "NOT_FOUND") {
                t.Fatalf("нет кода NOT_FOUND: %s", w.Body.String())
        }
}
```

Хендлер зависит от интерфейса Store, а не от базы: в бою подставим Postgres-реализацию, в тесте — stubStore с err: ErrNotFound. Sentinel-ошибка домена превращается в 404 и код NOT_FOUND уже в handleError из урока 17 — тест проверяет именно этот перевод, он и есть контракт. Заглушка — значения, а не поведение: если нужен сценарий «первый вызов ок, второй ошибка», добавляйте счётчик вызовов или замыкание-функцию вместо структуры.

### Проверка: заглушка сломалась

**Вопрос:** stubStore возвращает err = ErrDBDown («база недоступна»). Хендлер прогоняет ошибку через handleError из урока 17. Что должен потребовать тест?

- 404 NOT_FOUND: для теста любая ошибка — отсутствие данных
- ✅ 500 INTERNAL: инфраструктурная ошибка по словарю становится 500 с кодом INTERNAL и без деталей
- 502 Bad Gateway: база — это upstream, значит ответ прокси
- Хендлер запаникует: Recovery вернёт 500 без конверта

Словарь ошибок из урока 17 делит ошибки на доменные (NOT_FOUND — 404, валидация — 400) и инфраструктурные (всё непонятное — 500 INTERNAL без внутренностей). Ошибка соединения с базой — класс второй: клиенту бесполезно знать про pool timeout, а серверу — светить адреса. Тест фиксирует это разделение: заглушка возвращает доменную ErrNotFound — ждём 404, инфраструктурную ErrDBDown — ждём 500 INTERNAL. Паники нет: ошибка возвращается значением, Recovery тут ни при чём.

### Звенья в изоляции: CreateTestContext

Звенья тестируются двумя способами. Первый — интеграционный: собрать роутер, повесить звено на группу с маршрутом-приманкой и прогнать performRequest — так мы тестировали гварды с токенами. Второй — хирургический: gin.CreateTestContext(w) возвращает свежий Context, привязанный к вашему рекордеру, и пустой Engine рядом. Слушайте внимательно: в контексте нет ни запроса, ни цепочки — их вы ставите руками (c.Request = httptest.NewRequest(...)), после чего зовёте звено напрямую: c.Next(), c.AbortWithStatusJSON — всё как в бою. Это идеальный инструмент для звеньев с побочками через c.Set/c.Get из [урока 07](/courses/gin/lessons/07-kastomnye-middleware/): выставили запрос, прогнали звено, прочитали c.Get("user") и w.Code. Минус — вы тестируете звено без роутинга, и это честно отражать в имени: TestAuthAbort, а не TestProtectedRoute. Для гварда из урока 15 практичнее первый способ: mint токена через jwt.NewWithClaims с тестовым секретом — тем же, что зашит в verifyKey тестового роутера, — и два запроса: валидный и просроченный. TTL минус минута — и ExpiresAt в прошлом, библиотека вернёт ErrTokenExpired, гвард — 401. Секрет в тестах — константа тестового пакета, а не продовый: JWT_SECRET из окружения в юнит-тесты не едет.

### Гвард JWT: валидный и просроченный токены

```go
var testSecret = []byte("test-secret") // тот же, что в verifyKey тестового роутера

func makeToken(ttl time.Duration) string {
        claims := UserClaims{
                Username: "анна",
                RegisteredClaims: jwt.RegisteredClaims{
                        ExpiresAt: jwt.NewNumericDate(time.Now().Add(ttl)),
                },
        }
        s, _ := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(testSecret)
        return s
}

func TestAuthGuard(t *testing.T) {
        router := setupRouter() // группа /api/v1 под Auth()

        w := performRequest(router, http.MethodGet, "/api/v1/todos",
                func(r *http.Request) {
                        r.Header.Set("Authorization", "Bearer "+makeToken(time.Minute))
                })
        if w.Code != http.StatusOK {
                t.Fatalf("валидный токен: %d; тело: %s", w.Code, w.Body.String())
        }

        w = performRequest(router, http.MethodGet, "/api/v1/todos",
                func(r *http.Request) {
                        r.Header.Set("Authorization", "Bearer "+makeToken(-time.Minute))
                })
        if w.Code != http.StatusUnauthorized {
                t.Fatalf("просроченный токен: %d, нужен 401", w.Code)
        }
}
```

makeToken — вспомогательная фабрика из урока 15: те же UserClaims и HS256, но TTL приходит параметром, и отрицательный TTL делает токен просроченным. Секрет тестовый и совпадает с тем, чем роутер проверяет подпись — иначе гвард отверг и валидный токен. Тест ловит обе стороны контракта: живой токен пропускает к хендлеру, протухший даёт 401 — а текст ошибки в теле может различаться (подсказка про refresh на expired), это отдельная проверка строки.

### Multipart-тело: обещание урока 13

В уроке про загрузку файлов мы обещали: в тестах multipart-тело собирается mime/multipart.NewWriter — время сдержать слово. Схема: байтовый буфер, NewWriter(&buf), CreateFormFile("avatar", "cat.png") возвращает writer для файловой части, в него пишутся байты; текстовые поля — mw.WriteField("note", "..."); в конце mw.Close() — он дописывает финальный boundary, и без него парсер сервера упадёт с unexpected EOF. Content-Type берут не с потолка, а из mw.FormDataContentType(): boundary генерируется writer-ом, и заголовок обязан его содержать — руками писать multipart/form-data нельзя, тест развалится на разборе. Дальше знакомый ритуал: тело через io.NopCloser(&buf) в опции, performRequest — и проверки как у обычного хендлера: статус, заголовки, содержимое ответа. Тот же приём покрывает и лимиты: хендлер с http.MaxBytesReader из урока 13 в тесте с телом побольше потолка должен отдать 413 — таблица из одного счастливого и одного обжорного кейса закрывает оба ветвления. Массив файлов из form.File["photos"] тестируется повторными CreateFormFile с одним и тем же именем поля: multipart-формат такое любит, ParseMultipartForm соберёт слайс.

### Тест загрузки аватара

```go
func TestUploadAvatar(t *testing.T) {
        router := setupRouter()

        var buf bytes.Buffer
        mw := multipart.NewWriter(&buf)
        fw, err := mw.CreateFormFile("avatar", "cat.png")
        if err != nil {
                t.Fatalf("CreateFormFile: %v", err)
        }
        fw.Write([]byte("фальшивые байты png"))
        mw.WriteField("note", "мой кот")
        mw.Close() // финальный boundary — без него unexpected EOF

        w := performRequest(router, http.MethodPost, "/api/v1/avatar",
                func(r *http.Request) {
                        r.Header.Set("Content-Type", mw.FormDataContentType())
                        r.Body = io.NopCloser(&buf)
                })

        if w.Code != http.StatusCreated {
                t.Fatalf("статус: %d; тело: %s", w.Code, w.Body.String())
        }
}
```

CreateFormFile ставит части имя avatar, имя файла cat.png и Content-Type octet-stream — ровно то, что FormFile из урока 13 ждёт на разборе. FormDataContentType возвращает multipart/form-data с настоящим boundary — единственный честный источник заголовка. mw.Close обязателен: он пишет закрывающий boundary, и ParseMultipartForm на сервере без него видит обрезанное тело. Проверки — обычные: статус 201 и, при желании, имя сохранённого файла в ответе.

### Проверка: когда нужен настоящий сервер

**Вопрос:** Чем httptest.NewServer отличается от связки NewRequest+NewRecorder, и когда без него не обойтись?

- Ничем: это просто синтаксический сахар над рекордером
- ✅ NewServer поднимает настоящий TCP-слушатель с вашим http.Handler: нужен, когда тестируется клиент или полный транспорт — редиректы, gzip, TLS, WebSocket-диалер
- NewServer нужен всегда: рекордер не умеет заголовки
- NewServer запускает сервер в отдельном процессе и ждёт готовности по TCP

NewServer завернёт ваш Engine в настоящий http.Server и будет слушать случайный свободный порт: адрес — ts.URL, закрытие — t.Cleanup(ts.Close). Это уровень интеграционных тестов: клиентский код, прокси, редиректы, gzip, TLS через NewTLSServer и — да — WebSocket: Dialer из gorilla коннектится к ts.URL, и апгрейд из урока 20 проверяется целиком. Цена — реальные сокеты, медленнее и больше условий для флаков; поэтому по умолчанию — рекордер, а NewServer — осознанное исключение.

### Соберите performRequest

Допишите хелпер: тип первого параметра, тип возврата, конструкторы и точка входа, через которую роутер обрабатывает запрос.

```go
func performRequest(r http.___, method, path string,
        opts ...func(*http.Request)) *httptest.___ {
        req := httptest.___(method, path, nil)
        for _, opt := range opts {
                opt(req)
        }
        w := httptest.New___()
        r.___(w, req)
        return w
}
```

Подсказка: Engine реализует http.Handler — хелпер принимает интерфейс; рекордер — *httptest.ResponseRecorder; запрос собирает httptest.NewRequest; обработка — через r.ServeHTTP(w, req).

Правильные ответы в пропусках: `Handler`, `ResponseRecorder`, `NewRequest`, `Recorder`, `ServeHTTP`

### Соберите жизненный цикл теста хендлера

Тест защищённого GET /api/v1/todos/42 с мок-стором. Разложите события по порядку.

Правильный порядок строк:

1. `TestMain вызывает gin.SetMode(gin.TestMode) — тишина вместо [GIN-debug]`
2. `setupRouterWithStore собирает свежий Engine с stubStore под интерфейсом Store`
3. `httptest.NewRequest(GET, "/api/v1/todos/42", nil) — запрос в памяти`
4. `Опция ставит Authorization: Bearer с токеном, подписанным тестовым секретом`
5. `httptest.NewRecorder() готовит буфер вместо сети`
6. `router.ServeHTTP(w, req) прогоняет звенья и хендлер в тестовой горутине`
7. `Гвард проверяет подпись и срок токена, кладёт пользователя через c.Set`
8. `Хендлер зовёт Store.Get: заглушка возвращает ErrNotFound`
9. `handleError отвечает конвертом с кодом NOT_FOUND и статусом 404`
10. `Тест проверяет w.Code == 404 и строку NOT_FOUND в w.Body`
11. `При несовпадении t.Fatalf печатает полученное и ожидаемое`
12. `Следующий t.Run берёт свежие рекордер и роутер — состояние не течёт`

### Карточки: тестирование gin

- **httptest.NewRequest** — Собирает *http.Request без сети: method, target (путь с query), body (io.Reader). Ошибок не возвращает — кривые аргументы это паника. RemoteAddr всегда 192.0.2.1:1234 (TEST-NET) — все тесты делят одно per-IP ведро из урока 18, переписывается вручную в опции.
- **httptest.NewRecorder** — *httptest.ResponseRecorder — ResponseWriter, который копит всё в память: Code (финальный статус), Body (bytes.Buffer), Header(). Метод Result() собирает настоящую *http.Response — удобно для Cookies() и проверки заголовков целиком.
- **performRequest-паттерн** — Внутренний хелпер PerformRequest gin не экспортирует (живёт в _test-файлах пакета) — в проектах пишут свой: req := httptest.NewRequest; opts(req); w := httptest.NewRecorder; r.ServeHTTP(w, req). Опции func(*http.Request) ставят тело, Content-Type, токен, RemoteAddr.
- **CreateTestContext** — gin.CreateTestContext(w) возвращает свежий Context и пустой Engine: c.Request ставится руками, звено зовётся напрямую — хирургический тест middleware с c.Set/c.Get без маршрутизации. Интеграционный путь — роутер с приманкой и performRequest.
- **Table-driven для хендлеров** — Кейс = {method, path, body, want, wantErr}; t.Run даёт имя и точку входа для go test -run. Свежий роутер на кейс изолирует состояние пишущих хендлеров; t.Parallel — только для не пишущих. Конверт ошибок проверяется подстрокой кода: VALIDATION_FAILED, NOT_FOUND.
- **Multipart-тело в тесте** — mime/multipart.NewWriter(&buf): CreateFormFile("avatar", "cat.png") — файловая часть, WriteField — текстовые, Close() дописывает финальный boundary (без него unexpected EOF). Content-Type — только из mw.FormDataContentType(): boundary живёт в заголовке.

> **Прогоните тесты со звуком.** go test ./... -v — полный вывод с именами кейсов; go test -run TestTodosAPI/битое_тело -v — один кейс таблицы; флаг -race обязателен для всего, что пишется параллельно, и он же поймает конкурентную запись в websocket.Conn из урока 20, если ваш тест нарвётся. Coverage: go test -cover покажет, что таблица покрыла обе ветки handleError — доменную и инфраструктурную. Скорость — ваш индикатор здоровья: если пакет тестов идёт дольше пары секунд, вы где-то спите или ходите в сеть — рекордер-тесты обязаны быть мгновенными. В браузерной песочнице сайта Go не исполняется, поэтому интерактивы здесь quiz, predict, fill, order и флешкарты, а примеры живут в локальном go test, как и весь [курс gin](/courses/gin/). Дальше — 22-struktura: когда хендлеров, звеньев и Store станет много, их придётся разложить по слоям handler/service/repository с инъекцией зависимостей — тот самый шов Store, который сегодня дал вам моки, станет осью всей архитектуры.

## Коротко: что запомнить

- gin.Engine — http.Handler: router.ServeHTTP(w, req) прогоняет звенья и хендлер в тестовой горутине без TCP, портов и ожиданий; gin.SetMode(gin.TestMode) в TestMain глушит [GIN-debug].
- Пара httptest.NewRequest + NewRecorder — весь ритуал: RemoteAddr 192.0.2.1:1234 фиксирован (переписывайте в опции для per-IP тестов), Code/Body/Header читаются из рекордера.
- PerformRequest у gin внутренний — свой хелпер с опциями func(*http.Request) повторяет его за двадцать строк: тело с Content-Type, токен Bearer, подмена RemoteAddr.
- Моки — интерфейс Store и заглушка-структура: sentinel ErrNotFound даёт 404 с NOT_FOUND, ErrDBDown — 500 INTERNAL; тест хендлера не касается базы и потому мгновенен.
- Table-driven закрывает и счастливые пути, и несчастные: свежий роутер на t.Run изолирует состояние, подстрока кода ошибки проверяет конверт из урока 17, -run отлаживает один кейс.
- Хирургия звеньев — CreateTestContext с ручным c.Request; multipart — NewWriter/CreateFormFile/FormDataContentType/Close; настоящий стек (редиректы, TLS, WS-диалер) — httptest.NewServer как осознанное исключение.

## Домашнее задание

Возьмите API задач из предыдущих уроков и доведите покрытие до боевого: setupRouterWithStore с интерфейсом Store, таблица из шести кейсов (список, отсутствие, битый JSON, пустой title, создание, 500 на ErrDBDown), проверки статуса и кодов конверта NOT_FOUND/VALIDATION_FAILED/INTERNAL. Гвард из урока 15 — тестом с makeToken: валидный TTL, просроченный TTL, подпись чужим секретом; у двух последних ожидайте 401. Звено лимита из урока 18 — тестом с подменой RemoteAddr в опции: два разных адреса, два ведра, 429 после перелива. Загрузка — тестом с multipart.NewWriter: файл, текстовое поле, и второй кейс с телом сверх MaxBytesReader на 413. Прогоните всё с -race и добейтесь полного прохода пакета быстрее двух секунд; если медленнее — найдите, где тест ждёт сеть, и замените на заглушку.

## Вопросы и ответы по уроку

### NewRecorder или NewServer — что выбирать по умолчанию?

Рекордер: он быстрее, детерминирован и покрывает логику хендлеров и звеньев. NewServer берите осознанно — когда тестируете клиентский код, полный транспорт (редиректы, gzip, TLS) или WebSocket через Dialer из gorilla, как обещали в уроке 20. Смешивать в одном тесте не стоит: уровень абстракции у них разный.

### Как тестировать middleware без маршрутов?

gin.CreateTestContext(httptest.NewRecorder()) даёт свежий Context: поставьте c.Request = httptest.NewRequest(...), вызовите звено или c.Next() — и проверяйте c.Set/c.Get, w.Code и тело. Для звеньев с завязкой на роутинг (гварды, лимиты) чаще удобнее мини-роутер с приманкой и performRequest — разница видна в именах тестов: TestAuthAbort против TestProtectedRoute.

### Нужен ли testify?

Стандартной библиотеки достаточно: t.Fatalf с парой «получено — ожидалось» читается не хуже assert.Equal. testify хорош в больших пакетах: require сокращает лесенку if, assert.ObjectsAreEqual и suites дают утилиты. Что точно не нужно в httptest-тестах — фреймворки моков: интерфейс на три метода заглушится структурой быстрее, чем пишется настройка mock-библиотеки.

### Как протестировать WebSocket-хендлер из урока 20?

Рекордер здесь бессилен: апгрейд требует настоящего соединения. Поднимите httptest.NewServer с вашим роутером, возьмите websocket.Dialer из gorilla, соединитесь с ws-адресом ts.URL (для wss — NewTLSServer и клиент с доверенным сертификатом), и гоняйте ReadMessage/WriteMessage как настоящий клиент: эхо, хаб, heartbeat и закрытие с кодом 1009 проверяются целиком. Управляйте дедлайнами теста через conn.SetReadDeadline, чтобы тест не висел при регрессе.
