AlgoMaster Logo

httptest Package

High Priority12 min readUpdated June 6, 2026

The net/http/httptest package gives you two ways to test HTTP code without standing up a real network server in production: an in-memory ResponseRecorder for unit tests that call a handler directly, and a real loopback server on a random port for integration tests that drive the full transport stack. Both ship in the standard library, so there's no third-party framework to learn. This chapter covers how to build requests, invoke handlers, assert on the result, and choose between the two testing approaches.

Building Test Requests

A handler's job is to read an *http.Request and write to an http.ResponseWriter. To test that in isolation, you need a request to feed it. httptest.NewRequest(method, target, body) returns an *http.Request already populated with the method, URL, and body. It's the same shape as a real incoming request, minus the network round trip.

The third argument is an io.Reader. For a JSON or form body, strings.NewReader or bytes.NewReader is the usual choice. Passing nil is correct for methods that don't carry a body (GET, DELETE, HEAD).

The target can be a full URL like https://example.com/cart or just a path like /cart. The host doesn't matter for handler tests because the handler never sees the network; it's reading from the in-memory request. Pick whatever reads clearly in your test.

httptest.NewRequest panics if you pass an invalid method or URL, which is what you want in a test. A real malformed input should never reach this function in production code, so failing loudly during the test is appropriate.

The ResponseRecorder

httptest.NewRecorder() returns a *ResponseRecorder, which implements http.ResponseWriter and remembers everything the handler writes. After the handler returns, you read the status, headers, and body off the recorder and make assertions.

The recorder exposes three fields and one method you'll use constantly:

MemberWhat It Holds
rec.CodeThe status code the handler wrote with WriteHeader, or 200 if it never called it
rec.BodyA *bytes.Buffer of everything written to the response body
rec.HeaderMapThe http.Header map the handler wrote to (legacy, prefer rec.Result().Header)
rec.Result()Returns an *http.Response with the recorded status, headers, and body

The following example tests a handler for a shop where each product has an ID, name, and price, served at /products/{id}.

And the test:

Three assertions show up in almost every recorder test: status code, headers, and body. The status comes from rec.Code. Headers come from rec.Result().Header (the newer API; rec.HeaderMap works too but predates Result() and isn't recommended for new code). The body comes from rec.Body.String() for text comparisons or json.NewDecoder(rec.Body).Decode(&v) for structured payloads.

Testing a POST Handler

POSTs add two concerns to the test: you need to send a body, and you usually want to verify side effects in the system under test. A POST /cart handler that adds an item to a user's cart is a typical example.

The test sends a body, then checks status, content type, response shape, and the in-memory cart state:

The pattern is the same as the GET test, with a body added to the request. The cart = nil line at the top of each test is a manual reset, because the package-level slice persists between tests. A cleaner design would inject the storage as a dependency, but for a small handler, the explicit reset is fine.

Asserting on Headers

rec.Result().Header returns the http.Header map as written by the handler. The same Get, Set, Add, and Del methods you use on a real response apply.

resp.Header.Get returns the empty string when a header was never set, which makes negative assertions natural. The defer resp.Body.Close() is courteous: Result() returns a real *http.Response, and Go's HTTP package always wants you to close response bodies, even when reading from an in-memory recorder. Skipping the close in a test won't leak anything, but the linter will complain.

A common mistake is to read headers off rec.Header() directly, which returns the headers the handler is writing to as it runs. That works only while the handler is executing. After it returns, rec.Result().Header is the snapshot to read from.

The In-Memory Server

httptest.NewServer(handler) starts a real HTTP server on a random localhost port. It returns a *httptest.Server with a URL field you point an http.Client at, and a Close() method that shuts it down. The handler receives real requests over a real TCP connection.

The Checkout handler under test:

The defer ts.Close() is mandatory. If you forget it, the server keeps listening after the test ends, leaking a goroutine and a port. Go test will still pass, but a long-running test suite slowly chews through file descriptors.

The handler can also be a single http.HandlerFunc if you don't need routing:

That form is convenient for testing one handler at a time. Use a ServeMux (or your real router) when the test needs path matching, middleware chains, or multiple routes.

Recorder vs Server

Both tools exercise the same handler, but the cost and coverage differ. Pick based on what you need to verify.

The recorder path is a direct function call: test code builds a request, calls the handler, reads the recorder. The server path goes through a real client, transport, TCP loopback, and HTTP server before reaching the handler. Both end at the same handler, but the server path tests everything the recorder can't.

ConcernRecorderServer
SpeedMicroseconds per callMilliseconds per call
Sync modelSynchronous, runs on the test goroutineAsynchronous, handler runs on a server goroutine
Tests middleware chain ordering?Yes, if you wrap the handler in the testYes, the full mux/router runs
Tests http.Transport behavior (redirects, retries, gzip)?NoYes
Tests TLS handshake?NoUse httptest.NewTLSServer
Tests request body streaming?LimitedYes
Tests timeouts and cancellation?AwkwardNatural
Tests TCP-level concerns (keep-alive, Content-Length)?NoYes
Needs defer Close()?NoYes

The shorthand: use the recorder for handler logic, use the server when you're testing how a client and handler talk to each other. Most projects end up with mostly recorder tests and a small number of server tests for integration paths.

Testing TLS Endpoints

httptest.NewTLSServer(handler) starts the same kind of in-memory server, except it speaks TLS using a self-signed certificate the package generates on the fly. The certificate isn't trusted by the system, so a plain http.Get against ts.URL will fail with an x509 error. The server exposes a pre-configured client that trusts its cert:

ts.Client() returns an *http.Client whose transport is configured with the server's certificate as a trusted root. Using any other client will hit a certificate verification error, which is correct behavior; a production client should never blindly trust a random self-signed cert. The TLS variant is mostly useful when the code under test does something different on HTTPS than on HTTP, like setting cookies with the Secure flag or refusing to send sensitive headers over plain HTTP.

For the great majority of handler tests, the plain NewServer is enough. Use NewTLSServer only when TLS behavior is the specific thing under test.

A Full Example: Testing the Checkout Flow

Pulling the pieces together, here's a handler with two error paths and a success path, tested with the recorder for unit coverage and the server for one integration sanity check.

And the corresponding tests:

The table-driven recorder test covers five behaviors with one block of code. The single server test runs one happy-path request through the full HTTP stack so you know the handler also works when invoked over real transport. That split, many recorder unit tests plus a thin integration test, is the standard shape of HTTP test files in Go projects.

Quiz

httptest Quiz

10 quizzes