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