Однажды мне дали тестовое задание. Задача состояла в том, чтобы парсить кучу страниц с помощью определенного фреймворка на основе python. Скромно говоря, я справился, меня хорошо оценили с одним огромным замечанием: никаких юнит-тестов. Вот и был в итоге приговор — мне отказали в заявлении. Кем я стал после этого? Профи? Не совсем. Самозванец? Возможно ближе. Независимо от ответа, урок усвоен. Итак, давайте поговорим о модульных тестах для вашего приложения Go 1.11 в Google AppEngine.

В своей предыдущей статье я придумал приветственное приложение, которое почти ничего не делает, просто сохраняет оптимизм и защищено пользовательской проверкой аутентификации. Напомним, что до сих пор у нас был только один обработчик для тестирования:

func (s *Service) WarmupRequestHandler(w http.ResponseWriter, r *http.Request) {
    s.writeResponseData(w, http.StatusOK, &Response{"OK"})
    return
}

и наши маршруты в main.go выглядели следующим образом:

r.HandleFunc("/_ah/warmup", s.WarmupRequestHandler).Methods("GET") r.HandleFunc("/test-admin", s.WarmupRequestHandler).Methods("GET") r.HandleFunc("/test-no-admin",s.WarmupRequestHandler).Methods("GET")
r.HandleFunc("/auth",s.Auth(s.WarmupRequestHandler)).Methods("GET")

Auth – это промежуточное ПО, которое принимает экземпляр функции http.HttpHandler и возвращает его с некоторой логикой. В нашем примере это была проверка JWT (веб-токен JSON). Полный код промежуточного ПО можно найти по ссылке:



Проблема контекста

Google App Engine первого поколения заставляет вас передавать контекст повсюду. Это не было бы проблемой, если бы контекст был стандартным, то есть экземпляром context.Context. Но это не так — последний должен быть экземпляром контекста google.golang.org/appengine. И вам нужен экземпляр запроса для его инициализации. Идеальным решением было бы придумать промежуточное программное обеспечение контекста, где у нас есть экземпляр запроса, и контекст будет инициализирован из него. Тогда мы сможем использовать его везде, где есть спрос.

Лучшим местом для размещения контекста будет сам экземпляр запроса, поскольку последний позволяет вам делать это с контекстом. Чуть позже я покажу вам, как это связано с юнит-тестами, на данный момент мы хотим инициировать контекст из запроса, а затем вызвать любой обработчик:

func (s *Service) ContextMiddleware(next http.HandlerFunc) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        ctx := appengine.NewContext(r)
        ctx, _ = appengine.Namespace(ctx, "")
        
        next.ServeHTTP(w, r.WithContext(ctx))
    }
}

Затем, всякий раз, когда нам может понадобиться контекст, он всегда доступен, вызывая метод r.Context(). Например, в нашем промежуточном программном обеспечении авторизации мы регистрируем, кто вызывает наш API, следующим образом:

log.Infof(r.Context(), "URL requested by: %s", claims.Email)

Теперь, когда у нас есть оболочка контекста, давайте обновим наши маршруты:

r.HandleFunc("/_ah/warmup", s.ContextMiddleware(s.WarmupRequestHandler)).Methods("GET")
r.HandleFunc("/test-admin", s.ContextMiddleware(s.WarmupRequestHandler)).Methods("GET")
r.HandleFunc("/test-no-admin", s.ContextMiddleware(s.WarmupRequestHandler)).Methods("GET")

Как видите, изменилось не так уж много: мы только что обернули все наши обработчики с помощью s.ContextMiddleware. Что касается нашей доморощенной проверки авторизации, это будет выглядеть немного по-другому, но не так сильно: мы по-прежнему хотим сначала инициализировать контекст, затем выполнить проверку авторизации и только потом вызывать наш обработчик.

r.HandleFunc("/auth", s.ContextMiddleware(s.Auth(s.WarmupRequestHandler))).Methods("GET")

Хорошо, теперь все готово для перехода к юнит-тестам.

Тестирование

Google предоставляет нам тестовую библиотеку: google.golang.org/appengine/aetest. В основном он запускает локальный сервер разработки, что позволяет вам иметь доступ к контексту выполнения. Ранее я указывал, что вы должны использовать контекст appengine повсюду. В тестах мы будем иметь дело с этим тестовым контекстом.

Основная идея состоит в том, чтобы подготовить запрос, запустить тестируемое приложение, выполнить запрос, обслужить запрос и затем проверить ответ от приложения. Другими словами, вы можете вызвать сам обработчик, предоставив ему интерфейс записи и сам запрос. С учетом сказанного базовая структура теста будет выглядеть следующим образом:

func TestService(t *testing.T) {
    testContext, done, err := aetest.NewContext()
    if err != nil {
       t.Fatal(err)
    }
    
    testContext, _ = appengine.Namespace(testContext, "")
    defer done()
    t.Run("TEST_NAME", func(t *testing.T) {
        // Do the test with testContext
    })

Для выполнения теста вам в основном нужны четыре вещи: служба, запрос, контекст тестирования и реализация http.ResponseWriter. Последний прекрасно представлен библиотекой net/http/httptest и может быть инициализирован следующим образом:

rr := httptest.NewRecorder()

Часть запроса так же проста:

req, _ := http.NewRequest(http.MethodGet, "/_ah/warmup", &bytes.Buffer{})

Теперь, чтобы выполнить фактический запрос, мы должны вызвать наш обработчик, что в основном означает вызов метода ServeHTTP с нашим контекстом тестирования.

handlerFunc = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    s.WarmupRequestHandler(w, r)
})
handlerFunc.ServeHTTP(rr, req.WithContext(testContext))

Если совсем сложить:

func TestService(t *testing.T) {
    testContext, done, err := aetest.NewContext()
    if err != nil {
       t.Fatal(err)
    }
    
    testContext, _ = appengine.Namespace(testContext, "")
    defer done()
    t.Run("TEST_NAME", func(t *testing.T) {
        // Define the service under test
        s := &Service{}
        // Define the request and response recorder
        req, _ := http.NewRequest(http.MethodGet, "/_ah/warmup", &bytes.Buffer{})
        rr := httptest.NewRecorder()
        // Define test handler and call the actual handler
        handlerFunc = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            s.WarmupRequestHandler(w, r)
        })
        
        handlerFunc.ServeHTTP(rr, req.WithContext(testContext))
        
        // Status code check
        if statusCode := rr.Code; statusCode != test.expectedCode {
            t.Errorf("handler returned wrong status code: got %v want %v", statusCode, test.expectedCode)
        }
        
        // Body check
        var actualResponse Response
        json.NewDecoder(rr.Body).Decode(&actualResponse)
        if actualResponse != Response{"OK"} {
            t.Errorf("handler returned wrong response code: got %v want %v", actualResponse, Response{"OK"})
        }
})

Когда дело доходит до тестирования части аутентификации, просто убедитесь, что вы обернули обработчик так же, как это делается в main.go:

handlerFunc = s.Auth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    s.WarmupRequestHandler(w, r)
}))
handlerFunc.ServeHTTP(rr, req.WithContext(testContext))

и не забудьте добавить заголовок токена в запрос

req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", "TOKEN"))

Полный код тестов можно найти здесь:



Я узнаю больше о модульном тестировании во Части 2. Удачного юнит-тестирования!