Тестирование HTTP хендлеров в Go
Перевод статьи "Testing Your (HTTP) Handlers in Go".
Вы пишите веб-сервис на Go и, конечно же, вы хотите тестировать ваши хендлеры. У вас используется пакет net/http, но вы не знаете как протестировать возврат корректных кодов в ваших хендлерах, правильность заголовков HTTP и насколько верно был сформирован ответ клиенту.
Давайте обсудим эти вопросы и посмотрим как можно писать более тестируемые приложения с использованием внедрения зависимостей и моков.
Стандартные хендлеры
Для начала, напишем простой тест, который будет проверят код ответа. В нашем случает это должен быть 200 ответ.
// handlers.go
package handlers
// http.HandleFunc("/health-check", HealthCheckHandler)
func HealthCheckHandler(w http.ResponseWriter, r *http.Request) {
// Очень простой хендлер проверки состояния.
w.WriteHeader(http.StatusOK)
w.Header().Set("Content-Type", "application/json")
// В будущем мы хотим сообщать сообщать о состоянии
// базы данных или кеша (например Redis) выполняя
// простой PING и отдавать все это в запросе
io.WriteString(w, `{"alive": true}`)
}
И вот наш тест:
// handlers_test.go
package handlers
import (
"net/http"
"testing"
)
func TestHealthCheckHandler(t *testing.T) {
// Создаем запрос с указанием нашего хендлера. Нам не нужно
// указывать параметры, поэтому вторым аргументом передаем nil
req, err := http.NewRequest("GET", "/health-check", nil)
if err != nil {
t.Fatal(err)
}
// Мы создаем ResponseRecorder(реализует интерфейс http.ResponseWriter)
// и используем его для получения ответа
rr := httptest.NewRecorder()
handler := http.HandlerFunc(HealthCheckHandler)
// Наш хендлер соответствует интерфейсу http.Handler, а значит
// мы можем использовать ServeHTTP и напрямую указать
// Request и ResponseRecorder
handler.ServeHTTP(rr, req)
// Проверяем код
if status := rr.Code; status != http.StatusOK {
t.Errorf("handler returned wrong status code: got %v want %v",
status, http.StatusOK)
}
// Проверяем тело ответа
expected := `{"alive": true}`
if rr.Body.String() != expected {
t.Errorf("handler returned unexpected body: got %v want %v",
rr.Body.String(), expected)
}
}
Как видите, Go'шные пакеты testing и httptest делают тестирование наших хендлеров очень простым. Мы подготавливаем *http.Request
, a *httptest.ResponseRecorder
и затем используем их для проверки работы самого хендлера(коды, тело ответа и т.д.).
Если ваш хендлер ожидает определенные параметры, то это не проблема:
// например GET /api/projects?page=1&per_page=100
req, err := http.NewRequest("GET", "/api/projects",
// url.Values это просто map[string][]string
url.Values{"page": {"1"}, "per_page": {"100"}})
if err != nil {
t.Fatal(err)
}
// Наш хенлер может ожидать определенный ключ для API
req.Header.Set("Authorization", "Bearer abc123")
// Заем вызываем handler.ServeHTTP(rr, req) как в примере выше.
Кроме того, если есть необходимость проверить как ваш хендлер или миделваре изменяют и мутируют данные запроса, то можно создать анонимную функцию, у которой будет доступ к внешним переменным которые вы сможете проверить в рамках своего теста.
// Declare it outside the anonymous function
var token string
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request){
// ':=' operator so we don't shadow our token variable above.
// Обратите внимание, что тут нужно обязательно использовать
// '=' , а не ':=' чтобы избежать затенения переменной
token = GetToken(r)
// Мы устанавливаем заголовок как и в примере выше
w.Header().Set("Content-Type", "application/json")
})
// Запускаем хендлер, проверяем коды, тело ответа и т.д.
if token != expectedToken {
t.Errorf("token does not match: got %v want %v", token, expectedToken)
}
if ctype := rr.Header().Get("Content-Type"); ctype != "application/json") {
t.Errorf("content type header does not match: got %v want %v",
ctype, "application/json")
}
Маленький лайфхак: для строк application/json
или Content-Type
можно использовать константы, тогда вам не прийдется заново набирать(и ошибаться), а пользоваться автоподстановкой. Опечатка в тесте может быть очень фатальна, потому что вы будете уверенны, что все протестировано и работает правильно.
Не забывайте, что нужно тестировать и отказы, и исключительные ситуации в том числе. Проверяйте, возвращает ли ваш хендлер ошибку, когда это необходимо (например HTTP 403, или HTTP 500)
Заполняем context.Context в тестах
Как быть с хендлерами, которые ожидают данные из context.Context? Есть ли способ руками создать контекст и заполнить его различными данными, например типом пользователя и токеном?
Предпложим, что у вас есть кастомный хендлер который предоставляет метод
ServeHTTPC(context.Context, http.ResponseWriter, *http.Request)
. В Go 1.7 context.Context добавят в http.Request и это сделает жизнь значительно проще.
Для примера ниже я буду использовать Goji роутер. Он предоставляет возможность использовать хендлеры с context.Context
. Тем не менее, описанный способ подойдет для большинства роутеров/фрейиворков, которые работают с context.Context
.
func TestGetProjectsHandler(t *testing.T) {
req, err := http.NewRequest("GET", "/api/users", nil)
if err != nil {
t.Fatal(err)
}
rr := httptest.NewRecorder()
// например func GetUsersHandler(ctx context.Context,
// w http.ResponseWriter, r *http.Request)
goji.HandlerFunc(GetUsersHandler)
// Создаем новый context.Context и заполняем его данными
ctx = context.Background()
ctx = context.WithValue(ctx, "app.auth.token", "abc123")
ctx = context.WithValue(ctx, "app.user",
&YourUser{ID: "qejqjq", Email: "user@example.com"})
// Указываем на контекст *http.Request и ResponseRecorder.
handler.ServeHTTPC(ctx, rr, req)
// Проверяем код, тело ответа и т.д.
if status := rr.Code; status != http.StatusOK {
t.Errorf("handler returned wrong status code: got %v want %v",
status, http.StatusOK)
}
// Тут мы можем проверить какие данные изменились/добавились
// в нашем context.Contex
if id , ok := ctx.Value("app.req.id").(string); !ok {
t.Errorf("handler did not populate the request ID: got %v", id)
}
}
Мокаем обращения к базе данных
Наши хендлеры используют некоторый интерфейс datastore.ProjectStore
с тремя методами (Create
, Get
, Delete
). Мы можем замокать этот интерфейс для наших тестов определенным образом и проверить правильно ли отдаются HTTP коды.
Если вы хотите больше узнать о использовании интерфейсов для абстракции работы с базой данных, то рекомендую прочитать статью Thoughtbot и статью от fAlex Edwards.
// handlers_test.go
package handlers
// Возвращает ошибки в каждом методе
type badProjectStore struct {
// Это конкретный тип, который реализует datastore.ProjectStore.
// Мы встроили его сюда, у нас автоматически добавились необходимые
// методы и теперь наш тип badProjectStore удовлетворяет
// интерфейсу datastore.ProjectStore, без необходимости добавлять заглушки
// каждый метод (в зависимости от теста у нас будет использоваться
// только те или иные методы)
*datastore.Project
}
func (ps *badProjectStore) CreateProject(project *datastore.Project) error {
return datastore.NetworkError{errors.New("Bad connection"}
}
func (ps *badProjectStore) GetProject(id string) (*datastore.Project, error) {
return nil, datastore.NetworkError{errors.New("Bad connection"}
}
func TestGetProjectsHandlerError(t *testing.T) {
var store datastore.ProjectStore = &badProjectStore{}
// Мы выполняем внедрение нашего окружения в хендлеры.
// Ref: http://elithrar.github.io/article/http-handler-error-handling-revisited/
env := handlers.Env{Store: store, Key: "abc"}
req, err := http.NewRequest("GET", "/api/projects", nil)
if err != nil {
t.Fatal(err)
}
rr := httptest.Recorder()
// Handler это кастомный тип, который работает с env and a http.Handler
// GetProjectsHandler обращается к GetProject и должен вернуть 500
// в случае ошибки.
Handler{env, GetProjectsHandler)
handler.ServeHTTP(rr, req)
// Тут мы проверяем, что наш хендлер отработал с ошибкой.
if status := rr.Code; status != http.StatusInternalServeError {
t.Errorf("handler returned wrong status code: got %v want %v"
rr.Code, http.StatusOK)
}
// Мы должны проверить, что в теле JSON тоже есть упоминание ошибки.
expected := []byte(`{"status": 500, "error": "Bad connection"}`)
if !bytes.Equals(rr.Body.Bytes(), expected) {
t.Errorf("handler returned unexpected body: got %v want %v",
rr.Body.Bytes(), expected)
}
Пример выше несколько переусложнен, но нужно обратить внимание на несколько ключевых моментов:
- В нашей заглушке нет никакой работы с базой данных. Модульные тесты в пакете
handlers
не должны ничего знать про базу. - Мы создали заглушку, которая возвращает ошибку и это позволило проверить работу нашего хендлера в исключительной ситуации. Мы проверили какой код возвращается и что пишется в тело ответа.
- Как вы могли догадаться, можно добавить "хорошую" заглушку на базе
*datastore.Project
и протестировать в таком виде, например выполнить кодирование/декодирование в JSON. Таким образом, мы могли бы отловить ситуации, когда внесение изменений может сломать совместимость сencoding/json
.
Что дальше?
Конечно, это не исчерпывающий туториал, но после прочтения этой статьи вы будете знать с чего начинать. Если вы застряли на более сложном и комплексном примере, обратитесь к сообществу или посмотрите исходники пакетов, которые используют пакет httptest via GoDoc.