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.

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
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() {}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*$' 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: TestRecorderWithAssertThe 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:
| Helper | Passes when |
|---|---|
assert.HTTPSuccess | status in 200 to 299 |
assert.HTTPError | status is 400 or above |
assert.HTTPRedirect | status in 300 to 399 |
assert.HTTPStatusCode | status equals the one given |
assert.HTTPBodyContains | body contains the substring |
assert.HTTPBodyNotContains | body 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:
Error: Expected HTTP success status code for "/hello?" but received 400It 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.Codeandrec.Bodywithassertandrequire. - 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
| Need | Helper functions | Recorder with assert |
|---|---|---|
| Status of a GET | One line | Three lines |
| Body contains text | One line | Two lines |
| Several checks on one response | Several requests | One request |
| Request headers or body | Not supported | Yes |
| Response headers, cookies | Not available | Yes |
| JSON comparison | No | JSONEq |
| Side-effect safe | No: repeats the call | Yes |
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.


