Chapter 11: HTTP Client Interface Mock¶
Description¶
Define an HTTPClient interface with a Do(*http.Request) (*http.Response, error) method matching the http.Client signature. Production code uses this interface; tests provide a stub that returns canned responses. This is the simplest and most testable HTTP mocking strategy: no test server, no transport hacking, just an interface.
Code¶
type HTTPClient interface {
Do(req *http.Request) (*http.Response, error)
}
type WeatherClient struct {
client HTTPClient
apiKey string
}
func NewWeatherClient(client HTTPClient, apiKey string) *WeatherClient {
return &WeatherClient{client: client, apiKey: apiKey}
}
func (c *WeatherClient) GetWeather(city string) (*WeatherResponse, error) {
url := fmt.Sprintf("https://api.weather.com/v1/%s?key=%s", url.PathEscape(city), c.apiKey)
resp, err := c.client.Do(req)
if err != nil {
return nil, fmt.Errorf("weather request: %w", err)
}
// parse response...
}
Test¶
type mockHTTPClient struct {
DoFunc func(req *http.Request) (*http.Response, error)
}
func (m *mockHTTPClient) Do(req *http.Request) (*http.Response, error) {
return m.DoFunc(req)
}
func TestWeatherClient_GetWeather(t *testing.T) {
type fields struct {
client *mockHTTPClient
}
before := func(t *testing.T) fields {
t.Helper()
return fields{client: &mockHTTPClient{}}
}
t.Run("success", func(t *testing.T) {
f := before(t)
f.client.DoFunc = func(req *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(
`{"city":"London","temp_c":15.5,"condition":"Cloudy"}`,
)),
}, nil
}
c := NewWeatherClient(f.client, "test-key")
w, err := c.GetWeather("London")
require.NoError(t, err)
assert.Equal(t, "London", w.City)
assert.Equal(t, 15.5, w.TempC)
})
t.Run("API error", func(t *testing.T) {
f := before(t)
f.client.DoFunc = func(req *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusInternalServerError,
Body: io.NopCloser(strings.NewReader(`{}`)),
}, nil
}
_, err := NewWeatherClient(f.client, "test-key").GetWeather("London")
assert.Error(t, err)
assert.Contains(t, err.Error(), "500")
})
t.Run("network failure", func(t *testing.T) {
f := before(t)
f.client.DoFunc = func(req *http.Request) (*http.Response, error) {
return nil, errors.New("connection refused")
}
_, err := NewWeatherClient(f.client, "test-key").GetWeather("London")
assert.Error(t, err)
assert.Contains(t, err.Error(), "connection refused")
})
t.Run("invalid JSON", func(t *testing.T) {
f := before(t)
f.client.DoFunc = func(req *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(`not json`)),
}, nil
}
_, err := NewWeatherClient(f.client, "test-key").GetWeather("London")
assert.Error(t, err)
assert.Contains(t, err.Error(), "parse")
})
}
Testing Approach¶
The HTTP client interface mock:
- Interface segregation —
HTTPClient{ Do(*http.Request) (*http.Response, error) }is a single-method interface. Productionhttp.Clientsatisfies it. The stub implements it with a function field. - Per-test behavior — each subtest sets
DoFuncto return exactly what it needs (success, errors, status codes). No global mock state leaks between tests. - Error paths visible — network failures, HTTP errors, and malformed responses are all trivially testable by changing what
DoFuncreturns. No need to start/stop test servers. - Zero dependencies — the mock is a 6-line struct. No testify/mock, no httptest. The pattern scales: add a
DoFuncfield and each test configures it inline.
View source code on GitHub