1. Навіщо unit‑тестувати обробники
Коли ви вперше пишете HTTP‑сервер, дуже хочеться «перевіряти, як користувач»: підняли сервер, потицяли curl, пораділи, закомітили. Проблема в тому, що такий підхід швидко стає повільним і нервовим: сервер треба запускати, порти можуть конфліктувати, а тести перетворюються на мініінтеграцію навіть там, де вам потрібно перевірити лише одну гілку if.
Unit‑тест обробника — це спосіб перевірити логіку відповіді напряму: який статус виставили, які заголовки додали, який JSON насправді пішов у тіло відповіді. І все це — без сокетів, без реальної мережі й без магії. Ми буквально викликаємо ServeHTTP і дивимося на результат.
Що таке unit‑тест обробника: прямий виклик ServeHTTP
Важливо правильно уявити картину: обробник — це не «щось, що живе тільки в сервері». Це звичайний об’єкт, який уміє обслуговувати запит. Інтерфейс http.Handler — це лише метод ServeHTTP(w, r). Отже, щоб протестувати обробник, нам не потрібен сервер. Нам потрібен об’єкт, схожий на ResponseWriter, і запит *http.Request.
Схема виходить майже кумедною своєю простотою:
flowchart LR
T[Тест] -->|готує| RQ[*http.Request]
T[Тест] -->|готує| RR[Записувач відповіді]
T[Тест] -->|викликає| H[handler.ServeHTTP]
H --> RR
T[Тест] -->|перевіряє| RR
У цьому й полягає «фішка» net/http/httptest: він дає нам інструменти, щоб одночасно удавати клієнта й сервер, не виходячи з процесу тестування.
2. Інструменти: httptest.NewRecorder і httptest.NewRequest
httptest.NewRecorder: «памʼять» замість справжнього ResponseWriter
http.ResponseWriter у реальному сервері пише дані в мережу. У тесті мережа не потрібна: нам потрібно зберегти те, що обробник намагався відправити клієнту. Для цього є httptest.ResponseRecorder. Він накопичує статус‑код, заголовки й тіло відповіді в пам’яті, щоб тест потім міг усе прочитати та порівняти з очікуванням.
Мінімальна схема така: створюємо recorder, викликаємо обробник, а потім читаємо rr.Code, rr.Header() і rr.Body.String() (або декодуємо JSON із rr.Body). Це як чек після покупки: ви не сперечаєтеся з касиром біля каси, а спокійно дивитеся в чек і розумієте, що саме вам пробили.
httptest.NewRequest: збираємо *http.Request без справжнього клієнта
Щоб обробник міг щось обробити, йому потрібен запит: метод, URL, тіло, заголовки. У реальному житті запит приходить по мережі. У тесті ми створюємо його самі через httptest.NewRequest(method, target, body). Це зручніше й безпечніше, ніж намагатися вручну зібрати http.Request як конструктор LEGO без інструкції.
Особливо важливо пам’ятати про body: якщо це JSON, то ми зазвичай передаємо strings.NewReader(...) або bytes.NewBufferString(...). А якщо обробник очікує заголовок Content-Type, ми виставляємо його самі — інакше тест буде схожий на «я не пристебнувся, але хочу перевірити подушку безпеки».
3. Що перевіряти у відповіді обробника
У HTTP‑тестах новачок часто перевіряє лише тіло відповіді. Це майже завжди помилка: тіло може бути правильним, але статус — ні, і клієнт або проксі інтерпретують відповідь інакше. Або статус правильний, але Content-Type не той, і фронтенд раптом не розуміє, що це JSON.
Практичний мінімум для JSON‑відповіді майже завжди такий:
| Що перевіряємо | Де дивитися | Чому це важливо |
|---|---|---|
| Статус‑код | |
Це основний сигнал результату операції |
|
|
Клієнт розуміє формат відповіді |
| JSON‑структура | |
Перевіряємо зміст, а не форматування |
І так: порівнювати JSON «рядок у рядок» — крихко. Пробіли, порядок полів, перенесення рядків — усе це може змінюватися, а контракт API при цьому не порушується. Тому ми декодуємо JSON у структуру й порівнюємо поля.
4. Заготовка API та обгортка помилки
Щоб тести були коротшими, у реальному застосунку корисно централізувати запис помилок: обробник робить свою роботу й повертає помилку, а спільна обгортка вирішує, який статус і яку обгортку помилки відправити. Такий патерн давно відомий у Go‑спільноті: функція-обробник повертає error, а ServeHTTP один раз однаково обробляє помилку для всіх маршрутів.
Нижче — компактні шматочки того, що нам потрібно для тестів.
Типи обгортки помилки (те, що клієнт побачить у JSON):
package httpapi
type apiError struct {
Code string `json:"code"`
Message string `json:"message"`
Fields map[string]string `json:"fields,omitempty"`
}
type errorEnvelope struct {
Error apiError `json:"error"`
}
Скелет підходу «обробник повертає помилку» — ідея така: обгортка сама реалізує ServeHTTP:
package httpapi
import "net/http"
type appHandler func(http.ResponseWriter, *http.Request) error
func (fn appHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if err := fn(w, r); err != nil {
writeError(w, err)
}
}
І ще нам потрібні доменні або прикладні види помилок, щоб мапити їх у validation/not_found/internal. Тут ми спираємося на те, що в Go помилки — це значення, і їх часто порівнюють або розпізнають за типом чи через errors.Is.
package httpapi
import "errors"
var ErrNotFound = errors.New("not found")
type ValidationError struct {
Fields map[string]string
}
func (e ValidationError) Error() string { return "validation error" }
Далі writeError має видати правильний статус і envelope. Ми не будемо робити його величезним: ми тестуємо ідею, а не пишемо «товстого монстра»:
package httpapi
import (
"encoding/json"
"errors"
"net/http"
)
func writeError(w http.ResponseWriter, err error) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
if errors.Is(err, ErrNotFound) {
w.WriteHeader(http.StatusNotFound)
_ = json.NewEncoder(w).Encode(errorEnvelope{Error: apiError{
Code: "not_found", Message: "resource not found",
}})
return
}
if v, ok := err.(ValidationError); ok {
w.WriteHeader(http.StatusBadRequest)
_ = json.NewEncoder(w).Encode(errorEnvelope{Error: apiError{
Code: "validation", Message: "invalid request", Fields: v.Fields,
}})
return
}
w.WriteHeader(http.StatusInternalServerError)
_ = json.NewEncoder(w).Encode(errorEnvelope{Error: apiError{
Code: "internal", Message: "internal error",
}})
}
Зверніть увагу на важливе правило безпеки: для 500 ми не віддаємо err.Error() назовні. Повідомлення фіксоване. Внутрішню причину ми залишимо логам, але це не тема сьогоднішньої лекції — сьогодні ми саме тестом закріпимо, що витоку немає.
5. Приклади unit‑тестів
Перший unit‑тест: простий обробник, який повертає 204
Почати краще з максимально простого: обробник без JSON, без помилок, без тіла. Чому? Бо спочатку ви відпрацьовуєте механіку NewRecorder + NewRequest + ServeHTTP, а вже потім додаєте JSON і обгортку помилки. Це як вчитися їздити: спочатку порожнє паркування, а вже потім місто.
Ось мікроприклад обробника й тесту:
package httpapi
import "net/http"
func health(w http.ResponseWriter, r *http.Request) error {
w.WriteHeader(http.StatusNoContent)
return nil
}
package httpapi
import (
"net/http"
"net/http/httptest"
"testing"
)
func TestHealth_NoContent(t *testing.T) {
rr := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/health", nil)
appHandler(health).ServeHTTP(rr, req)
if rr.Code != http.StatusNoContent {
t.Fatalf("status=%d, want %d", rr.Code, http.StatusNoContent)
}
}
Тут ми тестуємо статус. Усе. Не треба ускладнювати. Якщо цей тест не проходить, у вас проблема з базовою механікою.
Unit‑тест успішної JSON‑відповіді: перевіряємо Content-Type і структуру
Коли статус‑коди вже не лякають, додамо JSON‑відповідь. Нехай буде обробник ping, який повертає { "ok": true }. Ми спеціально не робимо тут «задачі», щоб не відволікатися на бізнес-логіку: ціль — навчитися перевіряти JSON структурно.
Обробник:
package httpapi
import (
"encoding/json"
"net/http"
)
type okResp struct {
OK bool `json:"ok"`
}
func ping(w http.ResponseWriter, r *http.Request) error {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
return json.NewEncoder(w).Encode(okResp{OK: true})
}
Тест:
package httpapi
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestPing_OK(t *testing.T) {
rr := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/ping", nil)
appHandler(ping).ServeHTTP(rr, req)
if rr.Header().Get("Content-Type") != "application/json; charset=utf-8" {
t.Fatalf("Content-Type=%q", rr.Header().Get("Content-Type"))
}
var got okResp
_ = json.NewDecoder(rr.Body).Decode(&got)
if !got.OK {
t.Fatalf("ok=false, want true")
}
}
Так, тут ми спрощено ігноруємо помилку декодування (_ = ...), лише щоб код був компактним. Трохи нижче ми зробимо нормальний хелпер, який падатиме красиво й чесно.
Перевіряємо обгортку помилки: validation і fields
Тепер найцікавіше: тестуємо помилку як контракт. Для цього потрібен обробник, який може повернути ValidationError. Нехай це буде «створити задачу»: приймаємо JSON { "title": "..." }, і якщо title порожній — повертаємо ValidationError{Fields: ...}.
Обробник:
package httpapi
import (
"encoding/json"
"net/http"
)
type createTaskReq struct {
Title string `json:"title"`
}
func createTask(w http.ResponseWriter, r *http.Request) error {
var req createTaskReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
return ValidationError{Fields: map[string]string{"body": "invalid json"}}
}
if req.Title == "" {
return ValidationError{Fields: map[string]string{"title": "must not be empty"}}
}
w.WriteHeader(http.StatusCreated)
return nil
}
Тест має перевірити три речі: статус 400, Content-Type, а також структуру envelope і наявність fields.title.
package httpapi
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestCreateTask_EmptyTitle_ValidationEnvelope(t *testing.T) {
rr := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/tasks",
strings.NewReader(`{"title":""}`))
appHandler(createTask).ServeHTTP(rr, req)
if rr.Code != http.StatusBadRequest {
t.Fatalf("status=%d, want %d", rr.Code, http.StatusBadRequest)
}
}
Цього поки недостатньо: ми ще не перевірили JSON. Додамо декодування через хелпер трохи пізніше, а поки покажу напряму, щоб було зрозуміло, що саме ми хочемо витягнути:
package httpapi
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestCreateTask_EnvelopeFields(t *testing.T) {
rr := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/tasks",
strings.NewReader(`{"title":""}`))
appHandler(createTask).ServeHTTP(rr, req)
var env errorEnvelope
if err := json.NewDecoder(rr.Body).Decode(&env); err != nil {
t.Fatalf("decode: %v", err)
}
if env.Error.Code != "validation" {
t.Fatalf("code=%q, want %q", env.Error.Code, "validation")
}
if env.Error.Fields["title"] != "must not be empty" {
t.Fatalf("fields.title=%q", env.Error.Fields["title"])
}
}
Оце вже unit‑тест контракту: клієнту не важливо, де саме ви перевіряли title — клієнту важливо, що він отримав зрозумілу структуру помилки.
Перевіряємо 500: «не витекла внутрішня причина»
Одна з найнеприємніших помилок в API — повернути користувачу текст на кшталт "pq: password authentication failed for user..." або "dial tcp ... connection refused". Користувач усе одно не полагодить ваш Postgres, а от зловмисник буде радий. Тому 500‑відповідь має бути стабільною за повідомленням, а внутрішні деталі — тільки в логах.
Зробімо обробник, який імітує внутрішню помилку:
package httpapi
import (
"errors"
"net/http"
)
func alwaysFails(w http.ResponseWriter, r *http.Request) error {
return errors.New("db exploded: boom")
}
І тест: статус 500, code=internal, message=internal error, і головне — відсутність "db exploded" у message.
package httpapi
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestInternalError_MessageIsStable(t *testing.T) {
rr := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/x", nil)
appHandler(alwaysFails).ServeHTTP(rr, req)
var env errorEnvelope
_ = json.NewDecoder(rr.Body).Decode(&env)
if rr.Code != http.StatusInternalServerError {
t.Fatalf("status=%d", rr.Code)
}
if env.Error.Message != "internal error" {
t.Fatalf("message=%q", env.Error.Message)
}
if env.Error.Message == "db exploded: boom" {
t.Fatalf("leaked internal error to client")
}
}
Так, остання перевірка виглядає трохи іграшково, але як навчальний тест вона чудово фіксує правило: назовні — стабільний текст.
Тестові хелпери: робимо перевірки читабельними
Коли тестів стає більше ніж три, у них з’являється повтор: «декодуй JSON», «перевір статус», «перевір Content-Type». Якщо копіювати це вручну, тести роздуваються й починають виглядати як бухгалтерський звіт за квартал. Тому ми пишемо маленькі helper-функції й позначаємо їх t.Helper() — тоді при падінні тесту Go покаже рядок виклику helper’а, а не його нутрощі.
Helper для JSON‑декодування:
package httpapi
import (
"encoding/json"
"io"
"testing"
)
func mustJSON(t *testing.T, r io.Reader, dst any) {
t.Helper()
if err := json.NewDecoder(r).Decode(dst); err != nil {
t.Fatalf("decode json: %v", err)
}
}
Helper для заголовка:
package httpapi
import "testing"
func mustCTJSON(t *testing.T, ct string) {
t.Helper()
if ct != "application/json; charset=utf-8" {
t.Fatalf("Content-Type=%q", ct)
}
}
І тепер тест на validation стає коротшим і приємнішим:
func TestCreateTask_Validation(t *testing.T) {
rr := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/tasks",
strings.NewReader(`{"title":""}`))
appHandler(createTask).ServeHTTP(rr, req)
if rr.Code != http.StatusBadRequest {
t.Fatalf("status=%d", rr.Code)
}
mustCTJSON(t, rr.Header().Get("Content-Type"))
var env errorEnvelope
mustJSON(t, rr.Body, &env)
if env.Error.Fields["title"] == "" {
t.Fatalf("expected fields.title")
}
}
Так тест читається як історія: «зробив запит → отримав відповідь → перевірив статус → перевірив заголовок → перевірив JSON». Майже як нормальна людина, а не як компілятор.
Table-driven unit‑тести: кілька кейсів однією функцією
Коли в обробника багато гілок, зручно тримати їх в одному тесті таблицею: вхід → очікуваний статус → очікуваний code. Це економить час і допомагає не забути про сценарії. При цьому важливо не перетворювати table-driven тест на монстра на 200 рядків: краще один обробник — одна таблиця, і нехай кейсів буде стільки, скільки реально потрібно для покриття гілок.
Приклад: тестуємо writeError через обробник, який повертає різні помилки. Так, ми тестуємо через appHandler, бо саме він формує відповідь клієнту, а це і є наш контракт.
func TestErrors_MappedToEnvelope(t *testing.T) {
cases := []struct {
name string
err error
want int
code string
}{
{"notfound", ErrNotFound, http.StatusNotFound, "not_found"},
{"validation", ValidationError{Fields: map[string]string{"x": "bad"}}, http.StatusBadRequest, "validation"},
{"internal", errors.New("x"), http.StatusInternalServerError, "internal"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
h := appHandler(func(w http.ResponseWriter, r *http.Request) error { return tc.err })
rr := httptest.NewRecorder()
h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/x", nil))
var env errorEnvelope
mustJSON(t, rr.Body, &env)
if rr.Code != tc.want || env.Error.Code != tc.code {
t.Fatalf("status=%d code=%q", rr.Code, env.Error.Code)
}
})
}
}
Цей тест дуже прикладний: він не сперечається про те, як улаштована внутрішня типізація помилок; він фіксує зовнішній контракт — яка помилка на що перетворюється на HTTP‑межі.
6. Типові помилки під час unit‑тестування обробників
Помилка № 1: перевіряють лише тіло відповіді й ігнорують статус‑код.
Це пастка «я бачу JSON, значить усе добре». На практиці клієнту часто важливіший статус: 201 vs 200, 204 без тіла, 400 vs 422 — це різні смисли. Звикайте в кожному тесті починати з rr.Code, а вже потім лізти в JSON.
Помилка № 2: порівнюють JSON як рядок.
Сьогодні encoder поставив пробіли інакше, завтра ви змінили порядок полів (або додали omitempty), і тест падає, хоча контракт не зламано. Декодуйте JSON у структуру (struct) або в map[string]any і перевіряйте поля. Це і надійніше, і зрозуміліше.
Помилка № 3: забувають про Content-Type.
Якщо ви робите JSON API, то Content-Type — частина договору. Без нього клієнти й проксі можуть поводитися інакше, а деякі HTTP‑клієнти взагалі не намагатимуться декодувати JSON автоматично. У тестах перевірка заголовка займає один рядок, а економить години відлагодження.
Помилка № 4: намагаються unit‑тестом перевірити роутинг і {id} одночасно.
Unit‑тест обробника — це про логіку відповіді конкретної функції або об’єкта. Щойно ви починаєте перевіряти маршрутизацію, ви непомітно перетворюєте тест на «майже інтеграційний». Це не завжди погано, але методично краще розділяти: unit — про обробник, окремо — про «склейку». Інакше тести стають каламутними: падає — і незрозуміло що.
Помилка № 5: не використовують t.Helper(), і помилки читаються як криптограма.
Без t.Helper() падіння тесту часто вказує на рядок усередині helper-функції, а не на місце, де її викликали. У маленькому проєкті це ще терпимо, але в реальному ви витрачаєте час на «де ж я це викликав». Позначайте хелпери, і тест-репорти будуть людянішими.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