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