skip to content

How do you exercise an http.Handler directly in a Go test without binding a port?

level: juniorimportance: must knowfreq 78%

answer

  1. a handler is only an interface method
  2. two helpers: one request, one writer
  3. the writer keeps the bytes in memory
  4. httptest.NewRequest plus httptest.NewRecorder
  5. call ServeHTTP yourself, then read Result()

basics

~20 s

Build the request with httptest.NewRequest and pass it, together with an httptest.NewRecorder, straight to the handler's ServeHTTP. The recorder captures status, headers and body in memory, so no port is bound and nothing is served.

solid answer

~40 s

An `http.Handler` is just a function of `(http.ResponseWriter, *http.Request)`, so a test can call it directly. `httptest.NewRequest(method, target, body)` builds a server-side `*http.Request` — the shape a handler receives, not an outgoing client request — and `httptest.NewRecorder()` returns a `*httptest.ResponseRecorder`, an in-memory `http.ResponseWriter` that keeps whatever the handler writes. Call `h.ServeHTTP(rec, req)`, then read `rec.Result()` for the `*http.Response` and assert on `StatusCode`, `Header` and the body. This is the fastest possible HTTP test: no listener, no TCP, no client, no port to collide with in parallel tests. Reach for `httptest.NewServer` only when you need a real URL — for example when the code under test is a client SDK that must dial something.

code

go · 20 lines
go
func TestChargeHandler(t *testing.T) {
	req := httptest.NewRequest("GET", "/v1/charges/ch_1", nil)
	rec := httptest.NewRecorder()

	chargeHandler.ServeHTTP(rec, req)

	res := rec.Result()
	defer res.Body.Close()

	if res.StatusCode != http.StatusOK {
		t.Fatalf("status = %d, want 200", res.StatusCode)
	}
	body, err := io.ReadAll(res.Body)
	if err != nil {
		t.Fatalf("read body: %v", err)
	}
	if !strings.Contains(string(body), `"succeeded"`) {
		t.Errorf("body = %s", body)
	}
}

go deeper

for a junior

Be ready to write the four lines from memory: httptest.NewRequest, httptest.NewRecorder, ServeHTTP, then assert on Result(). Say out loud that no port is opened.

for a middle

Explain why this works at all — an http.Handler is one interface method, so the recorder is simply another ResponseWriter implementation. Know that httptest.NewRequest builds the server-side request, not an outgoing one.

for a senior

Show judgment about what the recorder does not cover: wire framing, keep-alive, server timeouts, hijacking and the client side. Say when you would switch to a real test server and why the handler test still stays.

for a principal

Frame it as a testing-cost decision for a codebase: recorder tests are cheap and parallel-safe, so most HTTP coverage should sit there, with a small number of server-backed tests reserved for client and transport behaviour.

## The two seams `net/http/httptest` gives you Go's HTTP server is built on one tiny interface: ```go type Handler interface { ServeHTTP(ResponseWriter, *Request) } ``` Because that is the whole contract, testing a handler needs no network at all. You need something that looks like an incoming request, and something that looks like a `ResponseWriter` but remembers what was written. `net/http/httptest` provides exactly those two things: - **`httptest.NewRequest(method, target string, body io.Reader) *http.Request`** — a request in *server* form. It is not the same as `http.NewRequest`, which builds an *outgoing* request for a client to send. `httptest.NewRequest` fills in the fields a handler expects: `RequestURI` is set, `RemoteAddr` gets a documentation address, and a target that is only a path (`"/health"`) gets `Host` `example.com`. It panics on a target it cannot parse, which is what you want in a test — a malformed URL is a bug in the test, not a case to handle. - **`httptest.NewRecorder() *httptest.ResponseRecorder`** — an `http.ResponseWriter` backed by memory. It exposes `Code` (seeded to 200), a `Body *bytes.Buffer`, `Flushed`, and `Header()`, and it can hand you a `*http.Response` via `Result()`. ## The shape of the test ```go req := httptest.NewRequest("GET", "/v1/charges/ch_1", nil) rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) res := rec.Result() defer res.Body.Close() ``` Then assert. Prefer `res.StatusCode`, `res.Header.Get(...)` and the bytes read from `res.Body` over poking the recorder's exported fields directly; `Result()` is the supported surface and `HeaderMap` is explicitly deprecated. If the handler is a `http.HandlerFunc`, you can call it as a plain function (`myHandler(rec, req)`); if it is a `*http.ServeMux` or a middleware chain, call `ServeHTTP` so routing and wrapping actually run. That distinction matters: a test that calls the leaf function directly proves the leaf works but proves nothing about the route pattern that reaches it. ## Why this is the default, and what it does not cover The recorder path is unbeatable on speed and determinism. There is no listener, so parallel tests cannot collide on a port; there is no client, so no timeout, redirect policy or connection pool sits between your assertions and the handler. Failures point straight at the handler. What it deliberately does not exercise is everything the `net/http` **server** does around your handler: writing the status line and the wire framing, chunked encoding, `Content-Length` computation on the socket, HTTP/2, keep-alive behaviour, `Server` timeouts, and the client side entirely. The recorder is not a full `ResponseWriter` implementation either — it does not support hijacking a connection, so a handler that upgrades the connection cannot be tested this way. That is where the other seam comes in. `httptest.NewServer(handler)` starts a real HTTP server on a random port on the loopback interface and gives you `srv.URL` and `srv.Client()`; `defer srv.Close()` shuts it down. Use it when the thing under test is a *client*: a wrapper around somebody else's API, a retry policy, a header-signing round tripper. In that direction, the handler you pass to `httptest.NewServer` is the double — it plays the remote service — and the code under test is your own client. A useful rule of thumb: **testing what you serve → recorder; testing what you call → server.** ## Common mistakes - Using `http.NewRequest` where `httptest.NewRequest` belongs. It mostly works, but it leaves `RequestURI` empty and the request is not in the shape the server would hand you. - Asserting on `rec.Body.String()` while ignoring the status, so a handler that returns 500 with a helpful message passes a body check. - Forgetting that `rec.Code` starts at 200. A handler that returns before writing anything still records 200, so a test that only checks for 200 can pass against a handler that did nothing. - Spinning up `httptest.NewServer` for a handler test out of habit. It costs a listener and a client for no extra coverage of the handler itself.

  • When would you start a real httptest.NewServer instead of using a recorder?
    When the code under test is a client rather than a handler. A wrapper around a third-party API, a retry policy or a signing round tripper needs a real URL to dial, so `httptest.NewServer` gives you `srv.URL` and a handler that plays the remote service. For your own handlers, the recorder covers the same ground faster and without a port.
  • What is the difference between httptest.NewRequest and http.NewRequest?
    `http.NewRequest` builds an outgoing client request; `httptest.NewRequest` builds the incoming, server-side form a handler actually receives — `RequestURI` is populated, `RemoteAddr` is set, and a bare path gets a default host. It also panics rather than returning an error, which is right for a test.
  • Does testing a handler through a recorder prove the route pattern works?
    Only if you call `ServeHTTP` on the mux rather than on the leaf handler function. Passing the recorder to the router runs pattern matching, path values and any middleware; calling the handler function directly skips all of it and proves only the body of that one function.

The recorder is a sheet of carbon paper under the handler's pen: the handler writes exactly as it always would, and you read the copy afterwards instead of standing at a mailbox waiting for the letter.

saying these in an interview costs you the question

  • Starting a real server to test a plain handler
  • Asserting only on the body, never the status code
  • Assuming a recorder can hijack the connection
  • Using http.NewRequest to build an incoming request
  • Believing a 200 in the recorder means the handler wrote one