Skip to content
gohacks.dev
Testing

Test HTTP Clients in Go With a Fake RoundTripper Type

Test Go HTTP client code with no server and no network: a RoundTripFunc fake returns canned responses, records the request and fakes failures.

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

An HTTP client is a function of what the server returns. To test one, you have to make the server return things on demand, including the awkward ones: a 503, a body that stops halfway, a connection reset, a response that never arrives. Starting a real listener for each case works, but the standard library offers a layer below it that needs no listener at all, the http.RoundTripper.

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

This article uses it on a small currency client, with the tests run on Go 1.25.6. For the server side of the same story, see testing handlers with the httptest recorder.

What a RoundTripper is

http.Client does redirects, cookies and timeouts. When it needs to send one request and get one response, it delegates to its Transport, which is an interface with a single method: RoundTrip(*http.Request) (*http.Response, error). The default is http.DefaultTransport, which opens connections. Replace it and the client sends the request to your code instead.

That makes the seam clean. The code under test takes an *http.Client, or builds one from a configuration struct, and nothing else about it changes:

rates.goGo
package main

import (
	"context"
	"encoding/json"
	"fmt"
	"net/http"
	"net/url"
)

type Client struct {
	HTTP    *http.Client
	BaseURL string
	Token   string
}

type Rate struct {
	Code string  `json:"code"`
	Rate float64 `json:"rate"`
}

func (c *Client) Rate(ctx context.Context, code string) (Rate, error) {
	u := c.BaseURL + "/v1/rates/" + url.PathEscape(code)
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
	if err != nil {
		return Rate{}, err
	}
	req.Header.Set("Authorization", "Bearer "+c.Token)

	resp, err := c.HTTP.Do(req)
	if err != nil {
		return Rate{}, fmt.Errorf("rates: %w", err)
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusOK {
		return Rate{}, fmt.Errorf("rates: unexpected status %d", resp.StatusCode)
	}
	var r Rate
	if err := json.NewDecoder(resp.Body).Decode(&r); err != nil {
		return Rate{}, fmt.Errorf("rates: decode: %w", err)
	}
	return r, nil
}

func main() {}

Client.Rate builds a request with the caller's context, sets a bearer token, sends it, checks the status and decodes JSON. Each of those steps has a failure mode, and each is worth a test.

The fake, in five lines

rates_test.goGo
package main

import (
	"context"
	"errors"
	"io"
	"net/http"
	"strings"
	"testing"
	"time"
)

// RoundTripFunc lets a function be an http.RoundTripper, like http.HandlerFunc.
type RoundTripFunc func(*http.Request) (*http.Response, error)

func (f RoundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }

func respond(status int, body string) *http.Response {
	return &http.Response{
		StatusCode: status,
		Header:     http.Header{"Content-Type": {"application/json"}},
		Body:       io.NopCloser(strings.NewReader(body)),
	}
}

func newClient(rt http.RoundTripper) *Client {
	return &Client{HTTP: &http.Client{Transport: rt}, BaseURL: "https://api.example.test", Token: "t0ken"}
}

func TestRate(t *testing.T) {
	var seen *http.Request
	c := newClient(RoundTripFunc(func(r *http.Request) (*http.Response, error) {
		seen = r
		return respond(200, `{"code":"EUR","rate":0.92}`), nil
	}))

	got, err := c.Rate(context.Background(), "EUR")
	if err != nil {
		t.Fatal(err)
	}
	if got != (Rate{"EUR", 0.92}) {
		t.Errorf("got %+v", got)
	}
	if seen.URL.String() != "https://api.example.test/v1/rates/EUR" {
		t.Errorf("url = %s", seen.URL)
	}
	if seen.Header.Get("Authorization") != "Bearer t0ken" {
		t.Errorf("auth header = %q", seen.Header.Get("Authorization"))
	}
}

func TestRateFailures(t *testing.T) {
	boom := errors.New("connection reset")
	tests := []struct {
		name string
		rt   RoundTripFunc
		want string
	}{
		{"server error", func(*http.Request) (*http.Response, error) { return respond(503, ``), nil }, "unexpected status 503"},
		{"bad json", func(*http.Request) (*http.Response, error) { return respond(200, `{"code":`), nil }, "decode"},
		{"transport error", func(*http.Request) (*http.Response, error) { return nil, boom }, "connection reset"},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			_, err := newClient(tt.rt).Rate(context.Background(), "EUR")
			if err == nil || !strings.Contains(err.Error(), tt.want) {
				t.Fatalf("err = %v, want it to contain %q", err, tt.want)
			}
			if tt.name == "transport error" && !errors.Is(err, boom) {
				t.Error("transport error was not wrapped with %w")
			}
		})
	}
}

func TestRateHonoursContext(t *testing.T) {
	c := newClient(RoundTripFunc(func(r *http.Request) (*http.Response, error) {
		<-r.Context().Done()
		return nil, r.Context().Err()
	}))
	ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
	defer cancel()

	_, err := c.Rate(ctx, "EUR")
	if !errors.Is(err, context.DeadlineExceeded) {
		t.Fatalf("err = %v, want DeadlineExceeded", err)
	}
	t.Logf("error: %v", err)
}

