« Back to Index

Mock HTTP Response via RoundTripper interface

View original Gist on GitHub

Tags: #go #http

0. README.md

2025 Update

Here’s the latest approach I’ve used: https://github.com/fastly/cli/pull/1374/commits/98742dcfebe896e7b97bfbdb8f72906aede71594

First, the test itself:

// pkg/commands/domainv1/domain_test.go
package domainv1_test

import (
	"bytes"
	"fmt"
	"io"
	"net/http"
	"testing"

	v1 "github.com/fastly/go-fastly/v9/fastly/domains/v1"

	root "github.com/fastly/cli/pkg/commands/domainv1"
	"github.com/fastly/cli/pkg/testutil"
)

func TestDomainV1Create(t *testing.T) {
	fqdn := "www.example.com"
	sid := "123"
	did := "domain-id"

	scenarios := []testutil.CLIScenario{
		{
			Args:      "",
			WantError: "error parsing arguments: required flag --fqdn not provided",
		},
		{
			Args: fmt.Sprintf("--fqdn %s --service-id %s", fqdn, sid),
			Client: &http.Client{
				Transport: &testutil.MockRoundTripper{
					Response: &http.Response{
						StatusCode: http.StatusOK,
						Status:     http.StatusText(http.StatusOK),
						Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(v1.Data{
							DomainID:  did,
							FQDN:      fqdn,
							ServiceID: &sid,
						}))),
					},
				},
			},
			WantOutput: fmt.Sprintf("SUCCESS: Created domain '%s' (domain-id: %s, service-id: %s)", fqdn, did, sid),
		},
	}
	testutil.RunCLIScenarios(t, []string{root.CommandName, "create"}, scenarios)
}

Second, the supporting test util functions:

// pkg/testutil/client.go

package testutil

import (
  "encoding/json"
  "net/http"
)

// MockRoundTripper implements [http.RoundTripper] for mocking HTTP responses
type MockRoundTripper struct {
	Response *http.Response
	Err      error
}

// RoundTrip executes a single HTTP transaction, returning a Response for the
// provided Request.
func (m *MockRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
	return m.Response, m.Err
}

// GenJSON returns JSON encoding of data, or empty object in case of an error.
func GenJSON(data any) []byte {
	b, err := json.Marshal(data)
	if err != nil {
		return []byte("{}")
	}
	return b
}

Third, modified scenario codebase:

// pkg/testutil/scenarios.go

var acf global.APIClientFactory
if scenario.Client != nil {
  acf = func(_, _ string, _ bool) (api.Interface, error) {
    fc, err := fastly.NewClientForEndpoint("no-key", "api.example.com")
    if err != nil {
      return nil, fmt.Errorf("failed to mock fastly.Client: %w", err)
    }
    fc.HTTPClient = scenario.Client
    return fc, nil
  }
} else {
  acf = mock.APIClient(scenario.API)
}
opts.APIClientFactory = acf

1. Golang Mock Response via RoundTripper interface.go

package main

import (
	"bytes"
	"fmt"
	"io/ioutil"
	"net/http"
	"os"
	"time"
)

type MockRoundTripper struct{}

func (rt MockRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
	fmt.Printf("http.Request: %+v\n", req)

	res := &http.Response{
		StatusCode: 404,
		Body:       ioutil.NopCloser(bytes.NewBufferString("Not Found!?")),
		Header:     make(http.Header),
	}

	return res, nil
}

func main() {
	httpclient := &http.Client{
		Timeout:   time.Second * 10,
		Transport: MockRoundTripper{},
	}

	response, err := httpclient.Get("https://www.integralist.co.uk/")
	if err != nil {
		fmt.Println(err.Error())
		os.Exit(1)
	}
	defer response.Body.Close()

	body, err := ioutil.ReadAll(response.Body)
	if err != nil {
		fmt.Println(err.Error())
		os.Exit(1)
	}

	fmt.Println(string(body))
	fmt.Println(response.Status)
}

2. Golang Mock Response via RoundTripper interface (abstraction layer).go

// modified from https://gist.github.com/jarcoal/8940980
//

package main

import (
	"bytes"
	"fmt"
	"io/ioutil"
	"net/http"
	"os"
	"time"
)

type Responder func(*http.Request) (*http.Response, error)

type MockRoundTripperMultiples struct {
	host       string
	responders map[string]Responder
}

func (rt MockRoundTripperMultiples) RoundTrip(req *http.Request) (*http.Response, error) {
	key := req.Method + " " + req.URL.String()

	for k, r := range rt.responders {
		if k != key {
			continue
		}
		return r(req)
	}

	return http.DefaultTransport.RoundTrip(req)
}

func (rt *MockRoundTripperMultiples) RegisterResponder(method, path string, responder Responder) {
	if rt.responders == nil {
		rt.responders = make(map[string]Responder)
	}
	rt.responders[method+" "+rt.BuildURL(path)] = responder
}

func (rt *MockRoundTripperMultiples) BuildURL(path string) string {
	return "https://" + rt.host + path
}

func requestOK(*http.Request) (*http.Response, error) {
	return &http.Response{
		StatusCode: 200,
		Body:       ioutil.NopCloser(bytes.NewBufferString("OK!!!")),
		Header:     make(http.Header),
	}, nil
}

func requestBad(*http.Request) (*http.Response, error) {
	return &http.Response{
		StatusCode: 400,
		Body:       ioutil.NopCloser(bytes.NewBufferString("Bad Request!??")),
		Header:     make(http.Header),
	}, nil
}

func get(url string, httpclient *http.Client) *http.Response {
	res, err := httpclient.Get(url)
	if err != nil {
		fmt.Println(err.Error())
		os.Exit(1)
	}

	return res
}

func read(url string, httpclient *http.Client) {
	res := get(url, httpclient)

	body, err := ioutil.ReadAll(res.Body)
	if err != nil {
		fmt.Println(err.Error())
		os.Exit(1)
	}
	defer res.Body.Close()

	fmt.Println(string(body))
	fmt.Println(res.Status)
}

func main() {
	transport := &MockRoundTripperMultiples{
		host: "www.integralist.co.uk",
	}
	transport.RegisterResponder("GET", "/good", requestOK)
	transport.RegisterResponder("GET", "/bad", requestBad)

	httpclient := &http.Client{
		Timeout:   time.Second * 10,
		Transport: transport,
	}

	read("https://www.integralist.co.uk/good", httpclient)
	read("https://www.integralist.co.uk/bad", httpclient)

	// no registered 'responder' matches the following URL so it will fallback to
	// using http.DefaultTransport.RoundTrip implementation to make a 'real'
	// network request.
	read("https://www.integralist.co.uk/about", httpclient)
}