Модульное тестирование функций, использующих параметры URL-адресов gorilla/mux

Вот что я пытаюсь сделать:

main.go

package main

import (
    "fmt"
    "net/http"

    "github.com/gorilla/mux"
)

func main() {
    mainRouter := mux.NewRouter().StrictSlash(true)
    mainRouter.HandleFunc("/test/{mystring}", GetRequest).Name("/test/{mystring}").Methods("GET")
    http.Handle("/", mainRouter)

    err := http.ListenAndServe(":8080", mainRouter)
    if err != nil {
        fmt.Println("Something is wrong : " + err.Error())
    }
}

func GetRequest(w http.ResponseWriter, r *http.Request) {
    vars := mux.Vars(r)
    myString := vars["mystring"]

    w.WriteHeader(http.StatusOK)
    w.Header().Set("Content-Type", "text/plain")
    w.Write([]byte(myString))
}

Это создает базовый http-сервер, прослушивающий порт 8080 это повторяет параметр URL, указанный в пути. Таким образом, для http://localhost:8080/test/abcd он напишет ответ, содержащий abcd в теле ответа.

Модульный тест для GetRequest() функция находится в main_test.go:

package main

import (
    "net/http"
    "net/http/httptest"
    "testing"

    "github.com/gorilla/context"
    "github.com/stretchr/testify/assert"
)

func TestGetRequest(t *testing.T) {
    t.Parallel()

    r, _ := http.NewRequest("GET", "/test/abcd", nil)
    w := httptest.NewRecorder()

    //Hack to try to fake gorilla/mux vars
    vars := map[string]string{
        "mystring": "abcd",
    }
    context.Set(r, 0, vars)

    GetRequest(w, r)

    assert.Equal(t, http.StatusOK, w.Code)
    assert.Equal(t, []byte("abcd"), w.Body.Bytes())
}

Результат теста:

--- FAIL: TestGetRequest (0.00s)
    assertions.go:203: 

    Error Trace:    main_test.go:27

    Error:      Not equal: []byte{0x61, 0x62, 0x63, 0x64} (expected)
                    != []byte(nil) (actual)

            Diff:
            --- Expected
            +++ Actual
            @@ -1,4 +1,2 @@
            -([]uint8) (len=4 cap=8) {
            - 00000000  61 62 63 64                                       |abcd|
            -}
            +([]uint8) <nil>


FAIL
FAIL    command-line-arguments  0.045s

Вопрос в том, как мне подделать mux.Vars(r) для юнит-тестов? Я нашел некоторые обсуждения здесь, но предложенное решение больше не работает. Предложенное решение было:

func buildRequest(method string, url string, doctype uint32, docid uint32) *http.Request {
    req, _ := http.NewRequest(method, url, nil)
    req.ParseForm()
    var vars = map[string]string{
        "doctype": strconv.FormatUint(uint64(doctype), 10),
        "docid":   strconv.FormatUint(uint64(docid), 10),
    }
    context.DefaultContext.Set(req, mux.ContextKey(0), vars) // mux.ContextKey exported
    return req
}

Это решение не работает с context.DefaultContext а также mux.ContextKey Больше не существует.

Другим предлагаемым решением было бы изменить ваш код так, чтобы функции запроса также принимали map[string]string в качестве третьего параметра. Другие решения включают фактический запуск сервера и создание запроса и его отправку непосредственно на сервер. На мой взгляд, это побьет цель модульного тестирования, превратив их по существу в функциональные тесты.

Принимая во внимание тот факт, что ссылка связана с 2013 года. Есть ли другие варианты?

РЕДАКТИРОВАТЬ

Итак, я прочитал gorilla/mux исходный код, и в соответствии с mux.go функция mux.Vars() определяется здесь следующим образом:

// Vars returns the route variables for the current request, if any.
func Vars(r *http.Request) map[string]string {
    if rv := context.Get(r, varsKey); rv != nil {
        return rv.(map[string]string)
    }
    return nil
}

Значение varsKey определяется как iota здесь По сути, значение ключа 0, Я написал небольшое тестовое приложение, чтобы проверить это:main.go

package main

import (
    "fmt"
    "net/http"

    "github.com/gorilla/mux"
    "github.com/gorilla/context"
)

func main() {
    r, _ := http.NewRequest("GET", "/test/abcd", nil)
    vars := map[string]string{
        "mystring": "abcd",
    }
    context.Set(r, 0, vars)
    what := Vars(r)

    for key, value := range what {
        fmt.Println("Key:", key, "Value:", value)
    }

    what2 := mux.Vars(r)
    fmt.Println(what2)

    for key, value := range what2 {
        fmt.Println("Key:", key, "Value:", value)
    }

}

