JavaRush /Курси /Go SELF /Інтеграційні HTTP‑тести: ht...

Інтеграційні HTTP‑тести: httptest.Server

Go SELF
Рівень 64 , Лекція 1
Відкрита

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 рядком» ми майже завжди робимо так:

  1. перевіряємо resp.StatusCode
  2. перевіряємо Content-Type (якщо це JSON)
  3. декодуємо 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 містить текст внутрішньої помилки, — це якраз антиціль: ви закріплюєте витік деталей як норму. Такий тест захищатиме неправильну поведінку, а не правильну.

Табличний підхід: менше копіпасту, більше сенсу

Коли ви написали 35 інтеграційних тестів, раптом виявляється, що 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» — заголовок стає частиною контракту, і його варто перевіряти хоча б у ключових тестах.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