func TestPathIsEscaped(t *testing.T) {
	var path string
	c := newClient(RoundTripFunc(func(r *http.Request) (*http.Response, error) {
		path = r.URL.EscapedPath()
		return respond(200, `{}`), nil
	}))
	c.Rate(context.Background(), "a/b c")
	t.Logf("escaped path: %s", path)
}

RoundTripFunc is a function type with a RoundTrip method that calls itself, exactly like http.HandlerFunc. A closure becomes a transport by conversion. respond builds an *http.Response with a status, a header and a body wrapped in io.NopCloser. Those are the only fields the code reads. If your code looks at resp.Request, ContentLength or Proto, fill them in.

Shell
go test -count=1 -v . 2>&1 | grep -vE '^(=== RUN|=== PAUSE|=== CONT|PASS|ok)' | sed -E 's/ \([0-9.]+s\)//; s#^ +[a-z_]+\.go:[0-9]+: #    #'
Text
--- PASS: TestRate
--- PASS: TestRateFailures
    --- PASS: TestRateFailures/server_error
    --- PASS: TestRateFailures/bad_json
    --- PASS: TestRateFailures/transport_error
    error: rates: Get "https://api.example.test/v1/rates/EUR": context deadline exceeded
--- PASS: TestRateHonoursContext
    escaped path: /v1/rates/a%2Fb%20c
--- PASS: TestPathIsEscaped

The happy path. The closure stores the request it received. After the call the test asserts on the result and also on what the client sent: the exact URL and the Authorization header. A client that forgets to set the header, or builds the wrong URL, fails here, with no server involved. This is the biggest advantage of a fake transport over a canned httptest.Server. You see the request exactly as the standard library would have put it on the wire, without the cost of a socket.

Failures in a table. The three rows are a 503 with an empty body, a 200 with truncated JSON and a transport that returns an error. The client must map each to an error that says what happened. The transport error row also asserts errors.Is(err, boom). That works because Rate wraps with %w; the pattern is covered in testing errors with Is and As. Note that http.Client.Do wraps whatever the transport returns in a *url.Error, so the error text begins with the method and URL, and errors.Is still reaches the original through the chain.

Deadlines. The fourth test installs a transport that blocks until the request's context is done and then returns the context's error. The test gives the call a 20 millisecond timeout and expects the client to return promptly with context.DeadlineExceeded. The log shows the message: rates: Get "https://api.example.test/v1/rates/EUR": context deadline exceeded. That is real behaviour of http.Client plus the code under test; the transport only chooses to wait. The test takes about the duration of the timeout, which is the one place real time enters. If it matters, reduce it, or use a fake clock or synctest for retry loops with backoff.

Escaping. The last test records the escaped path for a code of a/b c. The log shows /v1/rates/a%2Fb%20c, so the slash and the space are encoded and cannot change which resource is addressed. A bug here becomes a security issue, so it is worth one row.

Rules a RoundTripper must follow

The http.RoundTripper documentation lists them, and a fake that breaks them can hide bugs:

  • It must return a non-nil response if it returns a nil error, and a nil response if it returns a non-nil error.
  • It must not modify the request. Clone it if you need to change it.
  • It should not interpret a non-2xx status as an error. That is the caller's job, as the 503 row shows.
  • The response body must be closable and the caller is responsible for closing it. A body that records whether Close was called finds leaks in your client, and the defer resp.Body.Close() in Rate is what such a check protects.

The same interface is how libraries add behaviour to a client: logging, retries, tracing and authentication wrap a transport and call the next one. Testing your own middleware transport is the same technique, with the fake as the inner transport.

Testing a retry wrapper with the same fake

Retry logic is where a fake transport earns its keep. Suppose you wrap the transport so a 503 or a connection error is retried up to three times. The inner fake can count calls and answer from a slice: 503, 503, then 200. The test then asserts that the client returned the third answer, that the inner transport saw exactly three requests, and that the body of the request, if it had one, was sent in full each time. That last check finds a classic bug, because a request body is a stream and a retry that reuses it sends nothing the second time unless GetBody is set. A real server makes the sequence awkward to arrange; here it is a three-element slice and a counter.

For backoff between attempts, inject a sleep function or a clock rather than calling time.Sleep, so the test does not wait.

Fake transport or httptest.Server

Both are valid, and they answer different questions. A fake transport tests the client logic: URLs, headers, status handling, decoding, error wrapping. It is fast, deterministic and needs no ports. httptest.NewServer tests the client against real HTTP behaviour: connection reuse, chunked bodies, redirects, TLS and the actual Transport settings. If you configure timeouts on the real transport, only a real server exercises them. A practical split is to cover the logic with a fake and keep two or three tests against httptest.Server for the wiring.

Neither proves that the real service behaves like your canned responses. Keep the examples close to the vendor's documented payloads, and if you can, run a contract check against their sandbox on a schedule. The contract is in the http.RoundTripper documentation, and the same idea for your own dependencies is in mocking with small interfaces. The net/http/httptest package covers the real-server side.

Also inWeb
Taggednet http
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.