John
John

Reputation: 771

Testing Chi Routes w/Path Variables

I'm having trouble testing my go-chi routes, specifically the route with path variables. Running the server with go run main.go works fine and requests to the route with the path variable behaves as expected.

When I run my tests for the routes, I always get the HTTP error: Unprocessable Entity. After logging out what's happening with articleID, it seems like the articleCtx isn't getting access to the path variable. Not sure if this means I need to use articleCtx in the tests, but I've tried ArticleCtx(http.HandlerFunc(GetArticleID)) and get the error:

panic: interface conversion: interface {} is nil, not *chi.Context [recovered] panic: interface conversion: interface {} is nil, not *chi.Context

Running the server: go run main.go

Testing the server: go test .

My source:

// main.go

package main

import (
    "context"
    "fmt"
    "net/http"
    "strconv"

    "github.com/go-chi/chi"
)

type ctxKey struct {
    name string
}

func main() {
    r := chi.NewRouter()

    r.Route("/articles", func(r chi.Router) {
        r.Route("/{articleID}", func(r chi.Router) {
            r.Use(ArticleCtx)
            r.Get("/", GetArticleID) // GET /articles/123
        })
    })

    http.ListenAndServe(":3333", r)
}

// ArticleCtx gives the routes using it access to the requested article ID in the path
func ArticleCtx(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        articleParam := chi.URLParam(r, "articleID")
        articleID, err := strconv.Atoi(articleParam)
        if err != nil {
            http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
            return
        }

        ctx := context.WithValue(r.Context(), ctxKey{"articleID"}, articleID)
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

// GetArticleID returns the article ID that the client requested
func GetArticleID(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()
    articleID, ok := ctx.Value(ctxKey{"articleID"}).(int)
    if !ok {
        http.Error(w, http.StatusText(http.StatusUnprocessableEntity), http.StatusUnprocessableEntity)
        return
    }

    w.Write([]byte(fmt.Sprintf("article ID:%d", articleID)))
}
// main_test.go

package main

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

func TestGetArticleID(t *testing.T) {
    tests := []struct {
        name           string
        rec            *httptest.ResponseRecorder
        req            *http.Request
        expectedBody   string
        expectedHeader string
    }{
        {
            name:         "OK_1",
            rec:          httptest.NewRecorder(),
            req:          httptest.NewRequest("GET", "/articles/1", nil),
            expectedBody: `article ID:1`,
        },
        {
            name:         "OK_100",
            rec:          httptest.NewRecorder(),
            req:          httptest.NewRequest("GET", "/articles/100", nil),
            expectedBody: `article ID:100`,
        },
        {
            name:         "BAD_REQUEST",
            rec:          httptest.NewRecorder(),
            req:          httptest.NewRequest("PUT", "/articles/bad", nil),
            expectedBody: fmt.Sprintf("%s\n", http.StatusText(http.StatusBadRequest)),
        },
    }

    for _, test := range tests {
        t.Run(test.name, func(t *testing.T) {
            ArticleCtx(http.HandlerFunc(GetArticleID)).ServeHTTP(test.rec, test.req)

            if test.expectedBody != test.rec.Body.String() {
                t.Errorf("Got: \t\t%s\n\tExpected: \t%s\n", test.rec.Body.String(), test.expectedBody)
            }
        })
    }
}

Not sure how to continue with this. Any ideas? I was wondering if there was an answer in net/http/httptest about using context with tests but didn't see anything.

Also pretty new go Go (and the context package), so any code review / best practice comments are greatly appreciated :)

Upvotes: 8

Views: 16338

Answers (5)

codewarrior
codewarrior

Reputation: 981

I am facing the same problem, however I don't like the accepted answer that manually inject RouteContext. When writing unit test, I want to try my best to make sure that the behavior of being tested code is as same as in real environment and of course in real life the RouteContext is not injected by us. Actually if you take a look at the chi source code, you will notice that the RouteContext was injected by Mux.ServerHTTP (https://github.com/go-chi/chi/blob/master/mux.go#L87) So, to let chi generate the RouteContext for you, you need to implement your test code in this way, so the unit test is testing the same code path that will be executed in real life:


