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.NewRequest builds an *http.Request for a handler. It panics on invalid input instead of returning an error, which is fine in tests.
  • httptest.NewRecorder is an http.ResponseWriter that stores the status in Code, headers in Header(), and the body in Body.

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.