Guides

Testing

Test handlers, routes, and auth with httptest or the built-in test helpers.

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"})
}
HelperUse
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.DELETEThe 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.

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.GenerateToken(&auth.Claims{
        Subject:   "u-1",
        ExpiresAt: time.Now().Add(time.Hour).Unix(),
        Custom:    map[string]any{"roles": roles},
    })
    if err != nil {
        t.Fatal(err)
    }
    return token
}
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

Terminal
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.
Copyright © 2026