Testing HTTP handlers without a server
You built a REST endpoint that returns user data. It works in your browser. Now you need to prove it works without spinning up a server and clicking buttons. You want to call the function directly, feed it a fake request, and check the response. Spinning up a full HTTP server for every test feels heavy and slow. You want speed and isolation.
Go makes this straightforward. The net/http/httptest package provides everything you need to test handlers without network overhead. You get a fake request and a fake response writer that captures everything your handler writes. The standard library handles the plumbing. You focus on assertions.
The handler is just a function
An HTTP handler in Go is a function with a specific signature: func(http.ResponseWriter, *http.Request). Testing it means providing a value that implements http.ResponseWriter and a value of type *http.Request. You do not need mocking frameworks. You do not need to generate fake certificates. The httptest package provides NewRecorder for the writer and NewRequest for the request. These types satisfy the interfaces and capture the output for inspection.
The core insight is that httptest.ResponseRecorder implements http.ResponseWriter. It has a Header() method that returns a mutable map, a Write() method that appends bytes to an internal buffer, and a WriteHeader() method that sets the status code. When your handler runs, it thinks it is talking to a real HTTP connection. In reality, it is writing to a struct in memory. After the handler returns, you inspect the recorder's fields to verify behavior.
Minimal test setup
Here is the simplest test: spawn a request, record the response, and assert the status code.
package main
import (
"net/http"
"net/http/httptest"
"testing"
)
// handler returns a simple 200 OK response.
func handler(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}
func TestHandler(t *testing.T) {
// NewRequest builds a request without network overhead.
req := httptest.NewRequest("GET", "/", nil)
// NewRecorder captures the response body and status code.
w := httptest.NewRecorder()
handler(w, req)
// Assert the status code matches the expectation.
if w.Code != http.StatusOK {
t.Errorf("handler returned wrong status code: got %v want %v", w.Code, http.StatusOK)
}
}
The test creates a request with httptest.NewRequest. The arguments are the method, the URL, and an optional body. Passing nil for the body creates a request with no content. The recorder is created with httptest.NewRecorder. The handler is called directly. The test checks w.Code. If the code matches, the test passes.
How the recorder captures output
The ResponseRecorder buffers everything. When the handler calls w.Write(body), the bytes go into an internal buffer. You can access the body as a string with w.Body.String() or as bytes with w.Body.Bytes(). When the handler calls w.WriteHeader(status), the recorder sets the Code field. When the handler calls w.Header().Set(key, value), the recorder updates the internal header map.
The recorder also provides a Result() method. This method returns a *http.Response struct that mirrors the captured output. It is useful when you want to use standard HTTP response methods, like Response.Body.Close() or Response.Header.Get(). The Result() method constructs the response on the fly from the recorder's state.
A convention in Go testing is to use t.Helper() in test helper functions. If you extract common setup logic into a helper, mark it with t.Helper(). This tells the test runner to report failures at the call site, not inside the helper. It makes debugging easier.
// newTestRequest creates a request with a JSON body.
func newTestRequest(t *testing.T, method, url string, body interface{}) *http.Request {
t.Helper() // Report failures at the call site, not here.
// Marshal the body to JSON for the request.
jsonBody, err := json.Marshal(body)
if err != nil {
t.Fatalf("failed to marshal body: %v", err)
}
// NewRequest builds the request with the JSON body.
req := httptest.NewRequest(method, url, bytes.NewReader(jsonBody))
req.Header.Set("Content-Type", "application/json")
return req
}
The helper marshals the body and sets the header. The test uses the helper to create requests. The t.Helper() call ensures error messages point to the test function, not the helper.
Testing JSON and query parameters
Real handlers do more than return static strings. They parse query parameters, read JSON bodies, set headers, and return structured data. A realistic test covers these paths. You create a request with query parameters or a body, call the handler, and assert the response.
Here is a handler that reads a query parameter and returns JSON.
package main
import (
"encoding/json"
"net/http"
)
// GetItem returns an item based on the ID query parameter.
func GetItem(w http.ResponseWriter, r *http.Request) {
id := r.URL.Query().Get("id")
if id == "" {
http.Error(w, "missing id", http.StatusBadRequest)
return
}
item := map[string]string{
"id": id,
"name": "Test Item",
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(item)
}
The test verifies the query parameter handling and the JSON response.
package main
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestGetItem(t *testing.T) {
// Create a request with a query parameter.
req := httptest.NewRequest("GET", "/item?id=123", nil)
w := httptest.NewRecorder()
GetItem(w, req)
// Assert the status code.
if w.Code != http.StatusOK {
t.Errorf("got status %v, want %v", w.Code, http.StatusOK)
}
// Assert the Content-Type header.
if w.Header().Get("Content-Type") != "application/json" {
t.Errorf("got header %v, want application/json", w.Header().Get("Content-Type"))
}
// Unmarshal the body to verify the data.
var result map[string]string
if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil {
t.Fatalf("failed to unmarshal body: %v", err)
}
if result["id"] != "123" {
t.Errorf("got id %v, want 123", result["id"])
}
}
The test creates a request with ?id=123. It calls the handler. It checks the status code. It checks the Content-Type header. It unmarshals the body into a map and checks the id field. This approach avoids brittle string comparisons. It catches JSON serialization bugs. It verifies the data structure.
A common pitfall is checking only the status code. A handler can return 200 OK with an empty body or malformed JSON. Always assert the body content. Another pitfall is forgetting to check headers. If your API relies on Content-Type or Cache-Control, the test should verify them. If you skip header checks, a regression that drops a header will pass the test.
Common pitfalls and compiler errors
If you pass the wrong type to httptest.NewRequest, the compiler rejects it with cannot use ... as ... in argument. The function expects a method string, a URL string, and an optional body. Passing a *http.Request instead of a string triggers this error. The compiler is strict about types. It prevents subtle bugs.
Another error occurs if you forget to import net/http/httptest. The compiler complains with undefined: httptest. Import the package and the error disappears.
Runtime panics can happen if the handler writes to the response after headers are sent. The httptest recorder is lenient in some versions, but it may panic with panic: Handler wrote headers after they were sent. This panic indicates a logic error in the handler. Fix the handler to write headers before the body.
Race conditions are rare in handler tests because the handler runs synchronously. If the handler spawns goroutines, you need synchronization. Use sync.WaitGroup or channels to wait for goroutines to finish before asserting. The worst goroutine bug is the one that never logs. Ensure goroutines complete or cancel before the test ends.
Testing middleware chains
Middleware wraps handlers. Testing middleware requires creating a dummy handler, wrapping it, and calling the wrapper. You can verify that the middleware modifies the request, checks the response, or short-circuits the chain.
Here is a simple auth middleware.
package main
import (
"net/http"
)
// AuthMiddleware checks for an API key in the header.
func AuthMiddleware(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
key := r.Header.Get("X-API-Key")
if key != "secret" {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
next(w, r)
}
}
The test creates a dummy handler, wraps it, and calls the wrapper.
package main
import (
"net/http"
"net/http/httptest"
"testing"
)
func TestAuthMiddleware(t *testing.T) {
// Dummy handler that always returns 200.
dummy := func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}
// Wrap the dummy handler with middleware.
wrapped := AuthMiddleware(dummy)
t.Run("missing key", func(t *testing.T) {
req := httptest.NewRequest("GET", "/", nil)
w := httptest.NewRecorder()
wrapped(w, req)
// Middleware should reject the request.
if w.Code != http.StatusUnauthorized {
t.Errorf("got status %v, want %v", w.Code, http.StatusUnauthorized)
}
})
t.Run("valid key", func(t *testing.T) {
req := httptest.NewRequest("GET", "/", nil)
req.Header.Set("X-API-Key", "secret")
w := httptest.NewRecorder()
wrapped(w, req)
// Middleware should pass to the next handler.
if w.Code != http.StatusOK {
t.Errorf("got status %v, want %v", w.Code, http.StatusOK)
}
})
}
The test uses subtests to cover different cases. The first subtest sends a request without the key. The middleware rejects it. The second subtest sends a request with the key. The middleware passes to the dummy handler. The test verifies the status codes. This pattern works for any middleware. Create a dummy, wrap it, call it, assert.
When to use each testing approach
Go provides multiple tools for HTTP testing. Pick the right one for the job.
Use a direct handler call with httptest.NewRecorder when you are unit testing handler logic, JSON parsing, or query parameter handling. This approach is fast and isolated. It avoids network overhead.
Use httptest.NewServer when you need a real URL for integration tests. This function starts a TCP server on a random port. It is useful when testing an HTTP client that calls your server, or when testing middleware that depends on the full request lifecycle.
Use httptest.NewTLSServer when your handler or client requires TLS certificates. This function starts a server with a self-signed certificate. It is useful for testing HTTPS endpoints.
Use table-driven tests when you need to verify multiple input combinations against the same handler. Table-driven tests keep the test code DRY and make it easy to add new cases.
Handlers are functions. Test them like functions. Trust httptest. It captures everything.