Учимся работать с пакетом 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.

Надеюсь, эта информация была вам полезна.

Похожие статьи:
«Український кіберальянс» (УКА) — спільнота українських кіберактивістів, яка виникла після об’єднання кількох хакерських груп...
Міністерство економіки запустило онлайн-платформу «Пульс», на якій збиратиме фідбек від власників бізнесу й ФОП про взаємодію...
28 грудня у «Часописі» говоритимемо про технологічні тренди наступних 10 років з Ярославом Ажнюком і Павлом Башмаковим, які...
Другий міський фестиваль винахідників Kyiv Mini Maker Faire відбудеться 14 листопада на ВНДХ. Він об’єднає більше 100 учасників, які...
Компания Lenovo представила на выставке CES 2016 обновленную линейку устройств серии ThinkPad X1, включая планшет ThinkPad X1 с модульной...
Яндекс.Метрика