Testing HTTP Handlers and Clients with httptest
Test Go HTTP handlers with httptest.NewRecorder and NewRequest, test HTTP client code against httptest.NewServer, and check status codes, headers and JSON bodies.
The net/http/httptest package lets you test web code without opening real ports or calling real services. It has two main tools: a recorder that captures what a handler writes, and a test server that your client code can call.
Testing a Handler with NewRecorder
// file: hello_test.go
package web
import (
"fmt"
"net/http"
"net/http/httptest"
"testing"
)
func Hello(w http.ResponseWriter, r *http.Request) {
name := r.URL.Query().Get("name")
if name == "" {
http.Error(w, "name is required", http.StatusBadRequest)
return
}
fmt.Fprintf(w, "hello, %s", name)
}
func TestHello(t *testing.T) {
tests := []struct {
name string
url string
wantStatus int
wantBody string
}{
{"with name", "/hello?name=asha", http.StatusOK, "hello, asha"},
{"missing name", "/hello", http.StatusBadRequest, "name is required\n"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, tc.url, nil)
rec := httptest.NewRecorder()
Hello(rec, req)
if rec.Code != tc.wantStatus {
t.Errorf("status = %d, want %d", rec.Code, tc.wantStatus)
}
if rec.Body.String() != tc.wantBody {
t.Errorf("body = %q, want %q", rec.Body.String(), tc.wantBody)
}
})
}
}
httptest.NewRequestbuilds an*http.Requestfor a handler. It panics on invalid input instead of returning an error, which is fine in tests.httptest.NewRecorderis anhttp.ResponseWriterthat stores the status inCode, headers inHeader(), and the body inBody.
The handler runs as a normal function call, with no network involved.
http.Error adds a newline:
http.Error writes the message followed by \n, which is why the expected body is "name is required\n". Many first handler tests fail on this one character.
Testing Through the Router
To also test routing and middleware, call ServeHTTP on the whole mux:
mux := http.NewServeMux()
mux.HandleFunc("GET /users/{id}", getUser)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, httptest.NewRequest("GET", "/users/42", nil))
Calling getUser directly would not fill in path values like {id}, because the mux does that. Going through the mux tests the handler the way it really runs.
Testing JSON Responses
Decode the body into a struct instead of comparing raw JSON strings, which breaks on spacing and key order:
// file: api_test.go
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
type User struct {
ID int `json:"id"`
Name string `json:"name"`
}
func getUser(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(User{ID: 1, Name: "asha"})
}
func TestGetUser(t *testing.T) {
rec := httptest.NewRecorder()
getUser(rec, httptest.NewRequest("GET", "/users/1", nil))
if ct := rec.Header().Get("Content-Type"); ct != "application/json" {
t.Errorf("Content-Type = %q", ct)
}
var got User
if err := json.NewDecoder(rec.Body).Decode(&got); err != nil {
t.Fatalf("decode: %v", err)
}
if got != (User{ID: 1, Name: "asha"}) {
t.Errorf("got %+v", got)
}
}
Testing Client Code with NewServer
When your code calls an HTTP API, start a local test server that pretends to be that API:
// file: client_test.go
package weather
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
)
type Client struct {
BaseURL string
HTTP *http.Client
}
func (c *Client) Temp(city string) (float64, error) {
resp, err := c.HTTP.Get(c.BaseURL + "/temp?city=" + city)
if err != nil {
return 0, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return 0, fmt.Errorf("weather api: status %d", resp.StatusCode)
}
var body struct {
Celsius float64 `json:"celsius"`
}
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
return 0, err
}
return body.Celsius, nil
}
func TestTemp(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("city") == "delhi" {
fmt.Fprint(w, `{"celsius": 31.5}`)
return
}
http.Error(w, "unknown city", http.StatusNotFound)
}))
defer srv.Close()
c := &Client{BaseURL: srv.URL, HTTP: srv.Client()}
got, err := c.Temp("delhi")
if err != nil || got != 31.5 {
t.Fatalf("Temp(delhi) = %v, %v", got, err)
}
if _, err := c.Temp("atlantis"); err == nil {
t.Error("expected an error for an unknown city")
}
}
httptest.NewServer starts a real HTTP server on a random local port. srv.URL is its address, and the client code runs unchanged. The fake server can return errors, slow responses or bad JSON on demand, which is how you test error handling.
Make the base URL configurable:
Client code with a hard-coded https://api.example.com cannot be pointed at a test server. Keep the base URL, and ideally the *http.Client, as fields so tests can replace them.
Forgetting srv.Close():
Every NewServer must be closed, or it keeps its port and goroutines open for the rest of the test run. Use defer srv.Close() or t.Cleanup(srv.Close) right after creating it.
Well-tested HTTP code:
Handlers are tested through the mux with NewRecorder, JSON is decoded rather than string-compared, and client code runs against httptest.NewServer fakes that cover success, error statuses and bad bodies.
Summary
Use httptest.NewRequest and httptest.NewRecorder to call handlers directly and check status, headers and body, going through the mux when routing matters. Decode JSON responses into structs before comparing. Test client code against httptest.NewServer, with the base URL configurable, and always close the server.