Accurate handler tests using httptest

Avatar of the author Willem Schots
3 Oct, 2024
~5 min.
RSS

In Go, testing HTTP handlers is more work than testing regular functions. Inputs must be wrapped in HTTP requests, and responses are written to http.ResponseWriter, rather than being returned directly.

To make matters worse, handlers expect server-side requests, while it’s easy to mistakenly construct client-side requests.

You’d almost give up on testing your handlers altogether.

Please don’t.

This article will discuss how we can use httptest to safely test HTTP handlers.

How to test an HTTP handler

As you’re probably aware, HTTP handlers implement the following interface:

type Handler interface {
	ServeHTTP(ResponseWriter, *Request)
}

So, to test a handler we need to provide both a response writer and a request.

Let’s begin by looking at the request.

Constructing a test request

The request needs to be provided as a pointer to a http.Request. Unfortunately, it’s easy to create this the wrong way by accident.

The net/http package in the standard library uses http.Request in two ways:

  • As incoming requests, on the server-side. Provided to handlers by a http.Server.
  • As outgoing requests, on the client-side. Provided as input to a http.Client.
Diagram showing a http request on the client and server

If you check the docs, you will see that a lot of fields should be interpreted differently depending on whether the request is server-side or client-side.

When testing HTTP handlers you need to make sure that you’re using server-side requests as input.

However, the two main constructor functions in the net/http package provide client-side requests. This is mentioned in the docs, but it’s easy to glance over.

Using client-side requests can lead to inaccurate handler tests. When running as part of a http.Server your handlers will only ever be provided with server-side requests.

This is where the httptest comes in. It has two constructor functions that return server-side requests:

In the example below the same request is constructed twice. Once using a server-side constructor and once using a client-side constructor.

As you will see, these requests will have different ContentLength values.

main.go
package main

import (
	"fmt"
	"log"
	"net/http"
	"net/http/httptest"
)

func main() {
	serverReq := httptest.NewRequest("POST", "https://example.com", customReader("Hello world!"))
	clientReq, err := http.NewRequest("POST", "https://example.com", customReader("Hello world!"))
	if err != nil {
		log.Fatalf("failed to construct request: %v", err)
	}

	fmt.Printf("server content length: %+v\n", serverReq.ContentLength)
	fmt.Printf("client content length: %+v\n", clientReq.ContentLength)
}

// customReader is a reader of which the http package can't determine the length.
// In real apps this might be a file or other data stream.
type customReader string

func (customReader) Read(b []byte) (n int, err error) {
	return 0, nil
}

So, always be sure to use the httptest constructors to create server-side requests when testing HTTP handlers.

Now, let’s look at responses.

Recording the response

As we saw, HTTP handlers accept a http.ResponseWriter.

This type is an interface, meaning that our tests will need to provide an implementation to our handlers.

We could write this implementation ourselves, but, why do extra work?

The httptest package provides a mock implementation that records mutations and does sanity checks.

This mock implementation can be created by calling httptest.NewRecorder(). It will return a httptest.ResponseRecorder.

Each recorder can only record a single response, a new recorder must be created for each call to the handler.

To get the recorded response from the recorder, call the Result method after the handler has run. This returns a regular http.Response.

For example:

main.go
package main

import (
	"fmt"
	"io/ioutil"
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"
)

func TestExtractResponse(t *testing.T) {
	req := httptest.NewRequest("POST", "https://example.com", strings.NewReader("Hello world!"))
	rr := httptest.NewRecorder()

	Handler(rr, req)

	// extract the response
	res := rr.Result()
	if res == nil {
		t.Fatal("no response :(")
	}

	defer res.Body.Close()

	fmt.Printf("%+v\n", res)
}

func Handler(w http.ResponseWriter, r *http.Request) {
	bdy, err := ioutil.ReadAll(r.Body)
	if err != nil {
		w.WriteHeader(http.StatusInternalServerError)
		return
	}
	w.WriteHeader(http.StatusOK)
	w.Write(bdy)
}

It’s now possible to verify elements of this response, see the examples in the next section.

Verification examples

Response status

The example below verifies that the status code of the response is 200.

main.go
package main

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

func TestStatusCode(t *testing.T) {
	req := httptest.NewRequest("POST", "https://example.com", strings.NewReader("Hello world!"))
	rr := httptest.NewRecorder()

	Handler(rr, req)

	// extract the response
	res := rr.Result()
	defer res.Body.Close()

	// verify the status code
	if res.StatusCode != http.StatusOK {
		t.Fatalf("got %v wanted %v", res.StatusCode, http.StatusOK)
	}
}

func Handler(w http.ResponseWriter, r *http.Request) {
	bdy, err := ioutil.ReadAll(r.Body)
	if err != nil {
		w.WriteHeader(http.StatusInternalServerError)
		return
	}
	w.WriteHeader(http.StatusOK)
	w.Write(bdy)
}

Response body

The example below verifies that the response body is equal to "Hello world!".

Here we first need to read the response body before we can compare it.

main.go
package main

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

func TestResponseBody(t *testing.T) {
	req := httptest.NewRequest("POST", "https://example.com", strings.NewReader("Hello world!"))
	rr := httptest.NewRecorder()

	Handler(rr, req)

	// extract the response
	res := rr.Result()
	defer res.Body.Close()

	bdy, err := ioutil.ReadAll(res.Body)
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}

	if string(bdy) != "Hello world!" {
		t.Fatalf("unexpected body: %s", bdy)
	}
}

func Handler(w http.ResponseWriter, r *http.Request) {
	bdy, err := ioutil.ReadAll(r.Body)
	if err != nil {
		w.WriteHeader(http.StatusInternalServerError)
		return
	}
	w.WriteHeader(http.StatusOK)
	w.Write(bdy)
}

Response headers

The example below tests that the Content-Type header of the response is equal to "text/plain".

main.go
package main

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

func TestContentTypeHeader(t *testing.T) {
	req := httptest.NewRequest("POST", "https://example.com", strings.NewReader("Hello world!"))
	rr := httptest.NewRecorder()

	Handler(rr, req)

	// extract the response
	res := rr.Result()
	defer res.Body.Close()

	contentType := res.Header.Get("Content-Type")
	if contentType != "text/plain" {
		t.Fatalf("got %s want %s", contentType, "text/plain")
	}
}

func Handler(w http.ResponseWriter, r *http.Request) {
	bdy, err := ioutil.ReadAll(r.Body)
	if err != nil {
		w.WriteHeader(http.StatusInternalServerError)
		return
	}
	// header needs to be set before WriteHeader is called.
	w.Header().Set("Content-Type", "text/plain")
	w.WriteHeader(http.StatusOK)
	w.Write(bdy)
}

Summary

  • Testing HTTP handlers requires some care, the httptest package provides essential tools.
  • Avoid using client-side constructors in the net/http when generating test requests.
  • Always use httptest.NewRequest or httptest.NewRequestWithContext to create server-side requests for accurate tests.
  • Use httptest.ResponseRecorder to construct valid http.ResponseWriter implementations.
  • Create a new response recorder for each call to your HTTP handler.
  • Extract the response from the recorder using the Result method.

That’s it for today.

Happy coding.

Get my free newsletter every second week

Used by 500+ developers to boost their Go skills.

"I'll share tips, interesting links and new content. You'll also get a brief guide to time for developers for free."

Avatar of the author
Willem Schots

Hello! I'm the Willem behind willem.dev

I created this website to help new Go developers, I hope it brings you some value! :)

You can follow me on Bluesky, Twitter/X or LinkedIn.

Thanks for reading!