Skip to content
gohacks.dev
Testing

Testify HTTP Assertions and httptest Together in Go

Use testify HTTP assertions with net/http handlers: HTTPSuccess, HTTPError, HTTPStatusCode and HTTPBodyContains, and when to use httptest and plain assert.

6 min read
Summarize in AI
Flat illustration of a browser and a server trading a request and a response.

Testify includes helpers for HTTP handler tests. They are quick to write, and they have limits that matter once a test grows beyond a status and a substring. This article runs them against a small handler on Go 1.25.6 and shows when to switch to the recorder.

Tested with Go 1.25.6, testify v1.11.1, macOS on Apple Silicon (arm64)

The handler and the helpers

http_test.goGo
package main

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

	"github.com/stretchr/testify/assert"
)

func hello(w http.ResponseWriter, r *http.Request) {
	switch r.URL.Query().Get("name") {
	case "":
		http.Error(w, "name required", http.StatusBadRequest)
	case "gone":
		w.WriteHeader(http.StatusGone)
	default:
		w.Write([]byte("hello " + r.URL.Query().Get("name")))
	}
}

func TestHTTPHelpers(t *testing.T) {
	assert.HTTPSuccess(t, hello, "GET", "/hello", url.Values{"name": {"ada"}})
	assert.HTTPError(t, hello, "GET", "/hello", nil)
	assert.HTTPStatusCode(t, hello, "GET", "/hello", url.Values{"name": {"gone"}}, http.StatusGone)
	assert.HTTPBodyContains(t, hello, "GET", "/hello", url.Values{"name": {"ada"}}, "ada")
	assert.HTTPBodyNotContains(t, hello, "GET", "/hello", url.Values{"name": {"ada"}}, "grace")
	t.Log("five HTTP assertions passed")
}

func TestHTTPFailureMessage(t *testing.T) {
	assert.HTTPSuccess(t, hello, "GET", "/hello", nil)
}

// For anything beyond status and body, use httptest directly and assert on the recorder.
func TestRecorderWithAssert(t *testing.T) {
	rec := httptest.NewRecorder()
	hello(rec, httptest.NewRequest("GET", "/hello?name=ada", nil))
	assert.Equal(t, 200, rec.Code)
	assert.Equal(t, "hello ada", rec.Body.String())
	assert.Empty(t, rec.Header().Get("Location"))
}

func main() {}
Shell
go test -count=1 -v . 2>&1 | grep -vE '^(=== RUN|=== PAUSE|=== CONT|=== NAME|PASS|ok|FAIL$|FAIL\s|exit status)' | sed -E 's/ \([0-9.]+s\)//; s#^ +[a-z_0-9]+\.go:[0-9]+: #    #; s/\x1b\[[0-9;]*m//g; s#/[^[:space:]]*/([a-z_0-9]+\.go)#\1#g; s/[[:space:]]+$//' | grep -vE '^\s*$'
Text
    five HTTP assertions passed
--- PASS: TestHTTPHelpers
        	Error Trace:	http_test.go:33
        	Error:      	Expected HTTP success status code for "/hello?" but received 400
        	Test:       	TestHTTPFailureMessage
--- FAIL: TestHTTPFailureMessage
--- PASS: TestRecorderWithAssert

The handler hello has three branches: a missing name gives 400, name=gone gives 410, and anything else answers hello <name>. TestHTTPHelpers checks them with the assertion helpers:

HelperPasses when
assert.HTTPSuccessstatus in 200 to 299
assert.HTTPErrorstatus is 400 or above
assert.HTTPRedirectstatus in 300 to 399
assert.HTTPStatusCodestatus equals the one given
assert.HTTPBodyContainsbody contains the substring
assert.HTTPBodyNotContainsbody does not contain it

Each takes t, the handler as http.HandlerFunc, the method, the URL, a url.Values of parameters, and for the status and body helpers the expected value. The log line five HTTP assertions passed confirms all of them held.

The failure message

TestHTTPFailureMessage calls HTTPSuccess with no parameters, so the handler answers 400. The output is:

Text
Error:      	Expected HTTP success status code for "/hello?" but received 400

It includes the URL as built, with an empty query string, and the status received. For a status check that is enough. It does not show the response body, which would explain the 400: name required. That is the first limit.

What the helpers do not do

