Содержание урока · 11 разделов
- Тестирование gin: сервер не нужен
- httptest.NewRequest и NewRecorder
- PerformRequest: свой хелпер вместо копипасты
- Заголовки и JSON-проверки
- Table-driven: вся API в одной таблице
- Мок-зависимости: шов через интерфейс
- Звенья в изоляции: CreateTestContext
- Multipart-тело: обещание урока 13
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
Тестирование 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 в четырёх строках
GOpackage 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 и не мешает параллельному запуску сервера на той же машине?
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: ShouldBindJSON выбирает JSON-биндер именно по заголовку, забытый Content-Type даст не 201, а ошибку привязки — половина «загадочных» фейлов тестов живёт здесь. Мы назовём хелпер performRequest со строчной — из уважения к настоящему PerformRequest из исходников gin, на который теперь можно смотреть как на эталон: у него тот же скелет.
performRequest и тест POST с биндингом
GOfunc 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 — закон науки о тестах: не угадывай, смотри, что реально вернулось.
Статус, если хендлер молчит
Прочитайте код и выберите, что он выведет:
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)Заголовки и 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
Прочитайте код и выберите, что он выведет:
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"))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
GOfunc 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
GOtype 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. Что должен потребовать тест?
Звенья в изоляции: CreateTestContext#
Звенья тестируются двумя способами. Первый — интеграционный: собрать роутер, повесить звено на группу с маршрутом-приманкой и прогнать performRequest — так мы тестировали гварды с токенами. Второй — хирургический: gin.CreateTestContext(w) возвращает свежий Context, привязанный к вашему рекордеру, и пустой Engine рядом. Слушайте внимательно: в контексте нет ни запроса, ни цепочки — их вы ставите руками (c.Request = httptest.NewRequest(...)), после чего зовёте звено напрямую: c.Next(), c.AbortWithStatusJSON — всё как в бою. Это идеальный инструмент для звеньев с побочками через c.Set/c.Get из урока 07: выставили запрос, прогнали звено, прочитали c.Get("user") и w.Code. Минус — вы тестируете звено без роутинга, и это честно отражать в имени: TestAuthAbort, а не TestProtectedRoute. Для гварда из урока 15 практичнее первый способ: mint токена через jwt.NewWithClaims с тестовым секретом — тем же, что зашит в verifyKey тестового роутера, — и два запроса: валидный и просроченный. TTL минус минута — и ExpiresAt в прошлом, библиотека вернёт ErrTokenExpired, гвард — 401. Секрет в тестах — константа тестового пакета, а не продовый: JWT_SECRET из окружения в юнит-тесты не едет.
Гвард JWT: валидный и просроченный токены
GOvar 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 соберёт слайс.
Тест загрузки аватара
GOfunc 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, и когда без него не обойтись?
Соберите performRequest
Допишите хелпер: тип первого параметра, тип возврата, конструкторы и точка входа, через которую роутер обрабатывает запрос.
Соберите жизненный цикл теста хендлера
Тест защищённого GET /api/v1/todos/42 с мок-стором. Разложите события по порядку.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: тестирование gin
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. Дальше — 22-struktura: когда хендлеров, звеньев и Store станет много, их придётся разложить по слоям handler/service/repository с инъекцией зависимостей — тот самый шов Store, который сегодня дал вам моки, станет осью всей архитектуры.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- 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, чтобы тест не висел при регрессе.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Сравните с альтернативами
Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.
Изучайте дальше
Следующая ступень учебного пути
Echo: минималистичный быстрый фреймворк
Курс Echo для Go: маршруты, middleware, валидация, JWT, SSE, тестирование и деплой. 25 уроков с примерами и итоговым API-проектом.
- Строить REST API на Echo
- Использовать типизированные запросы/ответы (Request/Response)
- Управлять ошибками через echo.HTTPError
- Писать middleware, включая JWT-аутентификацию