Учимся работать с пакетом httptest в Golang
Стандартная библиотека Golang содержит очень большое количество пакетов, что упрощает разработку и позволяет применять эту технологию в разных сферах, таких как системное программирование, построение HTTP-сервисов, парсинг, сети и т. д. Так как в Go есть встроенный HTTP-сервер, это позволяет быстро и гибко создавать бэкенд с REST-ориентированной архитектурой. В Go помимо HTTP-сервера имеется и инструментарий для тестирования всего, что связано с HTTP. Эта статья призвана дать вводную информацию о том, как тестировать HTTP-хендлеры вашего сервера или HTTP-запросы на внешние ресурсы.
Пример приложения
Для примера мы реализуем приложение, которое по запросу будет выдавать текущий курс Bitcoin к USD. Данные приложение будет брать с нескольких ресурсов и вычислять среднее значение.

Код этого приложения можно получить на GitHub. Стоит отметить, что приложение создавалось в учебных целях и использовать его в «бою» не рекомендуется, так как часть работы с ошибками была намеренно упущена, для упрощения приложения. Также часто код дублируется для простоты чтения.
Реализация
Начнем с создания простого HTTP сервера, который в будущем по запросу на «/rate/btc» будет выдавать усредненный курс Bitcoin. Для начала создадим файл main.go в выбранной вами директории (пример).
// main.go
package main
import (
	"log"
)
func init() {
	// Включаем номера строк в логах
	log.SetFlags(log.LstdFlags | log.Lshortfile)
}
func main() {
	// Напечатает: "[date] [time] [filename]:[line]: [text]"
	log.Println("First line")
}
Для гибкого запуска приложения нужна возможность конфигурации, например указать адрес сервера. В соответствии с манифестом «Twelve-Factor App» приложение должно хранить конфигурацию в переменных окружения (env vars или env). Golang позволяет работать с переменными окружения через пакет os. Добавим код, чтобы получить значение из переменной API_ADDRESS.
// main.go
package main
import (
	"fmt"
	"log"
	"os"
)
const (
	defaultApiAddress = ":8080"
)
var (
	apiAddress string
)
func init() {
	log.SetFlags(log.LstdFlags | log.Lshortfile)
	apiAddress = getVar("API_ADDRESS", defaultApiAddress)
}
func main() {
	log.Println(fmt.Sprintf("Server address: %s", apiAddress))
}
// Получить переменную среды или значение по умолчанию
// See https://golang.org/pkg/os/#Getenv
func getVar(key string, fallback string) string {
	if value := os.Getenv(key); value != "" {
		return value
	}
	return fallback
}
Также не забываем проверить работоспособность кода, покрыв его тестами.
// avg_test.go
package main
import (
	"log"
	"os"
	"testing"
)
func TestGetVarDefaultValue(t *testing.T) {
	defaultValue := "--TEST--"
	variableName := "OS_ENV_TEST_VARIABLE"
	if getVar(variableName, defaultValue) != defaultValue {
		t.Fail()
	}
}
func TestGetVarValueFromENV(t *testing.T) {
	defaultValue := "--TEST--"
	variableName := "OS_ENV_TEST_VARIABLE"
	originValue := "SUPPER+TEST"
	if err := os.Setenv(variableName, originValue); err != nil {
		log.Fatal(err)
	}
	if getVar(variableName, defaultValue) != originValue {
		t.Fail()
	}
}
Далее добавим обработчик для URL «/rate/btc» и проверим работает, ли он. Для того, чтобы можно было покрыть тестами совпадение URL, а не только функцию обработчика, нужно использовать http.NewServeMux() для регистрации обработчиков.
// main.go
package main
import (
	"fmt"
	"log"
	"net/http"
	"os"
)
const (
	defaultApiAddress = ":8080"
)
var (
	apiAddress string
)
func init() {
	log.SetFlags(log.LstdFlags | log.Lshortfile)
	apiAddress = getVar("API_ADDRESS", defaultApiAddress)
}
func main() {
	log.Fatal(http.ListenAndServe(apiAddress, handlers()))
}
func handlers() http.Handler {
	r := http.NewServeMux()
	r.HandleFunc("/rate/btc", func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(http.StatusOK)
		// Хардкодим ответ сервера
		if _, err := fmt.Fprintf(w, "BitCoin to USD rate: %f $\n", 0.0); err != nil {
			log.Println(err)
		}
	})
	return r
}
// Получить переменную среды или значение по умолчанию
// See https://golang.org/pkg/os/#Getenv
func getVar(key string, fallback string) string {
	if value := os.Getenv(key); value != "" {
		return value
	}
	return fallback
}
Реализуем тест, проверяющий результат выполнения запроса по адресу «/rate/btc».
// http_server_test.go
package main
import (
	"fmt"
	"io/ioutil"
	"net/http"
	"net/http/httptest"
	"testing"
)
func TestRouting_RateBTC(t *testing.T) {
	srv := httptest.NewServer(handlers())
	defer srv.Close()
	res, err := http.Get(fmt.Sprintf("%s/rate/btc", srv.URL))
	if err != nil {
		t.Fatal(err)
	}
	if res.StatusCode != http.StatusOK {
		t.Errorf("status not OK")
	}
	defer res.Body.Close()
	body, err := ioutil.ReadAll(res.Body)
	if err != nil {
		t.Fatal(err)
	}
	if string(body) != "BitCoin to USD rate: 0.000000 $\n" {
		t.Fail()
	}
}
Наше приложение готово, чтобы мы обращались к нему через HTTP. Но ему неоткуда брать данные. Давайте исправим это и подготовим ресурсы для получения курса Bitcoin. Так как источников будет несколько, давайте позаботимся, чтобы у нас был один, удобный для нас способ общения с ними. Создадим интерфейс для ресурсов.
// coins-rate/resource.go
package coins_rate
import "context"
// Coins rate resource interface
type Resource interface {
	BitCoinToUSDRate(ctx context.Context) (float64, error)
}
Обратите внимание на параметр метода BitCoinToUSDRate — это структура Context. Наличие этого параметра позволяет делать запросы WithContext, например, отменять запрос, если сервер долго не отвечает.
Приступим к реализации структуры, имплементирующей наш интерфейс. Для примера разберем реализацию HTTP-клиента для ресурса coincap.io. Согласно документации, если сделать GET-запрос по адресу api.coincap.io/v2/rates/bitcoin, в результате мы получим JSON вида.
{
    "data": {
        "id": "bitcoin",
        "symbol": "BTC",
        "currencySymbol": "₿",
        "type": "crypto",
        "rateUsd": "5193.4644857907109342"
    },
    "timestamp": 1554970158599
}
Нас интересует только значение поля rateUsd, поэтому мы реализуем структуру (для декодинга JSON), которая будет содержать только это поле.
type coinCapResponse struct {
	Data struct {
		// опция string говорит о том, что float64 нужно парсить из строки
		RateUsd float64 `json:"rateUsd,string"` 
	} `json:"data"`
}
Давайте разберем реализацию самого HTTP-ресурса. Для таких структур есть несколько рекомендаций:
- http.Client{} должен быть параметром, передаваемым из вне. В реальном мире вы захотите сконфигурировать прокси, SSL, таймауты и т. д., и если создавать http.Client{} внутри наших ресурсов, это придется делать в каждом.
 - Адрес ресурса (baseUrl) должен быть параметром передаваемым из вне. Это нужно для написания unit-тестов (смотреть ниже).
 
