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

huangapple go评论81阅读模式
英文:

Unit testing for functions that use gorilla/mux URL parameters

问题

TLDR:gorilla/mux以前不提供设置URL变量的可能性。现在它可以,这就是为什么第二个最受赞同的答案长时间以来都是正确答案的原因。

以下是要翻译的内容:

这段代码创建了一个基本的HTTP服务器,监听端口8080,并将路径中的URL参数回显到响应体中。例如,对于http://localhost:8080/test/abcd,它将返回一个包含abcd的响应。

GetRequest()函数的单元测试位于main_test.go中:

测试结果如下:

--- 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)?我在这里找到了一些讨论(链接:https://groups.google.com/d/topic/gorilla-web/TKOE2I0116Y),但提出的解决方案已经不再适用。提出的解决方案是:

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()函数定义在这里

// 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。我编写了一个小的测试应用程序来验证这一点:

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也不起作用。

英文:

TLDR: gorilla/mux used to not offer the possibility to set URL Vars. Now it does, that's why the second-most upvoted answer was the right answer for a long time.

Original question to follow:


Here's what I'm trying to do :

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

This creates a basic http server listening on port 8080 that echoes the URL parameter given in the path. So for http://localhost:8080/test/abcd it will write back a response containing abcd in the response body.

The unit test for the GetRequest() function is in 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())
}

The test result is :

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

The question is how do I fake the mux.Vars(r) for the unit tests?
I've found some discussions here but the proposed solution no longer works. The proposed solution was :

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
}

This solution doesn't work since context.DefaultContext and mux.ContextKey no longer exist.

Another proposed solution would be to alter your code so that the request functions also accept a map[string]string as a third parameter. Other solutions include actually starting a server and building the request and sending it directly to the server. In my opinion this would defeat the purpose of unit testing, turning them essentially into functional tests.

Considering the fact the the linked thread is from 2013. Are there any other options?

EDIT

So I've read the gorilla/mux source code, and according to mux.go the function mux.Vars() is defined here like this :

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

The value of varsKey is defined as iota here. So essentially, the key value is 0. I've written a small test app to check this :
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
}

Which when run, outputs :

Key: mystring Value: abcd
map[]

Which makes me wonder why the test doesn't work and why the direct call to mux.Vars doesn't work.

答案1

得分: 90

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

func TestGetRequest(t *testing.T) {
    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())
}
英文:

gorilla/mux provides the SetURLVars function for testing purposes, which you can use to inject your mock vars.

func TestGetRequest(t *testing.T) {
    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())
}

答案2

得分: 28

问题是,即使你使用0作为值来设置上下文值,它与mux.Vars()读取的值并不相同。mux.Vars()使用的是varsKey(正如你已经看到的),它的类型是contextKey而不是int

当然,contextKey被定义为:

type contextKey int

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

我不知道你如何欺骗gorilla mux或context来返回你的值。


话虽如此,我想到了几种测试这个问题的方法(请注意,下面的代码未经测试,我直接在这里输入的,所以可能有一些愚蠢的错误):

  1. 如有人建议的那样,运行一个服务器并向其发送HTTP请求。

  2. 而不是运行服务器,只需在测试中使用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)
        // 断言
    }
  1. 包装你的处理程序,使其接受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) {
        // 使用vars处理请求
    }

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

你可以根据个人喜好选择其中一种方法。就我个人而言,我可能会选择选项2或3,对选项3稍微偏向一些。

英文:

Trouble is, even when you use 0 as value to set context values, it is not same value that mux.Vars() reads. mux.Vars() is using varsKey (as you already saw) which is of type contextKey and not int.

Sure, contextKey is defined as:

type contextKey int

which means that it has int as underlying object, but type plays part when comparing values in go, so int(0) != contextKey(0).

I do not see how you could trick gorilla mux or context into returning your values.


That being said, couple of ways to test this comes to mind (note that code below is untested, I have typed it directly here, so there might be some stupid errors):

  1. As somebody suggested, run a server and send HTTP requests to it.

  2. Instead of running server, just use gorilla mux Router in your tests. In this scenario, you would have one router that you pass to ListenAndServe, but you could also use that same router instance in tests and call ServeHTTP on it. Router would take care of setting context values and they would be available in your handlers.

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

somewhere in main function you would do something like this:

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

and in your tests you can do:

    func TestGetRequest(t *testing.T) {
        r := http.NewRequest("GET", "employees/1", nil)
        w := httptest.NewRecorder()

        Router().ServeHTTP(w, r)
        // assertions
    }
  1. Wrap your handlers so that they accept URL parameters as third argument and wrapper should call mux.Vars() and pass URL parameters to handler.

With this solution, your handlers would have signature:

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

and you would have to adapt calls to it to conform to http.Handler interface:

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

To register handler you would use:

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

Which one you use is matter of personal preference. Personally, I would probably go with option 2 or 3, with slight preference towards 3.

答案3

得分: 2

在golang中,我对测试有稍微不同的方法。

我稍微改写了你的库代码:

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

我认为这是一个更好的方法 - 你真正测试了你写的代码,因为在go中启动/停止监听器非常容易!

英文:

In golang, I have slightly different approach to testing.

I slightly rewrite your lib code:

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

And here is test for it:

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

I think this is a better approach - you are really testing what you wrote since its very easy to start/stop listeners in go!

答案4

得分: 1

我使用以下辅助函数来从单元测试中调用处理程序:

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

	// 每次调用时添加一个新的子路径,因为我们不能(容易地)删除旧的处理程序
	invokeCount++
	router := mux.NewRouter()
	http.Handle(fmt.Sprintf("/%d", invokeCount), router)

	router.Path(routePath).Handler(handler)

	// 修改请求以在请求URL中添加"/%d"
	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())
}
英文:

I use the following helper function to invoke handlers from unit tests:

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

Because there is no (easy) way to deregister HTTP handlers and multiple calls to http.Handle for the same route will fail. Therefore the function adds a new route (e.g. /1 or /2) to ensure the path is unique. This magic is necessary to use the function in multiple unit test in the same process.

To test your GetRequest-function:

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

答案5

得分: 0

问题是你不能设置变量。

var r *http.Request
var key, value string

// 运行时恐慌,映射未初始化
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}",

        // 也许还可以应用中间件,谁知道呢。
        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

英文:

The issue is you can't set vars.

var r *http.Request
var key, value string

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

The solution is to create a new router on each test.

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

In your handler file.

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

In your tests.

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

You can use *api.Route wherever an http.Handler is needed.

huangapple
  • 本文由 发表于 2015年12月23日 19:57:01
  • 转载请务必保留本文链接:https://go.coder-hub.com/34435185.html
匿名

发表评论

匿名网友

:?: :razz: :sad: :evil: :!: :smile: :oops: :grin: :eek: :shock: :???: :cool: :lol: :mad: :twisted: :roll: :wink: :idea: :arrow: :neutral: :cry: :mrgreen:

确定