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

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

Похожие статьи:
Як за час повномасштабної війни змінилася кількість відряджень і як це впливає на українські IT-компанії? Що, окрім перемоги...
З моменту, коли росія почала повномасштабну війну проти України, минуло 10 днів. Понад тиждень, як життя кожного з нас...
Ще 10 років тому український ринок побутової техніки часто об’єднували з російським. Наприклад, пральні машинки Samsung...
Месяц назад прошла конференция Build 2016, на которой помимо прочего рассказали, что Xamarin теперь бесплатен для всех...
Привіт, мої любі сішники! В цьому випуску пропоную ознайомитися з VR, видалити dead code з legacy та почати...
Яндекс.Метрика