func Vars(r *http.Request) map[string]string {
    if rv := context.Get(r, 0); rv != nil {
        return rv.(map[string]string)
    }
    return nil
}

Который при запуске выдает:

Key: mystring Value: abcd
map[]

Что заставляет меня задуматься, почему тест не работает и почему прямой вызов mux.Vars не работает

6 ответов

Решение

Беда, даже когда вы используете 0 в качестве значения для установки значений контекста, это не то же значение, что mux.Vars() читает. mux.Vars() использует varsKey (как вы уже видели), который имеет тип contextKey и не int,

Конечно, contextKey определяется как:

type contextKey int

это означает, что он имеет int как базовый объект, но type играет роль при сравнении значений в go, поэтому int(0) != contextKey(0),

Я не понимаю, как вы могли бы обмануть гориллу или контекст в возвращении ваших значений.


При этом приходит на ум несколько способов проверить это (обратите внимание, что приведенный ниже код не проверен, я набрал его прямо здесь, поэтому могут быть некоторые глупые ошибки):

  1. Как кто-то предложил, запустите сервер и отправьте на него HTTP-запросы.
  2. Вместо запуска сервера просто используйте gorilla mux Router в своих тестах. В этом случае у вас будет один маршрутизатор, который вы передаете ListenAndServe, но вы также можете использовать тот же экземпляр маршрутизатора в тестах и ​​вызове ServeHTTP в теме. Маршрутизатор позаботится об установке значений контекста, и они будут доступны в ваших обработчиках.

    func Router() *mux.Router {
        r := mux.Router()
        r.HandleFunc("/employees/{1}", GetRequest)
        (...)
        return r 
    }
    

    где-то в основной функции вы бы сделали что-то вроде этого:

    http.Handle("/", Router())
    

    и в ваших тестах вы можете сделать:

    func TestGetRequest(t *testing.T) {
        r := http.NewRequest("GET", "employees/1", nil)
        w := httptest.NewRecorder()
    
        Router().ServeHTTP(w, r)
        // assertions
    }
    
  3. Оберните ваши обработчики так, чтобы они принимали параметры URL в качестве третьего аргумента и обертка должна вызывать mux.Vars() и передать параметры URL обработчику.

    При таком решении ваши обработчики будут иметь подпись:

    type VarsHandler func (w http.ResponseWriter, r *http.Request, vars map[string]string)
    

    и вам придется адаптировать вызовы к нему, чтобы соответствовать http.Handler интерфейс:

    func (vh VarsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
        vars := mux.Vars(r)
        vh(w, r, vars)
    }
    

    Для регистрации обработчика вы должны использовать:

    func GetRequest(w http.ResponseWriter, r *http.Request, vars map[string]string) {
        // process request using vars
    }
    
    mainRouter := mux.NewRouter().StrictSlash(true)
    mainRouter.HandleFunc("/test/{mystring}", VarsHandler(GetRequest)).Name("/test/{mystring}").Methods("GET")
    

Какой из них вы используете, это вопрос личных предпочтений. Лично я бы предпочел вариант 2 или 3 с небольшим предпочтением 3.

Вам нужно изменить свой тест на:

func TestGetRequest(t *testing.T) {
    t.Parallel()

    r, _ := http.NewRequest("GET", "/test/abcd", nil)
    w := httptest.NewRecorder()

    //Hack to try to fake gorilla/mux vars
    vars := map[string]string{
        "mystring": "abcd",
    }

    // CHANGE THIS LINE!!!
    r = mux.SetURLVars(r, vars)

    GetRequest(w, r)

    assert.Equal(t, http.StatusOK, w.Code)
    assert.Equal(t, []byte("abcd"), w.Body.Bytes())
}

Я использую следующую вспомогательную функцию для вызова обработчиков из модульных тестов:

func InvokeHandler(handler http.Handler, routePath string,
    w http.ResponseWriter, r *http.Request) {

    // Add a new sub-path for each invocation since
    // we cannot (easily) remove old handler
    invokeCount++
    router := mux.NewRouter()
    http.Handle(fmt.Sprintf("/%d", invokeCount), router)

    router.Path(routePath).Handler(handler)

    // Modify the request to add "/%d" to the request-URL
    r.URL.RawPath = fmt.Sprintf("/%d%s", invokeCount, r.URL.RawPath)
    router.ServeHTTP(w, r)
}

Потому что нет (простого) способа отменить регистрацию обработчиков HTTP и нескольких вызовов http.Handle по тому же маршруту не получится. Поэтому функция добавляет новый маршрут (например, /1 или же /2) чтобы путь был уникальным. Эта магия необходима для использования функции в нескольких модульных тестах в одном и том же процессе.

Чтобы проверить ваш GetRequest-функции:

