Testing
An *ezz.App is an http.Handler, so everything in net/http/httptest works without adapters. ezz adds a few helpers for the repetitive parts.
Test a route with httptest
This is the approach with the fewest surprises: build the real app, send a real request, assert on the recorder.
func TestGetUser(t *testing.T) {
app := buildApp(testStore(t)) // the same constructor main() uses
req := httptest.NewRequest("GET", "/api/v1/users/42", nil)
req.Header.Set("Authorization", "Bearer "+testToken(t, "member"))
rec := httptest.NewRecorder()
app.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("got %d, body: %s", rec.Code, rec.Body.String())
}
var user User
if err := json.Unmarshal(rec.Body.Bytes(), &user); err != nil {
t.Fatal(err)
}
if user.ID != "42" {
t.Fatalf("got %q", user.ID)
}
}
Build the app through a shared function so tests exercise the real middleware, auth rules, and route table rather than a simplified copy.
Use the test helpers
ezz.NewTestApp() wraps an app with request helpers and fluent assertions.
func TestHealth(t *testing.T) {
ta := ezz.NewTestApp()
ta.App().GET("/health", ezz.HFunc(func(ctx *ezz.Context) (map[string]string, error) {
return map[string]string{"status": "ok"}, nil
})).Public()
ta.GET("/health").
AssertStatus(t, 200).
AssertJSON(t, map[string]string{"status": "ok"})
}
| Helper | Use |
|---|---|
ta.App() | The underlying *ezz.App, for registering routes and providers |
ta.GET(path, headers...) | Send a GET |
ta.POST(path, body, headers...) | Send a POST; a struct body is marshalled as JSON |
ta.PUT, ta.PATCH, ta.DELETE | The same for other methods |
ta.Request(method, path, body, headers...) | Anything else |
A *TestResponse gives you StatusCode(), Header(), BodyString(), JSON(&v), and the raw Recorder, plus assertions:
resp := ta.POST("/users", CreateUserRequest{Name: "Ada", Email: "ada@example.com"})
resp.AssertStatus(t, 201).
AssertHeader(t, "Location", "/users/1").
AssertBodyContains(t, "Ada")
var created User
if err := resp.JSON(&created); err != nil {
t.Fatal(err)
}
Header helpers keep call sites short:
ta.GET("/me", ezz.WithJWT(token))
ta.POST("/upload", body, ezz.WithContentType("application/octet-stream"))
ta.GET("/items", ezz.WithHeaders(map[string]string{"X-Tenant": "acme"}))
NewTestApp builds a plain app with no auth provider, so route rules are not enforced until you call ta.App().Auth(...). Register middleware and the provider before registering routes, since chains are compiled at registration.A handler that reads ctx.MustUser() still answers 401 on a request carrying no user, whether or not a provider is configured, so asserting that an endpoint requires a token needs no setup:
ta := ezz.NewTestApp()
ta.App().GET("/me", ezz.HFunc(handleMe))
ta.GET("/me").AssertStatus(t, 401)
Configure a real provider and omit the header when the rule itself is what you want under test, rather than the handler's read of the user.
Test a handler function directly
Handlers are ordinary functions, so the fastest test skips HTTP entirely:
func TestGetUserNotFound(t *testing.T) {
h := getUser(emptyStore{})
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/users/404", nil)
ctx := ezz.NewTestContext(w, r)
_, err := h(ctx, GetUserRequest{ID: "404"})
var httpErr *ezz.HTTPError
if !errors.As(err, &httpErr) || httpErr.StatusCode != 404 {
t.Fatalf("expected a 404 HTTPError, got %v", err)
}
}
ezz.NewTestContext(w, r) builds a real context around a recorder, so ctx.SetHeader, ctx.JSON, ctx.Param, and ctx.User() behave as they do in production. Set a user with ctx.SetUser(...) and path values with r.SetPathValue(...).
This form skips binding and validation, since you pass the request struct yourself. Cover those with a request-level test.
Test binding and validation
func TestListUsersDefaults(t *testing.T) {
var got ListUsersRequest
ta := ezz.NewTestApp()
ta.App().GET("/users", ezz.H(func(ctx *ezz.Context, req ListUsersRequest) (any, error) {
got = req
return map[string]bool{"ok": true}, nil
})).Public()
ta.GET("/users").AssertStatus(t, 200)
if got.Page != 1 || got.Limit != 25 {
t.Fatalf("defaults not applied: %+v", got)
}
}
func TestListUsersRejectsLimit(t *testing.T) {
// ... same setup
ta.GET("/users?limit=500").
AssertStatus(t, 400).
AssertBodyContains(t, "Limit")
}
Test authenticated routes
Mint a token with the same provider the app uses:
func testToken(t *testing.T, roles ...string) string {
t.Helper()
jwt := auth.NewHMACProvider("test-secret", "test")
token, err := jwt.Generate("u-1", roles...)
if err != nil {
t.Fatal(err)
}
return token
}
Generate(subject, roles...) mints a token that is valid for an hour and carries the roles in
the shape both the route rules and the role resolver read. Build auth.Claims yourself and
call GenerateToken when you need a different expiry, an audience, or custom claims.
func TestAdminRouteForbidsMembers(t *testing.T) {
app := buildApp(testStore(t))
req := httptest.NewRequest("DELETE", "/api/v1/admin/users/42", nil)
req.Header.Set("Authorization", "Bearer "+testToken(t, "member"))
rec := httptest.NewRecorder()
app.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("got %d, want 403", rec.Code)
}
}
For a case where the token itself is irrelevant, a stub provider is simpler:
type stubProvider struct{ user any }
func (s stubProvider) Authenticate(*http.Request) (any, error) {
if s.user == nil {
return nil, errors.New("no user")
}
return s.user, nil
}
type stubUser struct{ roles []string }
func (u stubUser) ID() string { return "u-1" }
func (u stubUser) GetClaims() *auth.Claims { return &auth.Claims{Subject: "u-1"} }
func (u stubUser) GetAttribute(string) (any, bool) { return nil, false }
func (u stubUser) HasRole(role string) bool { return slices.Contains(u.roles, role) }
func (u stubUser) HasPermission(perm string) bool { return false }
app := ezz.New().Auth(stubProvider{user: stubUser{roles: []string{"admin"}}})
Test a resolver
Resolvers do not need HTTP at all:
func TestResolverExpandsHierarchy(t *testing.T) {
r := resolver{db: testDB(t)} // user u-1 has super_admin
authz, err := r.Resolve(context.Background(), auth.NewBasicUser(&auth.Claims{Subject: "u-1"}))
if err != nil {
t.Fatal(err)
}
if !slices.Contains(authz.Roles, "admin") {
t.Fatalf("super_admin should imply admin, got %v", authz.Roles)
}
}
To check the caching wrapper, count calls to the inner resolver:
type countingResolver struct{ n int }
func (c *countingResolver) Resolve(context.Context, auth.User) (auth.Authorization, error) {
c.n++
return auth.Authorization{Roles: []string{"admin"}}, nil
}
func TestCachedResolverCaches(t *testing.T) {
inner := &countingResolver{}
cached := auth.Cached(inner, time.Minute)
user := auth.NewBasicUser(&auth.Claims{Subject: "u-1"})
_, _ = cached.Resolve(context.Background(), user)
_, _ = cached.Resolve(context.Background(), user)
if inner.n != 1 {
t.Fatalf("inner resolver called %d times, want 1", inner.n)
}
cached.Invalidate("u-1")
_, _ = cached.Resolve(context.Background(), user)
if inner.n != 2 {
t.Fatalf("invalidation did not take effect")
}
}
Test the GoTrue proxy without GoTrue
Point InternalURL at an httptest.Server that returns whatever you want, including the golden fixtures in auth/testdata/gotrue:
func TestSignupForwards(t *testing.T) {
var gotBody []byte
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotBody, _ = io.ReadAll(r.Body)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"access_token":"tok","token_type":"bearer"}`))
}))
defer upstream.Close()
gt := auth.NewGoTrue(auth.GoTrueConfig{
InternalURL: upstream.URL,
JWTSecret: "test-secret",
})
app := ezz.New()
gt.Mount(app, "/auth")
rec := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/auth/signup",
strings.NewReader(`{"email":"ada@example.com","password":"password123"}`))
req.Header.Set("Content-Type", "application/json")
app.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("got %d: %s", rec.Code, rec.Body.String())
}
if !strings.Contains(string(gotBody), "ada@example.com") {
t.Fatalf("body not forwarded: %s", gotBody)
}
}
Run the suite
go test ./... # everything
go test -race ./... # with the race detector
go test -cover ./... # with coverage
go test -bench=. -benchmem ./...
The repository's Makefile wraps these as make test, make test-race, make test-coverage, and make bench.
Next steps
- Performance for benchmarking and the pooling rules that affect tests.
- Auth rules for the behaviour these tests assert.