Разберем код ресурса.
// coins-rate/coin_cap_resource.go
package coins_rate
import (
	"context"
	"encoding/json"
	"fmt"
	"log"
	"net/http"
	"github.com/pkg/errors"
)
type coinCapResource struct {
	httpClient *http.Client
	baseUrl    string
}
// return current BitCoin to USD rate on https://coincap.io/
func (rcv *coinCapResource) BitCoinToUSDRate(ctx context.Context) (float64, error) {
	r, err := http.NewRequest("GET", fmt.Sprintf("%s/v2/rates/bitcoin", rcv.baseUrl), nil)
	if err != nil {
		// Старайтесь всегда делать Wrap ошибок, так легче определить что произошло
		return 0, errors.Wrap(err, "[CoinCap]")
	}
	// Добавляем заголовок, указывающий какой формат данных мы ожидаем
	r.Header.Set("Accept", "application/json")
	// Если пришел контекст применяем его к реквесту
	if ctx != nil {
		r = r.WithContext(ctx)
	}
	// Выполняем http запрос
	res, err := rcv.httpClient.Do(r)
	if err != nil {
		return 0, errors.Wrap(err, "[CoinCap]")
	}
	if res.StatusCode != http.StatusOK {
		return 0, errors.New("[CoinCap] not OK status code")
	}
	// во избежания утечки памяти всегда закрывайте ресурс
	defer func() {
		if err := res.Body.Close(); err != nil {
			log.Println(err)
		}
	}()
	var data coinCapResponse
	// Декодируем ответ
	if err := json.NewDecoder(res.Body).Decode(&data); err != nil {
		return 0, errors.Wrap(err, "[CoinCap]")
	}
	return data.Data.RateUsd, nil
}
// Так как структура coinCapResource приватная (не доступна в других пакетах),
// реализуем конструктор какой будет возвращать ссылку на структу
// c 'type cast'-том на интерфейс Resource
func NewCoinCapResource(httpClient *http.Client) Resource {
	return &coinCapResource{httpClient: httpClient, baseUrl: "https://api.coincap.io"}
}
Разберем еще несколько тестов. Старайтесь писать маленькие, узконаправленные тесты. Ими легче управлять.
// coins-rate/coin_cap_resource_test.go
package coins_rate
import (
	"context"
	"net/http"
	"net/http/httptest"
	"testing"
	"time"
)
func TestCoinCapResource_BitCoinToUSDRate(t *testing.T) {
	// Создаем тестовый сервер
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		// Возвращаем JSON (согласно документации)
		w.WriteHeader(http.StatusOK)
		w.Write([]byte(`{"data":{"id":"bitcoin","symbol":"BTC","currencySymbol":"₿","type":"crypto","rateUsd":"4010.8714336221081818"},"timestamp":1552990697033}`))
		return
	}))
	resource := coinCapResource{
		httpClient: server.Client(), // http клиент, умеющий работать с тестовым сервером
		baseUrl:    server.URL,      // URL тестового сервера
	}
	// делаем запрос
	result, err := resource.BitCoinToUSDRate(nil)
	// если запрос вернул ошибку фелим тест
	if err != nil {
		t.Error(err)
	}
	// если нет ошибки, но результат нулевой - фелим тест
	if result == 0 {
		t.Fail()
	}
}
func TestCoinCapResource_BitCoinToUSDRateNotOK(t *testing.T) {
	// Создаем тестовый сервер
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		// Возвращаем ошибку
		w.WriteHeader(http.StatusBadRequest)
		return
	}))
	resource := coinCapResource{
		httpClient: server.Client(), // http клиент, умеющий работать с тестовым сервером
		baseUrl:    server.URL,      // URL тестового сервера
	}
	// делаем запрос
	result, err := resource.BitCoinToUSDRate(nil)
	// мы ожидаем ошибку и если ее нет фейлим тест
	if err == nil {
		t.Fail()
	}
	// если текст ошибки не соотвествует ожидаемому - фейлим тест
	if err.Error() != "[CoinCap] not OK status code" {
		t.Fail()
	}
	// мы ожидаем ошибку и если результат не нулевой - фейлим тест
	if result != 0 {
		t.Fail()
	}
}
func TestCoinCapResource_BitCoinToUSDRateTimout(t *testing.T) {
	// Создаем тестовый сервер
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		// При обращении к серверу он будет замирать на 100 милисекудн,
		// а потом отдавать ошибку
		time.Sleep(100 * time.Millisecond)
		w.WriteHeader(http.StatusNoContent)
		return
	}))
	resource := coinCapResource{
		httpClient: server.Client(), // http клиент, умеющий работать с тестовым сервером
		baseUrl:    server.URL,      // URL тестового сервера
	}
	// Создаем контекст с тамаутом в 10 милисекунд (это в 10 раз меньше чем ответ сервера)
	ctx := context.Background()
	ctx, _ = context.WithTimeout(ctx, 10*time.Millisecond)
	// делаем запрос
	result, err := resource.BitCoinToUSDRate(ctx)
	// мы ожидаем ошибку и если ее нет фейлим тест
	if err == nil {
		t.Fail()
	}
	// мы ожидаем ошибку и если результат не нулевой - фейлим тест
	if result != 0 {
		t.Fail()
	}
}
Больше тестов можно найти на GitHub. Еще две реализации ресурсов очень похожи на рассмотренную выше. Исходный код можно найти здесь.
Пришло время связать наши ресурсы с HHTP-сервером. Для начала напишем функцию вычисления среднего значения из массива чисел.
// main.go
// average array numbers
func avg(data []float64) float64 {
	if len(data) == 0 {
		return 0 // prevent  division by zero
	}
	var total float64 = 0
	for _, value := range data {
		total += value
	}
	return total / float64(len(data))
}
Не забываем тесты для новой функции.
// avg_test.go
package main
import (
	"testing"
)
func TestAvg(t *testing.T) {
	xs := []float64{98, 93, 77, 82, 83}
	if avg(xs) != 86.6 {
		t.Fail()
	}
}
func TestAvgEmpty(t *testing.T) {
	xs := []float64{}
	if avg(xs) != 0 {
		t.Fail()
	}
}
Во избежание усложнений мы не будем прибегать к использованию каких-либо реализаций «dependency injection», а просто инстанциируем ресурсы.
// main.go
var (
	apiAddress    string
	httpClient    *http.Client
	rateResources []coins_rate.Resource
)
func init() {
	log.SetFlags(log.LstdFlags | log.Lshortfile)
	apiAddress = getVar("API_ADDRESS", defaultApiAddress)
	httpClient = &http.Client{}
	rateResources = make([]coins_rate.Resource, 3)
	rateResources[0] = coins_rate.NewCoinCapResource(httpClient)
	rateResources[1] = coins_rate.NewCryptoCompareResource(httpClient)
	rateResources[2] = coins_rate.NewCryptonatorResource(httpClient)
}
Также модифицируем хендлер, чтобы он использовал ресурсы для получения данных.
// main.go
// Handler to get BitCoin rate
func getBitcoinRateHandler(w http.ResponseWriter, r *http.Request) {
      // Каждый http запрос будет выполнен в отдельной горутине.
      // Чтобы собрать результаты этих запросов воедино мы будем
      // использовать sync.WaitGroup
	var wg sync.WaitGroup 
	var mux sync.RWMutex
      // Переменная result будет служить хранилищем для результата всех горутине.
      // Когда N-горутин работают с одним ресурсом, возможна ситуация с data race.
      // Для предотвращения data race мы будем использовать sync.RWMutex
	var result []float64
	wg.Add(len(rateResources))
	for _, res := range rateResources {
		go func(res coins_rate.Resource) {
			defer wg.Done()
			rate, err := res.BitCoinToUSDRate(nil)
			if err != nil {
				log.Println(err)
				return
			}
			mux.Lock() // предотвращаем data race
			result = append(result, rate)
			mux.Unlock()
		}(res)
	}
      // Дожидаемся завершения всех запросов
	wg.Wait()
	if len(result) == 0 {
		w.WriteHeader(http.StatusNotFound)
		if _, err := fmt.Fprint(w, "There is not result\n"); err != nil {
			log.Println(err)
		}
		return
	}
	w.WriteHeader(http.StatusOK)
	if _, err := fmt.Fprintf(w, "BitCoin to USD rate: %f $\n", avg(result)); err != nil {
		log.Println(err)
	}
}
Последовательное выполнение HTTP-запросов приведет к суммированию времени выполнения. А если будет добавлено еще несколько ресурсов, время работы приложения будет увеличиваться. Решить эту проблему можно, прибегнув к запуску каждого запроса в goroutine.
Напишем тест для хедлера. Для начала реализуем stub (заглушку) для ресурсов.
// http_server_test.go
// Stub для ресурса
type testResource struct {
	result float64
	err    error
}
// Имплементируем интерфейс coins_rate.Resource
func (rcv *testResource) BitCoinToUSDRate(ctx context.Context) (float64, error) {
	return rcv.result, rcv.err
}
Наш тест должен сделать HTTP-запрос по адресу «/rate/btc» и сравнить ответ с ожидаемым результатом. Давайте напишем такой тест.
// http_server_test.go
func TestRouting_RateBTC(t *testing.T) {
	rateResources = make([]coins_rate.Resource, 2)
	rateResources[0] = &testResource{result: 10.5} // используем stub
	rateResources[1] = &testResource{result: 20.5} // используем stub
	// Создаем тестовый http сервер с сконфигурированных http.ServeMux
	srv := httptest.NewServer(handlers())
	defer srv.Close()
	// srv.URL содержит адрес тестового сервера (что то похожее на http://127.0.0.1:38143)
	res, err := http.Get(fmt.Sprintf("%s/rate/btc", srv.URL))
	if err != nil {
		t.Fatal(err)
	}
	// Проверяем статус ответа
	if res.StatusCode != http.StatusOK {
		t.Errorf("status not OK")
	}
	defer res.Body.Close()                // всегда закрываем ресурс
	body, err := ioutil.ReadAll(res.Body) // считываем body ответа
	if err != nil {
		t.Fatal(err)
	}
	// сравниваем результат
	if string(body) != "BitCoin to USD rate: 15.500000 $\n" {
		t.Fail()
	}
}
Больше тестов можно найти здесь. Запустим все тесты go test -race ./... .Приложение готово к запуску. Давайте скомпилируем и запустим его.
$ # компиляция $ CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o go-app main.go $ # запуск приложения $ ./go-app $ # или запуск с указанием адреса $ API_ADDRESS=:8181 ./go-app
Если сделать GET-запрос по адресу localhost:8080/rate/btc, мы получим усредненный курс Bitcoin.

Выводы
В этой статье мы реализовали примитивный HTTP-сервер, который делает N HTTP-запросов на внешние ресурсы для вычисления результата. Для написания Unit-тестов под это приложение использовался базовый функционал пакета httptest.
Надеюсь, эта информация была вам полезна.