func TestGetRequest(t *testing.T) {
    t.Parallel()

    r, _ := http.NewRequest("GET", "/test/abcd", nil)
    w := httptest.NewRecorder()

    InvokeHandler(http.HandlerFunc(GetRequest), "/test/{mystring}", w, r)

    assert.Equal(t, http.StatusOK, w.Code)
    assert.Equal(t, []byte("abcd"), w.Body.Bytes())
}

В Голанге у меня немного другой подход к тестированию.

Я немного переписал твой код lib:

package main

import (
        "fmt"
        "net/http"

        "github.com/gorilla/mux"
)

func main() {
        startServer()
}

func startServer() {
        mainRouter := mux.NewRouter().StrictSlash(true)
        mainRouter.HandleFunc("/test/{mystring}", GetRequest).Name("/test/{mystring}").Methods("GET")
        http.Handle("/", mainRouter)

        err := http.ListenAndServe(":8080", mainRouter)
        if err != nil {
                fmt.Println("Something is wrong : " + err.Error())
        }
}

func GetRequest(w http.ResponseWriter, r *http.Request) {
        vars := mux.Vars(r)
        myString := vars["mystring"]

        w.WriteHeader(http.StatusOK)
        w.Header().Set("Content-Type", "text/plain")
        w.Write([]byte(myString))
}

И вот тест для него:

package main

import (
        "io/ioutil"
        "net/http"
        "testing"
        "time"

        "github.com/stretchr/testify/assert"
)

func TestGetRequest(t *testing.T) {
        go startServer()
        client := &http.Client{
                Timeout: 1 * time.Second,
        }

        r, _ := http.NewRequest("GET", "http://localhost:8080/test/abcd", nil)

        resp, err := client.Do(r)
        if err != nil {
                panic(err)
        }
        assert.Equal(t, http.StatusOK, resp.StatusCode)
        body, err := ioutil.ReadAll(resp.Body)
        if err != nil {
                panic(err)
        }
        assert.Equal(t, []byte("abcd"), body)
}

Я думаю, что это лучший подход - вы действительно тестируете то, что написали, поскольку очень легко запускать / останавливать слушателей на ходу!

Проблема в том, что вы не можете установить переменные.

var r *http.Request
var key, value string

// runtime panic, map not initialized
mux.Vars(r)[key] = value

Решение заключается в создании нового маршрутизатора на каждом тесте.

// api/route.go

package api

import (
    "net/http"
    "github.com/gorilla/mux"
)

type Route struct {
    http.Handler
    Method string
    Path string
}

func (route *Route) Test(w http.ResponseWriter, r *http.Request) {
    m := mux.NewRouter()
    m.Handle(route.Path, route).Methods(route.Method)
    m.ServeHTTP(w, r)
}

В вашем файле обработчика.

// api/employees/show.go

package employees

import (
    "github.com/gorilla/mux"
)

func Show(db *sql.DB) *api.Route {
    h := func(w http.ResponseWriter, r http.Request) {
        username := mux.Vars(r)["username"]
        // .. etc ..
    }
    return &api.Route{
        Method: "GET",
        Path: "/employees/{username}",

        // Maybe apply middleware too, who knows.
        Handler: http.HandlerFunc(h),
    }
}

В ваших тестах.

// api/employees/show_test.go

package employees

import (
    "testing"
)

func TestShow(t *testing.T) {
    w := httptest.NewRecorder()
    r, err := http.NewRequest("GET", "/employees/ajcodez", nil)
    Show(db).Test(w, r)
}

Ты можешь использовать *api.Route где бы http.Handler нужно.

Поскольку context.setVar не является общедоступным из Gorilla Mux, и они не исправили эту проблему более 2 лет, я решил, что просто сделаю обходной путь для моего сервера, который получает переменную из заголовка вместо контекста, если переменная пуста. Поскольку переменная никогда не должна быть пустой, это не меняет функциональность моего сервера.

Создать функцию для получения mux.Vars

func getVar(r *http.Request, key string) string {
    v := mux.Vars(r)[key]
    if len(v) > 0 {
        return v
    }
    return r.Header.Get("X-UNIT-TEST-VAR-" + key)
}

Тогда вместо

vars := mux.Vars(r)
myString := vars["mystring"]

Просто позвони

myString := getVar("mystring")

Что означает, что в ваших юнит-тестах вы можете добавить функцию

func setVar(r *http.Request, key string, value string) {
    r.Header.Set("X-UNIT-TEST-VAR-"+key, value)
}

Тогда сделайте ваш запрос

r, _ := http.NewRequest("GET", "/test/abcd", nil)
w := httptest.NewRecorder()
setVar(r, "mystring", "abcd")
Другие вопросы по тегам