Skip to content
gohacks.dev
Web

Mock HTTP Servers in Go Tests With the httptest Package

Use httptest.NewServer to stand in for an upstream API in Go tests: scripted responses, slow servers for timeouts, TLS servers and request recording.

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

Code that calls another service is the hardest code to test honestly. The usual shortcut is a mock: an interface with a fake implementation that returns canned values. It is fast and gives a green test, and it tells you nothing about what happens when the real server returns a 500 with an HTML body, takes ten seconds to answer, or drops the connection halfway through. Go's standard library has a better answer: a real HTTP server that your test controls. net/http/httptest starts one in a line and tears it down at the end of the test.

Tested with Go 1.25.6, macOS on Apple Silicon (arm64)

This article builds a small client function and tests it against six server behaviors, including the ones people skip. It belongs to the web cluster that ends in a production HTTP server, and pairs with testing handlers without a server and testing middleware, which cover the other direction. It also supports the client work in retries and typed API clients. Everything ran on Go 1.25.6.

Two different tools in the package

httptest has two halves that are easy to confuse. httptest.NewRecorder and httptest.NewRequest build a fake ResponseWriter and *http.Request, so you can call a handler directly with no network. That is how you test your server's code. httptest.NewServer starts an actual listener on 127.0.0.1 with a random port, and returns a *httptest.Server whose URL field is http://127.0.0.1:PORT. That is how you test code that makes requests: you point it at the fake and the request goes through a real socket, real parsing and the real client transport.

The second one is the subject here. Its great advantage is fidelity. A client function that works against httptest handles real timeouts, real redirects (the client follows them; the server sends them), real chunked bodies and real connection reuse. A mock of http.RoundTripper skips all of that, and I keep it for the cases where only logic matters, as shown in the client article.

The function under test

titles.goGo
package titles

import (
	"context"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"time"
)

type Article struct {
	ID    int    `json:"id"`
	Title string `json:"title"`
}

// Title fetches an article and returns its title. The base URL is a parameter so tests can aim it at a fake.
func Title(ctx context.Context, c *http.Client, baseURL string, id int) (string, error) {
	ctx, cancel := context.WithTimeout(ctx, 300*time.Millisecond)
	defer cancel()
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf("%s/articles/%d", baseURL, id), nil)
	if err != nil {
		return "", err
	}
	res, err := c.Do(req)
	if err != nil {
		return "", fmt.Errorf("fetching article %d: %w", id, err)
	}
	defer res.Body.Close()
	if res.StatusCode != http.StatusOK {
		return "", fmt.Errorf("article %d: unexpected status %d", id, res.StatusCode)
	}
	var a Article
	if err := json.NewDecoder(io.LimitReader(res.Body, 1<<20)).Decode(&a); err != nil {
		return "", fmt.Errorf("article %d: decoding: %w", id, err)
	}
	return a.Title, nil
}

The only design decision that matters for testing is the baseURL parameter. A package-level constant with the production host would make this untestable without monkey patching. The *http.Client is a parameter too, so the TLS test below can supply a client that trusts the test certificate.

Six behaviors, one test file

titles_test.goGo
package titles

import (
	"context"
	"errors"
	"net/http"
	"net/http/httptest"
	"strings"
	"sync/atomic"
	"testing"
	"time"
)

// newServer starts a test server and arranges for it to be closed when the test ends.
func newServer(t *testing.T, h http.HandlerFunc) *httptest.Server {
	t.Helper()
	srv := httptest.NewServer(h)
	t.Cleanup(srv.Close)
	return srv
}

func TestTitleSuccessAndRequestShape(t *testing.T) {
	var gotPath, gotAccept string
	srv := newServer(t, func(w http.ResponseWriter, r *http.Request) {
		gotPath, gotAccept = r.URL.Path, r.Header.Get("Accept-Encoding")
		w.Header().Set("Content-Type", "application/json")
		w.Write([]byte(`{"id":7,"title":"Table driven tests"}`))
	})
	title, err := Title(context.Background(), srv.Client(), srv.URL, 7)
	if err != nil || title != "Table driven tests" {
		t.Fatalf("got %q, %v", title, err)
	}
	if gotPath != "/articles/7" {
		t.Errorf("requested %q", gotPath)
	}
	if !strings.Contains(gotAccept, "gzip") {
		t.Errorf("transport should ask for gzip, saw %q", gotAccept)
	}
}

func TestTitleStatusErrors(t *testing.T) {
	for _, code := range []int{http.StatusNotFound, http.StatusInternalServerError, http.StatusTeapot} {
		t.Run(http.StatusText(code), func(t *testing.T) {
			srv := newServer(t, func(w http.ResponseWriter, r *http.Request) { http.Error(w, "nope", code) })
			if _, err := Title(context.Background(), srv.Client(), srv.URL, 1); err == nil {
				t.Fatal("expected an error")
			}
		})
	}
}

func TestTitleMalformedBody(t *testing.T) {
	srv := newServer(t, func(w http.ResponseWriter, r *http.Request) { w.Write([]byte(`<html>maintenance</html>`)) })
	_, err := Title(context.Background(), srv.Client(), srv.URL, 1)
	if err == nil || !strings.Contains(err.Error(), "decoding") {
		t.Fatalf("want a decoding error, got %v", err)
	}
}

func TestTitleTimeout(t *testing.T) {
	release := make(chan struct{})
	srv := newServer(t, func(w http.ResponseWriter, r *http.Request) {
		select {
		case <-release:
		case <-r.Context().Done():
		}
	})
	defer close(release)
	start := time.Now()
	_, err := Title(context.Background(), srv.Client(), srv.URL, 1)
	if !errors.Is(err, context.DeadlineExceeded) {
		t.Fatalf("want a deadline error, got %v", err)
	}
	if elapsed := time.Since(start); elapsed > time.Second {
		t.Errorf("took %v; the timeout is 300ms", elapsed)
	}
}

