对使用 gorilla/mux URL 参数的函数进行单元测试

Unit testing for functions that use gorilla/mux URL parameters

TLDR:gorilla/mux 过去不提供设置 URL 变量的可能性。现在是这样了,这就是为什么投票数第二高的答案长期以来都是正确答案的原因。

要遵循的原始问题:


这是我正在尝试做的事情:

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)? 我发现了一些讨论 here,但建议的解决方案不再有效。建议的解决方案是:

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.DefaultContextmux.ContextKey 已不存在。

另一个建议的解决方案是更改您的代码,以便请求函数也接受 map[string]string 作为第三个参数。其他解决方案包括实际启动服务器并构建请求并将其直接发送到服务器。在我看来,这会破坏单元测试的目的,将它们本质上变成功能测试。

考虑到链接的线程来自 2013 年。还有其他选择吗?

编辑

所以我读了 gorilla/mux 源代码,根据 mux.go 函数 mux.Vars() 是这样定义的 here :

// 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 here。所以本质上,键值是 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 不起作用。

在 golang 中,我的测试方法略有不同。

我稍微重写了你的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)
}

我认为这是一个更好的方法 - 你真的在测试你写的东西,因为它很容易 start/stop 听众在去!

问题是,即使您使用 0 作为值来设置上下文值,它也不是 mux.Vars() 读取的相同值。 mux.Vars() 正在使用 varsKey(如您所见),它的类型为 contextKey 而不是 int

当然,contextKey 定义为:

type contextKey int

这意味着它有 int 作为底层对象,但是类型在 go 中比较值时起作用,所以 int(0) != contextKey(0)

我看不出您如何欺骗大猩猩多路复用器或上下文来返回您的值。


话虽如此,我想到了几种测试方法(请注意,下面的代码未经测试,我直接在此处输入,因此可能会出现一些愚蠢的错误):

  1. 正如有人建议的那样,运行 服务器并向其发送 HTTP 请求。
  2. 而不是 运行ning 服务器,只需在测试中使用 gorilla mux Router。在这种情况下,您将拥有一个传递给 ListenAndServe 的路由器,但您也可以在测试中使用相同的路由器实例并在其上调用 ServeHTTP。路由器会负责设置上下文值,它们将在您的处理程序中可用。

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

    在 main 函数的某处你会做这样的事情:

    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 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())
}

问题是您无法设置变量。

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)
}

您可以在需要 http.Handler 的地方使用 *api.Route

gorilla/mux 提供了用于测试目的的 SetURLVars 函数,您可以使用它来注入您的 mock vars.

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())
}