Testing HTTP Handlers

httptest gives you a fake response writer and a throwaway real server, so handlers can be tested without binding a port.

Syntaxrec := httptest.NewRecorder(); req := httptest.NewRequest(http.MethodGet, "/health", nil); h(rec, req)

An HTTP handler is a function that answers a web request. It takes two arguments: an http.ResponseWriter, which is where you write the reply, and an *http.Request, which describes what was asked for. A reply carries a status code, a number saying how it went, where 200 means success.

Testing a handler does not need a listening port, a free port number, or a sleep while something starts up. The package net/http/httptest gives you two ways in.

A recorder

httptest.NewRecorder() returns a *httptest.ResponseRecorder. It satisfies the http.ResponseWriter interface, so a handler writes to it quite happily, and it keeps what was written: Code is the status, Body is a buffer of the bytes, Header() is the headers. httptest.NewRequest(method, target, body) builds the request to pass in. It panics on a malformed target instead of returning an error, which is what you want in a test; http.NewRequest is the longhand that returns an error for you to check. Then you call the handler yourself — it is just a function — and inspect the recorder.

A real server

httptest.NewServer(handler) starts a real server on a port the operating system picks and puts its address in srv.URL, so you can curl $URL/health or point a client at it. Always pair it with defer srv.Close(). Use the recorder for your own handlers, and a server when the code under test is a client calling someone else's API.

Example

Example · go
// File: health_test.go — run it with: go test -v ./...
package api

import (
	"net/http"
	"net/http/httptest"
	"testing"
)

// Health replies with a small JSON document and status 200.
func Health(w http.ResponseWriter, r *http.Request) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(http.StatusOK)
	if _, err := w.Write([]byte(`{"status":"ok"}`)); err != nil {
		return
	}
}

func TestHealth(t *testing.T) {
	req := httptest.NewRequest(http.MethodGet, "/health", nil)
	// A fake ResponseWriter that keeps whatever the handler writes.
	rec := httptest.NewRecorder()
	Health(rec, req)
	if rec.Code != http.StatusOK {
		t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
	}
	if got, want := rec.Body.String(), `{"status":"ok"}`; got != want {
		t.Errorf("body = %q, want %q", got, want)
	}
	if got := rec.Header().Get("Content-Type"); got != "application/json" {
		t.Errorf("Content-Type = %q, want application/json", got)
	}
}

// Output:
// === RUN   TestHealth
// --- PASS: TestHealth (0.00s)
// PASS
// ok  	api	0.004s

When to use it

  • Check that a handler returns 400 and a useful message for every malformed request body you can think of.
  • Test a client wrapper against a fake upstream that returns a 500 or an empty body on demand.
  • Verify that middleware sets the headers it promises, by calling it with a recorder and inspecting them.

More examples

A stand-in for someone else's API

Use this when the thing you are testing makes requests rather than answers them. The fake server lets you return a 500, a truncated body or a wrong content type on demand, which is hard to arrange against the real service. srv.URL already includes the scheme, host and chosen port, so build paths by appending to it.

Example · go
package client

import (
	"io"
	"net/http"
	"net/http/httptest"
	"testing"
)

func TestFetchPing(t *testing.T) {
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if r.URL.Path != "/ping" {
			http.NotFound(w, r)
			return
		}
		if _, err := io.WriteString(w, "pong"); err != nil {
			t.Errorf("write: %v", err)
		}
	}))
	defer srv.Close()

	resp, err := http.Get(srv.URL + "/ping")
	if err != nil {
		t.Fatalf("get: %v", err)
	}
	defer resp.Body.Close()
	body, err := io.ReadAll(resp.Body)
	if err != nil {
		t.Fatalf("read body: %v", err)
	}
	if string(body) != "pong" {
		t.Errorf("body = %q, want pong", body)
	}
}

Posting a body and expecting a rejection

The third argument to httptest.NewRequest is the request body, and anything that reads bytes will do: strings.NewReader for a literal, bytes.NewReader for generated data, nil for none. This shape, one subtest per rejected body, is how validation rules get pinned down.

Example · go
package api

import (
	"encoding/json"
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"
)

func CreateUser(w http.ResponseWriter, r *http.Request) {
	var in struct {
		Name string `json:"name"`
	}
	if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
		http.Error(w, "invalid JSON", http.StatusBadRequest)
		return
	}
	if in.Name == "" {
		http.Error(w, "name is required", http.StatusBadRequest)
		return
	}
	w.WriteHeader(http.StatusCreated)
}

func TestCreateUserRejectsEmptyName(t *testing.T) {
	req := httptest.NewRequest(http.MethodPost, "/users", strings.NewReader(`{"name":""}`))
	req.Header.Set("Content-Type", "application/json")
	rec := httptest.NewRecorder()
	CreateUser(rec, req)
	if rec.Code != http.StatusBadRequest {
		t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest)
	}
	if !strings.Contains(rec.Body.String(), "name is required") {
		t.Errorf("body = %q, want it to mention the missing name", rec.Body.String())
	}
}

Reading the result as a response

rec.Result() returns a *http.Response built from what the handler wrote. Use it when you want response-level helpers such as res.Cookies(), or when the assertion code you already have takes a response. Call it after the handler has finished, and close the body as you would any response body.

Example · go
func TestRedirectSetsLocation(t *testing.T) {
	h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		http.Redirect(w, r, "/users/1", http.StatusSeeOther)
	})
	rec := httptest.NewRecorder()
	h(rec, httptest.NewRequest(http.MethodPost, "/users", nil))

	// Result turns the recording into a normal *http.Response.
	res := rec.Result()
	defer res.Body.Close()
	if res.StatusCode != http.StatusSeeOther {
		t.Fatalf("status = %d, want %d", res.StatusCode, http.StatusSeeOther)
	}
	if got := res.Header.Get("Location"); got != "/users/1" {
		t.Errorf("Location = %q, want /users/1", got)
	}
}

Discussion

  • Be the first to comment on this lesson.