JavaRush /Курси /Go SELF /Unit‑тести HTTP‑обробників:

Unit‑тести HTTP‑обробників: httptest.NewRecorder і httptest.NewRequest

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

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‑відповіді майже завжди такий:

Що перевіряємо Де дивитися Чому це важливо
Статус‑код
rr.Code
Це основний сигнал результату операції
Content-Type
rr.Header().Get("Content-Type")
Клієнт розуміє формат відповіді
JSON‑структура
json.NewDecoder(rr.Body).Decode(...)
Перевіряємо зміст, а не форматування

І так: порівнювати 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-функції, а не на місце, де її викликали. У маленькому проєкті це ще терпимо, але в реальному ви витрачаєте час на «де ж я це викликав». Позначайте хелпери, і тест-репорти будуть людянішими.

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