09 sie 2025·6 min czytania

Testowanie handlerów REST w Go: httptest i testy tabelaryczne

Testowanie handlerów REST w Go za pomocą httptest i testów tabelarycznych daje powtarzalny sposób sprawdzania uwierzytelniania, walidacji, kodów statusu i przypadków brzegowych przed wydaniem.

Testowanie handlerów REST w Go: httptest i testy tabelaryczne

Co warto mieć pewne przed wydaniem

Handler REST może się skompilować, przejść szybki test manualny i wciąż zawieść w produkcji. Większość błędów to nie problemy składniowe. To problemy z kontraktem: handler przyjmuje to, co powinien odrzucić, zwraca nieprawidłowy kod statusu lub ujawnia za dużo w błędzie.

Testy manualne pomagają, ale łatwo przegapić przypadki brzegowe i regresje. Próbujesz ścieżki szczęśliwej, może jednego oczywistego błędu, i idziesz dalej. Potem mała zmiana walidacji lub middleware cicho łamie zachowanie, które uważałeś za stabilne.

Celem testów handlerów jest prostota: sprawić, by obietnice handlera były powtarzalne. To obejmuje zasady uwierzytelniania, walidację wejścia, przewidywalne kody statusu i ciała błędów, na których klienci mogą bezpiecznie polegać.

Pakiet httptest w Go jest świetny, bo możesz wywołać handler bez uruchamiania prawdziwego serwera. Tworzysz żądanie HTTP, przekazujesz je do handlera i oglądasz ciało odpowiedzi, nagłówki i kod statusu. Testy są szybkie, izolowane i łatwe do uruchomienia przy każdym commicie.

Przed wydaniem powinieneś wiedzieć (nie mieć nadziei), że:

  • Zachowanie auth jest spójne dla brakujących tokenów, nieprawidłowych tokenów i złych ról.
  • Wejścia są walidowane: pola obowiązkowe, typy, zakresy i (jeśli to wymuszasz) nieznane pola.
  • Kody statusu pasują do kontraktu (np. 401 vs 403, 400 vs 422).
  • Odpowiedzi błędów są bezpieczne i spójne (bez stack trace, ten sam kształt za każdym razem).
  • Ścieżki nie-happy: timeouty, awarie zależności i puste wyniki są obsłużone.

Endpoint “Utwórz zgłoszenie” może działać, gdy wysyłasz idealny JSON jako admin. Testy złapią to, co zapomnisz sprawdzić: wygasły token, dodatkowe pole wysłane przypadkowo przez klienta, ujemny priorytet, lub różnicę między „nie znaleziono” a „błąd wewnętrzny”, gdy zawiedzie zależność.

Zdefiniuj kontrakt dla każdego endpointu

Zapisz, co handler obiecuje zrobić, zanim zaczniesz pisać testy. Jasny kontrakt utrzymuje testy skupione i zapobiega przemienianiu ich w domysły o tym, co kod „miał na myśli”. Ułatwia też refaktoryzacje, bo możesz zmieniać wnętrze bez zmiany zachowania.

Zacznij od wejść. Bądź konkretny, skąd pochodzi każda wartość i co jest wymagane. Endpoint może pobierać id ze ścieżki, limit z query stringa, nagłówek Authorization i ciało JSON. Zapisz zasady, które mają znaczenie: dozwolone formaty, min/max wartości, pola wymagane i co się dzieje, gdy czegoś brakuje.

Potem zdefiniuj wyjścia. Nie poprzestawaj na „zwraca JSON”. Zdecyduj, jak wygląda sukces, które nagłówki są istotne i jak wyglądają błędy. Jeśli klienci polegają na stałych kodach błędów i przewidywalnym kształcie JSON, traktuj to jako część kontraktu.

Praktyczna lista kontrolna:

  • Wejścia: wartości ze ścieżki/query, wymagane nagłówki, pola JSON i reguły walidacji
  • Wyjścia: kod statusu, nagłówki odpowiedzi, kształt JSON dla sukcesu i błędu
  • Efekty uboczne: jakie dane się zmieniają i co zostaje utworzone
  • Zależności: wywołania bazy danych, zewnętrzne serwisy, bieżący czas, generowane ID

