February 2, 2022

TIL: Testing Parametrized URLs with Chi Router

Today I learned that it’s possible to slightly decouple your http handler tests from your chi router. Well sort of…

When writing an API you’ll often have a route with URL such as /api/v1/users/{id} where you can get a single user by id. This case is supported by the chi router, but it isn’t clear how to inject this in a test without creating a router instance and setting up a test http server, this is not ideal when you’re trying to isolate tests and focus specifically on the handlers.

Under the hood the chi router works by adding data to the request context using the chi.RouteCtxKey type, so to test our handlers that use path parameters, we need to inject that key into the request context during the test. In this case we’ll inject a user Id of a known user.

Here’s an example

func Test_ChiHandler(t *testing.t) {
    // Create a test recorder
	res := httptest.NewRecorder()

    // Create a test request
	req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/admin/users/%v", targetUser.Id), nil)

    // Create a chi Context object
    chiCtx := chi.NewRouteContext()

	// Create a new test request with the additional Chi contetx
	req := r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, chiCtx))

	// Add the key/value to the context.
	chiCtx.URLParams.Add("id", fmt.Sprintf("%v", targetUser.Id))

    // Now when you issue call your handler it will contain
    // the user ID passed and your handler can pick it up
    // as if the request was made through the chi router.

}

I prefer to contain this behavior in a simple function within a chimocker package that I can import in a variety of handler tests.

package chimocker

import (
	"context"
	"net/http"

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

type Params map[string]string

// WithUrlParam returns a pointer to a request object with the given URL params
// added to a new chi.Context object.
func WithUrlParam(r *http.Request, key, value string) *http.Request {
	chiCtx := chi.NewRouteContext()
	req := r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, chiCtx))
	chiCtx.URLParams.Add(key, value)
	return req
}

// WithUrlParams returns a pointer to a request object with the given URL params
// added to a new chi.Context object. for single param assignment see WithUrlParam
func WithUrlParams(r *http.Request, params Params) *http.Request {
	chiCtx := chi.NewRouteContext()
	req := r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, chiCtx))
	for key, value := range params {
		chiCtx.URLParams.Add(key, value)
	}
	return req
}

Unfortunately, you will still be coupled to the Chi router, however, in most cases I think this is a worthy trade off.