func TestGetArticleID(t *testing.T) {
    tests := []struct {
        name           string
        reqPath        string
        expectedBody   string
    }{
        {
            name:         "OK_1",
            reqPath:      "/articles/1",
            expectedBody: `article ID:1`,
        },
        {
            name:         "OK_100",
            reqPath:      "/articles/100",
            expectedBody: `article ID:100`,
        },
        {
            name:         "BAD_REQUEST",
            reqPath:      "/articles/bad",
            expectedBody: fmt.Sprintf("%s\n", http.StatusText(http.StatusBadRequest)),
        },
    }

    testServer := httptest.NewServer(BuildRouter())
    defer testServer.Close()

    for _, test := range tests {
        t.Run(test.name, func(t *testing.T) {
            request, err := http.NewRequest(http.MethodGet, testServer.URL+test.reqPath, nil)
            if err != nil {
                t.Fatal(err)
            }

            response, err := http.DefaultClient.Do(request)
            if err != nil {
                t.Fatal(err)
            }

            respBody, err := ioutil.ReadAll(response.Body)
            if err != nil {
                t.Fatal(err)
            }
            defer response.Body.Close()
            assert.Equal(t, test.expectedBody, string(respBody))
        })
    }
}


The function BuildRouter() is from main.go:


type ctxKey struct {
    name string
}

func main() {
    http.ListenAndServe(":3333", BuildRouter())
}

// ArticleCtx gives the routes using it access to the requested article ID in the path
func ArticleCtx(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        articleParam := chi.URLParam(r, "articleID")
        articleID, err := strconv.Atoi(articleParam)
        if err != nil {
            http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
            return
        }

        ctx := context.WithValue(r.Context(), ctxKey{"articleID"}, articleID)
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

// GetArticleID returns the article ID that the client requested
func GetArticleID(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()
    articleID, ok := ctx.Value(ctxKey{"articleID"}).(int)
    if !ok {
        http.Error(w, http.StatusText(http.StatusUnprocessableEntity), http.StatusUnprocessableEntity)
        return
    }

    w.Write([]byte(fmt.Sprintf("article ID:%d", articleID)))
}

func BuildRouter() chi.Router {
    r := chi.NewRouter()

    r.Route("/articles", func(r chi.Router) {
        r.Route("/{articleID}", func(r chi.Router) {
            r.Use(ArticleCtx)
            r.Get("/", GetArticleID) // GET /articles/123
        })
    })

    return r
}

Upvotes: 1

Alex Chebotarsky
Alex Chebotarsky

Reputation: 513

Probably late to the party now :) But maybe somebody else would find it useful.

I had the same issue and wanted to keep my tests structured in a slice, so I ended up creating a "helper" function in order to add required chi context to the request:

func AddChiURLParams(r *http.Request, params map[string]string) *http.Request {
    ctx := chi.NewRouteContext()
    for k, v := range params {
        ctx.URLParams.Add(k, v)
    }

    return r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, ctx))
}

Again, as @Ullauri mentioned, all credit for the solution goes to soedar.

But with this function you will be able to nicely use it in the slice like so:

{
    name: "OK_100",
    rec:  httptest.NewRecorder(),
    req: AddChiURLParams(httptest.NewRequest("GET", "/articles/100", nil), map[string]string{
        "id": "100",
    }),
    expectedBody: `article ID:100`,
},

Hope this helps! :)

Upvotes: 2

Cabrera
Cabrera

Reputation: 1960

Had a similar issue, although I was unit testing a handler directly. Basically, it seems like the url parameters are not auto added to the request context when using httptest.NewRequest forcing you to manually add them.

Something like the following worked for me.

w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/{key}", nil)

rctx := chi.NewRouteContext()
rctx.URLParams.Add("key", "value")

r = r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, rctx))

handler := func(w http.ResponseWriter, r *http.Request) {
    value := chi.URLParam(r, "key")
}
handler(w, r)

All credit to soedar here =)

Upvotes: 18

In main you're instructing the usage of ArticleCtx after defining the path /articles, but in your test you're just using ArticleCtx directly.

Your test requests should not contain /articles, for example:

httptest.NewRequest("GET", "/1", nil)

Upvotes: 0

Gaurav Verma
Gaurav Verma

Reputation: 655

I had the same problem with named path variables. I was able to resolve it setting up router for my tests. A good sample is shown in go-chi tests.

Go Chi Sample test with URL params

Upvotes: 3

Related Questions