Zdecyduj też, gdzie kończą się testy handlerów. Testy handlerów są najsilniejsze na granicy HTTP: auth, parsowanie, walidacja, kody statusu i ciała błędów. Głębsze kwestie przenieś do testów integracyjnych: prawdziwe zapytania do bazy, połączenia sieciowe i pełne routowanie.

Jeśli backend jest generowany (na przykład AppMaster produkuje handlery i logikę biznesową w Go), podejście oparte na kontrakcie jest jeszcze bardziej przydatne. Możesz wygenerować kod ponownie i dalej weryfikować, że każdy endpoint zachowuje ten sam publiczny interfejs.

Przygotuj minimalny harness z httptest

Dobry test handlera powinien przypominać wysłanie prawdziwego żądania, bez uruchamiania serwera. W Go zwykle oznacza to: budujesz żądanie z httptest.NewRequest, przechwytujesz odpowiedź z httptest.NewRecorder i wywołujesz handler.

Wywołanie handlera bezpośrednio daje szybkie, skupione testy. To idealne, gdy chcesz zweryfikować zachowanie wewnątrz handlera: checki auth, reguły walidacji, kody statusu i ciała błędów. Użycie routera w testach jest przydatne, gdy kontrakt zależy od parametrów ścieżki, dopasowania trasy lub kolejności middleware. Zacznij od bezpośrednich wywołań i dodaj router tylko wtedy, gdy jest to potrzebne.

Nagłówki są ważniejsze, niż większość ludzi myśli. Brak Content-Type może zmienić sposób, w jaki handler czyta ciało. Ustawiaj nagłówki, których oczekujesz w każdym przypadku, aby porażki wskazywały na logikę, a nie na konfigurację testu.

Oto minimalny wzorzec, który możesz ponownie użyć:

