Учимся работать с пакетом 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.
Надеюсь, эта информация была вам полезна.