They call the handler on every assertion. Each helper builds a request and a recorder and runs the handler. Five assertions are five requests. For a pure handler that is harmless; for one that writes to a database or increments a counter, the effects repeat. Several checks on one response need one request.

They see only status and body. Headers, cookies, the content type, the Location of a redirect and JSON structure are out of reach. A 201 with the wrong Location passes HTTPSuccess.

They take a handler function. An http.Handler such as a router needs router.ServeHTTP wrapped in a function, and a handler behind middleware must be built into the function. The body helpers take a url.Values for query parameters and form data, so they cannot send a JSON request body.

They cannot set request headers. An authenticated route cannot be called with an Authorization header.

The recorder approach

TestRecorderWithAssert runs the handler once with httptest.NewRecorder and a request built by httptest.NewRequest, then asserts on the result with the ordinary functions: assert.Equal(t, 200, rec.Code), assert.Equal(t, "hello ada", rec.Body.String()) and assert.Empty(t, rec.Header().Get("Location")). One request, any header you want to set on the request, any field of the response, and assertions of every kind. For JSON, assert.JSONEq(t, want, rec.Body.String()) compares as data. This is the approach to use for anything beyond a smoke check; the technique is detailed in the recorder article.

A good compromise is a small helper in your test file, do(t, h, method, path, body, headers) *httptest.ResponseRecorder, that the tests share, followed by testify assertions on the recorder.

Sending a request body and headers

The recorder approach handles what the helpers cannot. A JSON request is httptest.NewRequest("POST", "/orders", strings.NewReader({"sku":"pen"})), with req.Header.Set("Content-Type", "application/json") and req.Header.Set("Authorization", "Bearer t-ada") before the call. The same recorder then yields the status, the headers and the body for assertions. A test that covers an authenticated JSON endpoint in six lines is not possible with the helpers, and that is the case for most real handlers.

After the call, decode the body with json.Unmarshal into a struct, use require.NoError for the decode, and assert.Equal on fields. Or compare the whole body with assert.JSONEq, which ignores key order and whitespace, so a harmless change in encoding does not break the test. Remember that json.Encoder appends a newline, so exact string comparisons need to include it.

Testing the whole router

Handlers are rarely used bare. A router adds path values, method matching and middleware, so the closest test to production calls the router: router.ServeHTTP(rec, req). The helper functions accept a function, which you can build as func(w http.ResponseWriter, r *http.Request) { router.ServeHTTP(w, r) }, but then you are back to the single-request-per-assertion cost. A shared do helper is cleaner:

  • do(t, router, "GET", "/orders/7", nil, headers) returns the recorder.
  • The test asserts on rec.Code and rec.Body with assert and require.
  • A table of such calls covers a route's status matrix, as shown for authorization in the middleware article.

Method not allowed, not found, trailing slash behaviour and path value parsing are all router features that only a router-level test sees.

A quick comparison

NeedHelper functionsRecorder with assert
Status of a GETOne lineThree lines
Body contains textOne lineTwo lines
Several checks on one responseSeveral requestsOne request
Request headers or bodyNot supportedYes
Response headers, cookiesNot availableYes
JSON comparisonNoJSONEq
Side-effect safeNo: repeats the callYes

The helpers are a convenience for the first two rows and a limitation for the rest. Most teams use them in a handful of smoke tests and the recorder everywhere else.

Common traps

The URL argument is the path plus query only, such as /hello; the helper builds /hello?name=ada from the values, and for a POST it sends the values as a form body. A route that expects a path value from a pattern like /orders/{id} will see an empty r.PathValue("id"), because the handler function is called directly and not through a mux. This is the same reason the recorder article tests the router. Cookies set by the handler are lost because the helpers discard the recorder. And a handler that panics will panic the test, so wrap risky handlers with a recover middleware in production code and test that separately.

Choosing

Use HTTPStatusCode and HTTPBodyContains for one-line checks of simple handlers, such as a health endpoint. Use the recorder with assert and require for everything that has authentication, JSON, headers, redirects or side effects. Test the router, not just the handler, so middleware and path values are exercised together, as in the auth middleware article. Edge cases deserve their own rows in a table: an empty body, a very large one, an unknown content type, a repeated query parameter. The API handler tests article lays out a larger set. The helpers are documented in the assert package, and the standard library pieces in net/http/httptest.

Also inWeb
Taggednet http
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.