req := httptest.NewRequest(http.MethodPost, "/v1/widgets", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
rec := httptest.NewRecorder()

handler.ServeHTTP(rec, req)
res := rec.Result()
defer res.Body.Close()

Aby utrzymać spójność asercji, pomaga mała pomocnicza funkcja do wczytywania i dekodowania ciała odpowiedzi. W większości testów najpierw sprawdzaj kod statusu (żeby błędy były łatwe do przejrzenia), potem kluczowe nagłówki, które obiecujesz (często Content-Type), a następnie ciało.

Jeśli backend jest generowany (włącznie z backendem Go produkowanym przez AppMaster), ten harness nadal ma zastosowanie. Testujesz kontrakt HTTP, na którym polegają użytkownicy, a nie styl kodu, który za nim stoi.

Projektuj czytelne testy tabelaryczne

Testy tabelaryczne działają najlepiej, gdy każdy przypadek czyta się jak mała historia: żądanie, które wysyłasz, i co oczekujesz w odpowiedzi. Powinieneś móc przeskanować tabelę i zrozumieć pokrycie bez skakania po pliku.

Solidny przypadek zwykle zawiera: jasną nazwę, żądanie (metoda, ścieżka, nagłówki, ciało), oczekiwany kod statusu i sprawdzenie odpowiedzi. Dla ciał JSON preferuj asercję kilku stabilnych pól (np. kod błędu) zamiast porównywania całego stringa JSON, chyba że kontrakt wymaga ścisłego outputu.

Prosty kształt przypadku, który możesz ponownie używać

Utrzymuj strukturę przypadku skupioną. Wyrzuć jednorazowe ustawienia do helperów, żeby tabela pozostała mała.

type tc struct {
	name       string
	method     string
	path       string
	headers    map[string]string
	body       string
	wantStatus int
	wantBody   string // substring or compact JSON
}

Dla różnych wejść używaj krótkich stringów ciała, które pokazują różnicę na pierwszy rzut oka: poprawny payload, jeden brakujący field, błędny typ i pusty string. Unikaj budowania JSON z dużą ilością formatowania w tabeli — szybko robi się to głośne.

Gdy widzisz powtarzające się ustawienia (tworzenie tokenu, wspólne nagłówki, domyślne ciało), przenieś je do helperów jak newRequest(tc) lub baseHeaders().

Jeśli jedna tabela zaczyna mieszać zbyt wiele pomysłów, podziel ją. Jedna tabela dla ścieżek sukcesu i inna dla ścieżek błędów jest często łatwiejsza do czytania i debugowania.

Sprawdzanie auth: przypadki, które zwykle się pomijają

Zamień kontrakty w kod
Buduj backendy w Go za pomocą wizualnej logiki i zachowuj spójność endpointów wraz ze zmianą wymagań.
Wypróbuj AppMaster

Testy auth często wyglądają dobrze dla ścieżki szczęśliwej, potem zawodzą w produkcji, bo jeden „mały” przypadek nigdy nie został przetestowany. Traktuj auth jako kontrakt: co klient wysyła, co serwer zwraca i co nigdy nie powinno być ujawnione.

Zacznij od obecności i ważności tokenu. Chroniony endpoint powinien zachowywać się inaczej, gdy nagłówek jest nieobecny vs obecny, ale niepoprawny. Jeśli używasz krótkotrwałych tokenów, testuj wygaśnięcie też, nawet jeśli zasymulujesz to przez wstrzyknięcie walidatora zwracającego „expired”.

Większość luk obejmują te przypadki:

  • Brak nagłówka Authorization -> 401 ze stabilną odpowiedzią błędu
  • Sfałszowany nagłówek (zły prefiks) -> 401
  • Nieprawidłowy token (zły podpis) -> 401
  • Wygasły token -> 401 (lub wybrany przez ciebie kod) ze przewidywalnym komunikatem
  • Prawidłowy token, ale zła rola/uprawnienia -> 403

Rozróżnienie 401 vs 403 ma znaczenie. Używaj 401, gdy wywołujący nie jest uwierzytelniony. Używaj 403, gdy jest uwierzytelniony, ale nie ma uprawnień. Jeśli zacierasz tę różnicę, klienci będą niepotrzebnie ponawiać żądania lub wyświetlać błędny interfejs.

Sprawdzanie ról nie wystarcza też dla endpointów „własności użytkownika” (jak GET /orders/{id}). Testuj własność: użytkownik A nie powinien widzieć zamówienia użytkownika B nawet z prawidłowym tokenem. To powinien być czysty 403 (lub 404, jeśli celowo ukrywasz istnienie), a ciało nie powinno niczego ujawniać. Trzymaj błąd ogólny. Nie sugeruj, że „zamówienie należy do użytkownika 42.”

Zasady wejścia: waliduj, odrzucaj i wyjaśniaj jasno

Wiele błędów przed wydaniem to błędy wejścia: brakujące pola, złe typy, nieoczekiwane formaty lub zbyt duże payloady.

Wymień każde wejście, które handler akceptuje: pola ciała JSON, parametry query i parametry ścieżki. Dla każdego zdecyduj, co się stanie, gdy będzie brakować, będzie puste, niepoprawne lub poza zakresem. Potem napisz przypadki, które udowodnią, że handler odrzuca złe wejścia wcześnie i zawsze zwraca ten sam rodzaj błędu.

Mały zestaw przypadków walidacyjnych zwykle pokrywa większość ryzyka:

  • Pola obowiązkowe: brak vs pusty string vs null (jeśli akceptujesz null)
  • Typy i formaty: liczba vs string, format email/ data/ UUID, parsowanie booleanów
  • Limity rozmiaru: maksymalna długość, maksymalna liczba elementów, payload za duży
  • Nieznane pola: ignorowane vs odrzucane (jeśli wymuszasz ścisłe dekodowanie)
  • Query i path params: brak, nieparsowalne i zachowanie domyślne

Przykład: handler POST /users akceptuje { "email": "...", "age": 0 }. Przetestuj brak email, email jako 123, email jako "not-an-email", age jako -1 i age jako "20". Jeśli wymagasz ścisłego JSON, przetestuj też { "email":"[email protected]", "extra":"x" } i potwierdź, że to nie przechodzi.

Sprawiaj, by błędy walidacji były przewidywalne. Wybierz kod statusu dla błędów walidacji (niektóre zespoły używają 400, inne 422) i trzymaj kształt ciała błędu spójnym. Testy powinny sprawdzać zarówno status, jak i wiadomość (lub pole details) wskazujące dokładnie, które wejście nie przeszło.

Kody statusu i ciała błędów: spraw, by były przewidywalne

Regeneruj bez długu technicznego
Aktualizuj wymagania i regeneruj czysty kod źródłowy zamiast łatać stare handlery.
Generuj kod

Testy handlerów są łatwiejsze, gdy błędy API są nudne i spójne. Chcesz, by każdy błąd mapował na jasny kod statusu i zwracał ten sam kształt JSON, niezależnie od tego, kto napisał handler.

Zacznij od małej, uzgodnionej mapy typów błędów na kody HTTP:

  • 400 Bad Request: niepoprawny JSON, brak wymaganych parametrów query
  • 404 Not Found: zasób o danym ID nie istnieje
  • 409 Conflict: naruszenie unikalności lub konflikt stanu
  • 422 Unprocessable Entity: poprawny JSON, ale nie przechodzi reguł biznesowych
  • 500 Internal Server Error: nieoczekiwane awarie (db down, nil pointer, outage zewnętrznego serwisu)

Potem trzymaj kształt ciała błędu stabilnym. Nawet jeśli tekst wiadomości zmieni się później, klienci powinni mieć przewidywalne pola, na których mogą polegać:

{ "code": "user_not_found", "message": "User was not found", "details": { "id": "123" } }

W testach sprawdzaj kształt, nie tylko status. Częstym błędem jest zwracanie HTML, plain text lub pustego ciała przy błędach — to psuje klientów i ukrywa błędy.

Testuj także nagłówki i kodowanie odpowiedzi błędów:

  • Content-Type to application/json (i charset spójny, jeśli go ustawiasz)
  • Ciało jest poprawnym JSON nawet przy błędach
  • code, message i details istnieją (details może być puste, ale nie powinno być przypadkowe)
  • Paniki i nieoczekiwane błędy zwracają bezpieczne 500 bez wycieków stack trace

Jeśli masz middleware do recover, dodaj test, który wymusi panic i potwierdzi, że wciąż dostajesz czysty JSON jako odpowiedź błędu.

Przypadki brzegowe: awarie, czas i ścieżki nie-happy

Modeluj swoje dane najpierw
Projektuj schematy PostgreSQL w Data Designer, a następnie generuj gotowe do produkcji usługi.
Modeluj dane

Testy szczęśliwej ścieżki potwierdzają, że handler działa raz. Testy przypadków brzegowych potwierdzają, że dalej działa, gdy świat jest niestabilny.

Wymuszaj, by zależności zawodziły w konkretny, powtarzalny sposób. Jeśli handler wywołuje bazę danych, cache lub zewnętrzne API, chcesz zobaczyć, co się stanie, gdy warstwy te zwrócą błędy, których nie kontrolujesz.

Warto je symulować przynajmniej raz dla każdego endpointu:

  • Timeout z wywołania zewnętrznego (context deadline exceeded)
  • Not found z magazynu, gdy klient oczekiwał danych
  • Naruszenie ograniczenia unikalności przy tworzeniu (duplikat email, duplikat slug)
  • Błąd sieci/transportu (connection refused, broken pipe)
  • Nieoczekiwany błąd wewnętrzny (ogólny „coś poszło nie tak”)

Utrzymuj testy stabilne, kontrolując wszystko, co może się różnić między uruchomieniami. Flaky test jest gorszy niż brak testu, bo uczy ignorowania porażek.

Uczyń czas i losowość przewidywalnymi

Jeśli handler używa time.Now(), ID lub wartości losowych, wstrzykuj je. Przekaż funkcję zegara i generatora ID do handlera lub serwisu. W testach zwróć stałe wartości, żeby móc asercjonować dokładne pola JSON i nagłówki.

Używaj małych fake'ów i sprawdzaj „brak efektów ubocznych”

Wol preferuj małe fake'i lub stuby zamiast pełnych mocków. Fake może zarejestrować wywołania i pozwolić ci sprawdzić, że nic się nie wydarzyło po porażce.

Na przykład w handlerze „utwórz użytkownika”, jeśli insert do bazy zawiedzie z powodu naruszenia unikalności, sprawdź kod statusu, stabilność ciała błędu i to, że nie wysłano welcome emaila. Twój fake mailer może expose'ować licznik (sent=0), żeby ścieżka błędu udowodniła, że nie wywołano efektów ubocznych.

Częste błędy, które czynią testy handlerów zawodnymi

Testy handlerów często zawodzą z złego powodu. Żądanie budowane w teście nie ma tego samego kształtu, co prawdziwe żądanie klienta. To prowadzi do hałaśliwych porażek i fałszywego poczucia bezpieczeństwa.

Jednym z częstych problemów jest wysyłanie JSON bez nagłówków, których handler oczekuje. Jeśli twój kod sprawdza Content-Type: application/json, zapomnienie go może spowodować, że handler pominie dekodowanie JSON, zwróci inny kod statusu lub przejdzie do gałęzi, która nigdy nie występuje w produkcji. To samo dotyczy auth: brak nagłówka Authorization to nie to samo, co nieprawidłowy token. To powinny być różne przypadki.

Inną pułapką jest asercja całego JSON-a jako surowego stringa. Małe zmiany w kolejności pól, odstępach lub nowe pola łamią testy, nawet gdy API jest poprawne. Dekoduj ciało do struktury lub map[string]any, a potem sprawdzaj to, co ma znaczenie: status, kod błędu, wiadomość i kilka kluczowych pól.

Testy stają się też zawodnymi, gdy przypadki dzielą mutowalny stan. Ponowne użycie tego samego in-memory store, globalnych zmiennych lub singleton routera między wierszami tabeli może przeciekać dane między przypadkami. Każdy test powinien zaczynać czysto lub resetować stan w t.Cleanup.

Wzorce, które zwykle powodują kruche testy:

  • Budowanie żądań bez tych samych nagłówków i kodowania, jakich używają prawdziwi klienci
  • Asercje pełnych stringów JSON zamiast dekodowania i sprawdzania pól
  • Ponowne używanie współdzielonego stanu bazy/cache/globalnego handlera między przypadkami
  • Pakowanie auth, walidacji i logiki biznesowej w jeden przeładowany test

Trzymaj każdy test skupiony. Jeśli jeden przypadek zawiedzie, powinieneś wiedzieć, czy dotyczy to auth, reguł wejścia, czy formatowania błędu w ciągu sekund.

Krótka lista kontrolna przed wydaniem, którą możesz ponownie użyć

Standaryzuj odpowiedzi błędów
Ustal standardowy kształt odpowiedzi błędów JSON w serwisach i utrzymuj go stabilnym podczas iteracji.
Utwórz projekt

Przed wypuszczeniem testy powinny udowodnić dwie rzeczy: endpoint trzyma się kontraktu i bezpiecznie, przewidywalnie zawodzi.

Uruchom to jako przypadki tabelaryczne i każdorazowo sprawdzaj odpowiedź i efekty uboczne:

  • Auth: brak tokenu, zły token, zła rola, poprawna rola (i potwierdź, że przypadek „zła rola” nie ujawnia detali)
  • Wejścia: brak wymaganych pól, złe typy, graniczne rozmiary (min/max), nieznane pola, które chcesz odrzucić
  • Wyjścia: kod statusu, kluczowe nagłówki (jak Content-Type), wymagane pola JSON, spójny kształt błędu
  • Zależności: wymuś jedną awarię downstream (DB, queue, płatności, email), zweryfikuj bezpieczny komunikat, potwierdź brak częściowych zapisów
  • Idempotencja: powtórz to samo żądanie (albo retry po timeoutcie) i upewnij się, że nie tworzysz duplikatów

Po tym dodaj jedną asercję sanity-check, która jest pomijana: potwierdź, że handler nie dotknął tego, czego nie powinien. Na przykład w przypadku błędnej walidacji sprawdź, że żaden rekord nie został utworzony i nie wysłano żadnego maila.

Jeśli budujesz API narzędziem takim jak AppMaster, ta sama lista nadal obowiązuje. Chodzi o to samo: udowodnić, że publiczne zachowanie pozostaje stabilne.

Przykład: jeden endpoint, mała tabela i co to wykrywa

Załóżmy prosty endpoint: POST /login. Akceptuje JSON z email i password. Zwraca 200 z tokenem przy sukcesie, 400 dla niepoprawnego wejścia, 401 dla złych poświadczeń i 500, jeśli serwis auth jest niedostępny.

Kompaktowa tabela jak ta pokrywa większość tego, co łamie się w produkcji.

func TestLoginHandler(t *testing.T) {
	// Fake dependency so we can force 200/401/500 without hitting real systems.
	auth := &FakeAuth{ /* configure per test */ }
	h := NewLoginHandler(auth)

	tests := []struct {
		name       string
		body       string
		authHeader string
		setup      func()
		wantStatus int
		wantBody   string
	}{
		{"success", `{"email":"[email protected]","password":"secret"}`, "", func() { auth.Mode = "ok" }, 200, `"token"`},
		{"missing password", `{"email":"[email protected]"}`, "", func() { auth.Mode = "ok" }, 400, "password"},
		{"bad email format", `{"email":"not-an-email","password":"secret"}`, "", func() { auth.Mode = "ok" }, 400, "email"},
		{"invalid JSON", `{`, "", func() { auth.Mode = "ok" }, 400, "invalid JSON"},
		{"unauthorized", `{"email":"[email protected]","password":"wrong"}`, "", func() { auth.Mode = "unauthorized" }, 401, "unauthorized"},
		{"server error", `{"email":"[email protected]","password":"secret"}`, "", func() { auth.Mode = "error" }, 500, "internal"},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			tt.setup()
			req := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(tt.body))
			req.Header.Set("Content-Type", "application/json")
			if tt.authHeader != "" {
				req.Header.Set("Authorization", tt.authHeader)
			}

			rr := httptest.NewRecorder()
			h.ServeHTTP(rr, req)

			if rr.Code != tt.wantStatus {
				t.Fatalf("status = %d, want %d, body=%s", rr.Code, tt.wantStatus, rr.Body.String())
			}
			if tt.wantBody != "" && !strings.Contains(rr.Body.String(), tt.wantBody) {
				t.Fatalf("body %q does not contain %q", rr.Body.String(), tt.wantBody)
			}
		})
	}
}

Przejdź przez jeden przypadek od początku do końca: dla „missing password” wysyłasz ciało z samym email, ustawiasz Content-Type, uruchamiasz przez ServeHTTP, potem asercjonujesz 400 i błąd, który jasno wskazuje na password. Ten pojedynczy przypadek udowadnia, że twój dekoder, walidator i format odpowiedzi błędu współpracują ze sobą.

Jeśli chcesz szybszego sposobu na standaryzację kontraktów, modułów auth i integracji przy nadal wysyłaniu rzeczywistego kodu Go, AppMaster (appmaster.io) jest zbudowany do tego. Nawet wtedy te testy pozostają wartościowe, bo zabezpieczają zachowanie, na którym polegają twoi klienci.

Łatwy do uruchomienia
Stworzyć coś niesamowitego

Eksperymentuj z AppMaster z darmowym planem.
Kiedy będziesz gotowy, możesz wybrać odpowiednią subskrypcję.

Rozpocznij