GO Lang testing HTTP client and server

Huge thanks to Boldly Go for making quite short and exactly wanted videos

Testing HTTP client

Here is sample http client

package main

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

func main() {
	r := NewRepository("https://catfact.ninja/fact")
	fact, err := r.GetRandomFactAboutCats()
	if err != nil {
		panic(err)
	}
	fmt.Println(fact)
}

type repository struct {
	address string
	client  *http.Client
}

func NewRepository(address string) *repository {
	return &repository{
		address: address,
		client:  &http.Client{},
	}
}

func (r *repository) GetRandomFactAboutCats() (string, error) {
	resp, err := r.client.Get(r.address)
	if err != nil {
		return "", err
	}
	defer resp.Body.Close()
	var data struct {
		Fact string `json:"fact"`
	}
	err = json.NewDecoder(resp.Body).Decode(&data)
	if err != nil {
		return "", err
	}
	return data.Fact, nil
}

notes:

  • does not matter if we are passing adress or not, key point here is to be able to pass http.Client it self

And here are examples of two approaches

package main

import (
	"encoding/json"
	"io"
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"
)

func TestWithServer(t *testing.T) {
	want := "cats have nine lives"

	s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		// here we may write as much conditions as we want
		// e.g.:
		// if r.Method != http.MethodGet { ... }
		// if r.URL.Path != "/fact" { ... }
		w.Header().Set("Content-Type", "application/json")
		_ = json.NewEncoder(w).Encode(map[string]string{
			"fact": want,
		})
	}))
	defer s.Close()

	// `s.URL` is the URL of the test server
	// if, we would pass `http.Client` as a parameter to the repository
	// we would pass `s.Client()` instead of `http.Client`
	r := NewRepository(s.URL)
	got, err := r.GetRandomFactAboutCats()
	if err != nil {
		t.Fatal(err)
	}
	if got != want {
		t.Errorf("got %q, want %q", got, want)
	}
}

func TestWithFake(t *testing.T) {
	client := &http.Client{
		Transport: FakeService(func(req *http.Request) (*http.Response, error) {
			// the same way, here we may introduce as much conditions as we want
			return &http.Response{
				StatusCode: http.StatusOK,
				Header: http.Header{
					"Content-Type": []string{"application/json"},
				},
				Body: io.NopCloser(strings.NewReader(`{"fact":"cats have nine lives"}`)),
			}, nil
		}),
	}

	// once again, at the very end the goal is to pass `http.Client`
	r := &repository{
		client: client,
	}

	got, err := r.GetRandomFactAboutCats()
	if err != nil {
		t.Fatal(err)
	}

	want := "cats have nine lives"
	if got != want {
		t.Errorf("got %q, want %q", got, want)
	}
}

// our fake service definition
type FakeService func(*http.Request) (*http.Response, error)

// with single method, that will be called instead of http.Client
func (fake FakeService) RoundTrip(req *http.Request) (*http.Response, error) {
	return fake(req)
}

notes:

  • in both cases, at the very end, we are passing http.Client so make sure it is "injectable"

Testing HTTP server

Second one is about HTTP servers, so here is our server example:

package main

import (
	"fmt"
	"log"
	"net/http"
)

func main() {
	http.HandleFunc("/", hello)
	log.Fatal(http.ListenAndServe(":8080", nil))
}

func hello(w http.ResponseWriter, r *http.Request) {
	w.Header().Set("Content-Type", "text/plain")
	fmt.Fprintln(w, "Hello World")
}

and corresponding test

package main

import (
	"io"
	"net/http"
	"net/http/httptest"
	"testing"
)

func TestWithServer(t *testing.T) {
	// note: we are bootstraping whole server here
	s := httptest.NewServer(http.HandlerFunc(hello))
	defer s.Close()

	req, err := http.NewRequest(http.MethodGet, s.URL, nil)
	if err != nil {
		t.Fatal(err)
	}

	// note - we are using `http.DefaultClient` here, so actual requests will be sent
	res, err := http.DefaultClient.Do(req)
	if err != nil {
		t.Fatal(err)
	}
	defer res.Body.Close()
	body, err := io.ReadAll(res.Body)
	if err != nil {
		t.Fatal(err)
	}
	got := string(body)
	want := "Hello World\n"
	if got != want {
		t.Errorf("got %q, want %q", got, want)
	}
}

func TestWithRecorder(t *testing.T) {
	req := httptest.NewRequest(http.MethodGet, "/", nil)
	recorder := httptest.NewRecorder()

	// instead of real request, we are calling our handler directly
	// passing `recorder` as a response writer
	hello(recorder, req)

	// after handler is called we can extract response from recorder
	res := recorder.Result()

	// rest is the same as in the previous test
	defer res.Body.Close()
	body, err := io.ReadAll(res.Body)
	if err != nil {
		t.Fatal(err)
	}
	got := string(body)
	want := "Hello World\n"
	if got != want {
		t.Errorf("got %q, want %q", got, want)
	}
}