func TestTitleFollowsRedirect(t *testing.T) {
	var hits atomic.Int32
	srv := newServer(t, func(w http.ResponseWriter, r *http.Request) {
		hits.Add(1)
		if r.URL.Path == "/articles/1" {
			http.Redirect(w, r, "/articles/2", http.StatusMovedPermanently)
			return
		}
		w.Write([]byte(`{"id":2,"title":"Moved"}`))
	})
	title, err := Title(context.Background(), srv.Client(), srv.URL, 1)
	if err != nil || title != "Moved" || hits.Load() != 2 {
		t.Fatalf("title=%q err=%v hits=%d", title, err, hits.Load())
	}
}

func TestTitleOverTLS(t *testing.T) {
	srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Write([]byte(`{"id":3,"title":"Secure"}`))
	}))
	t.Cleanup(srv.Close)

	// The default client does not trust the test certificate; srv.Client() does.
	if _, err := Title(context.Background(), http.DefaultClient, srv.URL, 3); err == nil {
		t.Error("default client should reject the self-signed certificate")
	}
	title, err := Title(context.Background(), srv.Client(), srv.URL, 3)
	if err != nil || title != "Secure" {
		t.Fatalf("got %q, %v", title, err)
	}
}
Text
=== RUN   TestTitleSuccessAndRequestShape
--- PASS: TestTitleSuccessAndRequestShape (0.00s)
=== RUN   TestTitleStatusErrors
=== RUN   TestTitleStatusErrors/Not_Found
=== RUN   TestTitleStatusErrors/Internal_Server_Error
=== RUN   TestTitleStatusErrors/I'm_a_teapot
--- PASS: TestTitleStatusErrors (0.00s)
    --- PASS: TestTitleStatusErrors/Not_Found (0.00s)
    --- PASS: TestTitleStatusErrors/Internal_Server_Error (0.00s)
    --- PASS: TestTitleStatusErrors/I'm_a_teapot (0.00s)
=== RUN   TestTitleMalformedBody
--- PASS: TestTitleMalformedBody (0.00s)
=== RUN   TestTitleTimeout
--- PASS: TestTitleTimeout (0.30s)
=== RUN   TestTitleFollowsRedirect
--- PASS: TestTitleFollowsRedirect (0.00s)
=== RUN   TestTitleOverTLS
2026/10/26 15:53:05 http: TLS handshake error from 127.0.0.1:49536: remote error: tls: bad certificate
--- PASS: TestTitleOverTLS (0.05s)
PASS
ok  	example.test/lab	1.666s

Each test targets something a mock would not catch. The first records the request the server received, which checks the path and shows that the transport asked for gzip, a fact of the real client that a fake would not reproduce. The second runs the status codes as subtests so a failure names the code. The third feeds an HTML maintenance page where JSON was expected, the most common real failure of an API client. The fourth hangs the server and checks that the call returns as the deadline passes, with an error that satisfies errors.Is(err, context.DeadlineExceeded); the handler selects on a channel and on r.Context().Done() so the server goroutine ends too, instead of leaking past the test. The fifth makes the server redirect with a 301 and proves the client followed it exactly once. The sixth starts a TLS server whose certificate is not in any trust store, shows that http.DefaultClient rejects it, and that srv.Client() (a client preconfigured to trust it) succeeds.

The log line in the output, http: TLS handshake error ... bad certificate, is the test server reporting the client that refused its certificate. It comes from the first call in the TLS test, the one that is supposed to fail, so it is evidence the check worked and not a problem. If the noise bothers you, set srv.Config.ErrorLog to a logger that discards, which needs NewUnstartedServer so you can configure the server before Start.

That constructor is worth knowing. httptest.NewUnstartedServer(handler) returns a server that is not listening yet, so you can change srv.Config (timeouts, ConnState, ErrorLog), set EnableHTTP2, or swap the listener, and then call Start() or StartTLS(). The HTTP/2 and keep-alive experiments elsewhere in this series use it to count connections from the server side.

Patterns that keep the tests small

A table of behaviors. When many tests differ only in how the server responds, make the handler a field of the table row. The case name then reads as a sentence: "503 then success", "chunked body cut off", "empty 200".

A scripted sequence. To test retries, the server must change its answer between calls. A counter works: if n := calls.Add(1); n < 3 { http.Error(w, "busy", 503); return }. Assert on the total count at the end, as the redirect test does with hits.

Record, then assert in the test goroutine. The handler runs on another goroutine. Calling t.Fatal from it is wrong; it must only be called from the test goroutine. Store what you saw (path, headers, body) in a variable or channel, and check it after the call returns. The variable writes above are safe because the request completes before the test reads them, and -race will tell you if you got it wrong. Running with -race is a habit worth keeping for every test that uses a server.

Simulate a broken connection. For the cases a handler cannot express, such as a body cut off mid-response, use http.Hijacker to take the connection and close it after writing half the bytes: conn, _, _ := w.(http.Hijacker).Hijack(), write a header claiming Content-Length: 100, send ten bytes and conn.Close(). The client then reports unexpected EOF, which is how your code will see a crashed upstream.

Do not share servers between parallel tests. Each test starting its own is cheap (a few hundred microseconds) and removes shared state. If you must share one, make the handler stateless.

What not to use it for

httptest.NewServer is for tests. It binds to a random loopback port and has no timeouts configured, so do not use it as a stand-in for a production server in a benchmark or a demo. For very fast tests of server code, skip the network and use the recorder style from the next article. The package reference is at pkg.go.dev/net/http/httptest, and the Go tutorial on adding a test covers the surrounding conventions.

Also inTesting
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.