1. Навіщо потрібні інтеграційні HTTP‑тести
Коли ви пишете unit‑тест обробника, ви тестуєте його «у вакуумі» — окрему функцію, яка отримала ResponseWriter і Request та щось записала у відповідь. Це дуже швидко й дуже корисно, але є один нюанс: у реальному житті обробник майже ніколи не живе сам по собі. Він існує у зв’язці «mux → middleware → handler», і клієнт бачить результат саме цієї зв’язки, а не «чистої» функції.
Інтеграційний тест HTTP‑шару — це коли ми піднімаємо тестовий сервер і звертаємося до нього як справжній клієнт: через http.Client, через URL, з усіма заголовками та з реальним читанням resp.Body. Такий тест ловить помилки, які unit‑тести часто пропускають: неправильний маршрут, неочікуваний 404/405, забутий middleware, відсутність Content-Type, різні формати помилок у різних частинах сервера.
Щоб не було відчуття «ще один вид тестів заради тестів», зафіксуймо думку простою таблицею:
| Що перевіряємо | Unit‑тест обробника (ResponseRecorder) | Інтеграційний тест (httptest.Server) |
|---|---|---|
| Логіку конкретного обробника | Чудово | Можна, але дорожче |
| Маршрутизацію ServeMux (patterns, {id}) | Майже ні | Так, це його робота |
| Middleware (наприклад, X-Request-ID) | Зазвичай ні | Так, звісно |
| Поведінку «як бачить клієнт» | Майже | Так, на 100 % |
| Швидкість/простоту | Максимальна | Трохи складніше, але все ще швидко |
І так, інтеграційні тести в Go — це не «важка артилерія» на 10 хвилин виконання. httptest.Server запускається всередині тесту — без Docker, без реального порту в конфігурації, без шаманства. Це хороший, практичний інструмент, який допомагає тримати HTTP‑контракт під контролем.
2. База: httptest.Server і тестований HTTP‑стек
Що таке httptest.Server
httptest.NewServer(handler) запускає міні‑HTTP‑сервер просто у вашому тесті. Він слухає на випадковому вільному порту, щоб не конфліктувати з іншими тестами та вашим локальним застосунком, віддає вам базовий URL (ts.URL) і готовий клієнт (ts.Client()), яким можна робити запити.
Важливо зрозуміти філософію: це «майже як мережа», але без зовнішнього світу. Усе відбувається в одному процесі й цілком передбачувано. Це чудовий баланс: ми тестуємо HTTP‑поведінку «як у клієнта», але не платимо за це складним оточенням.
Невелика схема, щоб картина склалася:
flowchart LR
T[Тест] -->|створює| S[httptest.Server]
S -->|ServeHTTP| H[ваш http.Handler: mux + middleware + handlers]
T -->|HTTP-запит| C["ts.Client()"]
C -->|GET/POST| S
S -->|HTTP-відповідь| C
C -->|status/headers/json| T
І одразу дисципліна, без якої все перетворюється на «тести, які інколи дивно зависають».
ts := httptest.NewServer(handler)
defer ts.Close() // закрити сервер обов’язково
Якщо забути Close, сервер може продовжити жити до завершення пакета тестів. Іноді це просто витік, а іноді — дивні ефекти, особливо якщо тестів багато.
Збираємо HTTP‑стек одним входом
Перш ніж писати інтеграційні тести, корисно мати одну функцію, яка збирає ваш HTTP‑стек повністю: mux, middleware і реєстрацію маршрутів. Тоді тести використовують рівно той самий шлях входу, що й main.
Уявімо, що в нас навчальний застосунок «tasks», і в ньому вже реалізовано HTTP‑API на кшталт:
- POST /api/v1/tasks — створити задачу
- GET /api/v1/tasks/{id} — отримати задачу
- помилки завжди повертаються у форматі error envelope
Ми зробимо збирання сервера через функцію newHTTPHandler(...). Вона повертатиме http.Handler, який ми віддамо в httptest.NewServer.
func newHTTPHandler(taskHandler http.Handler) http.Handler {
mux := http.NewServeMux()
mux.Handle("GET /api/v1/tasks/{id}", taskHandler)
mux.Handle("POST /api/v1/tasks", taskHandler)
return mux
}
Так, у реальному проєкті у вас, найімовірніше, буде TaskHandler з різними методами або окремі обробники для різних кінцевих точок. Тут важлива не архітектурна досконалість, а ідея: тест піднімає сервер так само, як його буде піднято в застосунку.
Якщо ви використовуєте middleware, наприклад request id, зручно включити його й сюди — тоді інтеграційний тест справді перевірить, що middleware працює.
Міні‑правила запитів: ts.URL, ts.Client() і resp.Body.Close()
Тепер про те, як саме робити запити.
ts.URL — це базова адреса, наприклад "http://127.0.0.1:54321". У тестах ми просто додаємо шлях рядком. Так, це склеювання рядків, але в тесті це допустимо й зручно.
ts.Client() — це вже налаштований http.Client. Для звичайного NewServer тут немає особливої магії, але звичка хороша: «сервер дає клієнта».
І найважливіший момент: resp.Body — це потік. Його треба закривати, як двері в підʼїзді: не тому, що ви злі, а тому, що так роблять дорослі.
resp, err := ts.Client().Get(ts.URL + "/api/v1/tasks/1")
if err != nil {
t.Fatalf("request: %v", err)
}
defer resp.Body.Close()
Якщо не закривати body, тести можуть почати поїдати ресурси й раптово ставати нестабільними. Найнеприємніший тип багів: «у мене на ноутбуці пройшло, а в CI впало».
3. Перевіряємо контракт API: статус, заголовки та JSON‑структуру
Інтеграційні тести особливо корисні, коли ви перевіряєте контракт, а не те, як саме зараз випадково склалося. Тому замість «порівняти body рядком» ми майже завжди робимо так:
- перевіряємо resp.StatusCode
- перевіряємо Content-Type (якщо це JSON)
- декодуємо JSON і перевіряємо поля
З типами для error envelope ми вже визначилися раніше; зафіксуймо їх у тестовому файлі (або в пакеті, якщо ви хочете перевикористовувати).
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"`
}
Для успішного сценарію візьмімо простий DTO задачі:
type taskDTO struct {
ID int `json:"id"`
Title string `json:"title"`
Done bool `json:"done"`
}
І маленький helper, щоб JSON‑помилки в тесті читалися нормально. Плюс t.Helper(), щоб тест падав «у місці виклику», а не всередині helper’а — це економить нерви й чай.
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)
}
}
До речі, сама ідея «помилки — це значення, їх потрібно програмувати, а не лише друкувати» — дуже в дусі Go. У HTTP‑шарі це проявляється так: ми працюємо з помилками структурно, а не рядками. Те саме ми робимо й у тестах: декодуємо структуру та перевіряємо поля.
4. Практика: сценарії інтеграційних HTTP‑тестів
Маршрутизація та {id}
Насамперед переконаймося, що шаблон у ServeMux справді спрацьовує, і {id} витягується коректно. Це типовий приклад того, що unit‑тест обробника не ловить (там ви можете випадково тестувати обробник, який узагалі не зареєстрований на цей шлях).
Зробімо мінімальний mux і сервер:
mux := http.NewServeMux()
mux.HandleFunc("GET /api/v1/tasks/{id}", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, r.PathValue("id"))
})
ts := httptest.NewServer(mux)
defer ts.Close()
Далі робимо запит:
resp, err := ts.Client().Get(ts.URL + "/api/v1/tasks/42")
if err != nil {
t.Fatalf("request: %v", err)
}
defer resp.Body.Close()
І перевіряємо:
b, _ := io.ReadAll(resp.Body)
if string(b) != "42" {
t.Fatalf("body=%q, want %q", string(b), "42")
}
Цей тест здається кумедно простим, але він перевіряє важливу річ: маршрут справді зареєстрований правильно, і сервер справді вміє діставати {id}. У реальному проєкті такі тести рятують від «ой, я написав "/task/{id}" замість "/tasks/{id}" і тепер усе повертає 404».
Успіх: POST створює задачу й повертає JSON
Тепер перейдемо до нормального API‑сценарію: створюємо задачу. Ми перевіримо, що сервер:
- прийняв JSON,
- повернув правильний статус,
- повернув правильний Content-Type,
- повернув DTO задачі.
Припустімо, у нас уже є handler, який обслуговує POST /api/v1/tasks. У тесті нам важливо надіслати JSON‑тіло.
body := strings.NewReader(`{"title":"buy milk"}`)
req, _ := http.NewRequest(http.MethodPost, ts.URL+"/api/v1/tasks", body)
req.Header.Set("Content-Type", "application/json")
Виклик:
resp, err := ts.Client().Do(req)
if err != nil {
t.Fatalf("request: %v", err)
}
defer resp.Body.Close()
Перевірка статусу та заголовка:
if resp.StatusCode != http.StatusCreated {
t.Fatalf("status=%d, want %d", resp.StatusCode, http.StatusCreated)
}
if ct := resp.Header.Get("Content-Type"); ct != "application/json; charset=utf-8" {
t.Fatalf("Content-Type=%q", ct)
}
І перевірка JSON‑тіла:
var got taskDTO
mustJSON(t, resp.Body, &got)
if got.Title != "buy milk" {
t.Fatalf("title=%q", got.Title)
}
Зверніть увагу на важливу дрібницю: ми не порівнюємо весь JSON як рядок. Завтра ви додасте поле updated_at або зміните порядок полів — рядкове порівняння зламається, хоча контракт «задача створюється» залишиться правильним. Тест має бути вашим союзником, а не прискіпливим критиком.
Помилка 400: неправильний {id} і fields в error envelope
Тепер найцікавіше: перевірка error envelope в інтеграційному тесті. Ми хочемо переконатися, що не лише сам обробник уміє формувати envelope, а й увесь стек загалом (mux, спільний обробник помилок, middleware) не ламає формат.
Сценарій: клієнт викликає GET /api/v1/tasks/abc. За контрактом, це validation‑помилка, бо {id} має бути числом.
Запит:
resp, err := ts.Client().Get(ts.URL + "/api/v1/tasks/abc")
if err != nil {
t.Fatalf("request: %v", err)
}
defer resp.Body.Close()
Перевірка статусу й декодування envelope:
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("status=%d, want %d", resp.StatusCode, http.StatusBadRequest)
}
var env errorEnvelope
mustJSON(t, resp.Body, &env)
Перевірка полів:
if env.Error.Code != "validation" {
t.Fatalf("code=%q", env.Error.Code)
}
if env.Error.Fields["id"] == "" {
t.Fatalf("fields.id is empty")
}
Тут ми перевіряємо саме те, що обіцяли клієнту: «validation‑помилка має код "validation" і мапу fields». Текст поля може бути «must be integer» або «invalid id» — це вже залежить від вашого стандарту. Але те, що fields існує і містить "id", — це вже контракт.
Помилка 404: not_found теж зобов’язаний бути envelope’ом
Наступний сценарій схожий, але сенс інший: id валідний, а сутності немає. Це вже не validation, а not_found.
Припустімо, GET /api/v1/tasks/9999 повертає not_found. У тестовій конфігурації ви можете підняти сервер з порожнім сховищем або з парою задач — так, як ви робили в коді застосунку.
resp, err := ts.Client().Get(ts.URL + "/api/v1/tasks/9999")
if err != nil {
t.Fatalf("request: %v", err)
}
defer resp.Body.Close()
Перевірка статусу + envelope:
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("status=%d, want %d", resp.StatusCode, http.StatusNotFound)
}
var env errorEnvelope
mustJSON(t, resp.Body, &env)
if env.Error.Code != "not_found" {
t.Fatalf("code=%q", env.Error.Code)
}
Чому інтеграційний тест тут важливіший за unit‑тест? Бо часто трапляється ситуація «в одному обробнику його загорнули, а в іншому — забули й повернули голий http.Error». Unit‑тест окремого обробника цього не зловить, якщо ви не написали unit‑тести на кожну кінцеву точку. Інтеграційні тести допомагають утримати єдиний стандарт хоча б на критичних маршрутах.
Помилка 500: повідомлення має бути стабільним і безпечним
Для 500‑помилок є окрема домовленість: клієнт отримує стабільне повідомлення (наприклад, "internal error"), а реальні деталі залишаються всередині логів. Це не примха, а питання безпеки та UX: користувачу не потрібно бачити "dial tcp 10.0.0.12:5432: connection refused".
Перевіряти це корисно саме інтеграційно: щоб переконатися, що спільний обробник помилок справді приховує внутрішні деталі та повертає стабільний envelope.
Тестовий сценарій зазвичай такий: підняти сервер у конфігурації, де обробник навмисно падає з внутрішньою помилкою. Як саме — залежить від архітектури вашого застосунку. Головне — що клієнт має побачити:
- HTTP 500
- {"error":{"code":"internal","message":"internal error"}}
Перевірка виглядає знайомо:
if resp.StatusCode != http.StatusInternalServerError {
t.Fatalf("status=%d, want %d", resp.StatusCode, http.StatusInternalServerError)
}
var env errorEnvelope
mustJSON(t, resp.Body, &env)
if env.Error.Message != "internal error" {
t.Fatalf("message=%q", env.Error.Message)
}
А перевіряти, що message містить текст внутрішньої помилки, — це якраз антиціль: ви закріплюєте витік деталей як норму. Такий тест захищатиме неправильну поведінку, а не правильну.
Табличний підхід: менше копіпасту, більше сенсу
Коли ви написали 3–5 інтеграційних тестів, раптом виявляється, що 60 % коду — це одне й те саме: створити запит, зробити Do, закрити body, перевірити статус, декодувати JSON.
Найпростіше рішення — табличні тести та невеликі допоміжні функції. Але будемо обережні: інтеграційні тести не повинні перетворюватися на «комбайн, який перевіряє все підряд одним циклом». Добрий стиль такий: один тестовий кейс = один зрозумілий сценарій.
Наприклад, можна зібрати таблицю для помилок:
cases := []struct {
name string
path string
status int
code string
}{
{"bad id", "/api/v1/tasks/abc", 400, "validation"},
{"missing", "/api/v1/tasks/9999", 404, "not_found"},
}
А запускати так:
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
resp, _ := ts.Client().Get(ts.URL + tc.path)
defer resp.Body.Close()
var env errorEnvelope
mustJSON(t, resp.Body, &env)
})
}
Зверніть увагу: я навмисно не намагаюся запхати сюди «і успіх, і помилки, і різні методи». Щойно таблиця стає надто універсальною, читабельність падає. Ваш тест починає нагадувати бухгалтерську форму, а не перевірку поведінки.
5. Типові помилки
Помилка № 1: забувають defer ts.Close().
У результаті сервер продовжує жити довше, ніж має. Іноді це майже непомітно, а іноді призводить до «плаваючих» проблем і витоків ресурсів. Виправляється банально: створили сервер — одразу ж поруч написали defer ts.Close().
Помилка № 2: забувають defer resp.Body.Close().
Це одна з найчастіших причин, чому тести починають поводитися нестабільно зі зростанням кількості сценаріїв. Body — це потік, у нього є ресурси, і в тестах їх теж треба звільняти. Якщо не хочеться пам’ятати — заведіть допоміжну функцію mustClose(t, resp.Body) і користуйтеся нею дисципліновано.
Помилка № 3: порівнюють JSON як рядок.
Сьогодні порядок полів один, завтра інший, післязавтра ви додали omitempty, і «ідеально працюючий API» раптом ламає тест. Рядкове порівняння корисне рідко, а для контрактів майже завжди шкідливе. Надійніше декодувати JSON у struct/map і перевіряти потрібні поля.
Помилка № 4: інтеграційний тест намагається перевірити «взагалі все».
Коли в одному тесті одночасно перевіряються маршрутизація, middleware, успішна відповідь і ще три види помилок — ви отримуєте тест, який складно читати й складно лагодити. Він падає, і незрозуміло чому. Інтеграційні тести мають бути маленькими: один сценарій — одна причина падіння.
Помилка № 5: не перевіряють Content-Type, хоча API обіцяє JSON.
Дуже легко випадково повернути JSON‑тіло, але забути заголовок або повернути text/plain через http.Error. Для клієнта це може бути критично, особливо для автоматичних клієнтів і SDK. Якщо контракт каже «це JSON» — заголовок стає частиною контракту, і його варто перевіряти хоча б у ключових тестах.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