Testing¶
This guide covers the patterns used throughout burrow for testing handlers, middleware, migrations, and templates. All examples use testify for assertions and Go's standard net/http/httptest package.
Common Test Imports¶
Most test files use a subset of these imports:
import (
"context"
"html/template"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/go-chi/chi/v5"
"github.com/oliverandrich/burrow"
"github.com/oliverandrich/burrow/burrowtest"
"github.com/oliverandrich/burrow/contrib/auth"
"github.com/oliverandrich/burrow/contrib/session"
"github.com/oliverandrich/den"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
Burrow's test helpers live in the burrowtest sub-package (following the net/http/httptest convention). Importing burrowtest blank-imports the SQLite Den backend, so test binaries don't need to repeat the _ ".../den/backend/sqlite" line — production binaries that don't depend on burrowtest still don't link the SQLite engine.
Framework Test Helpers¶
burrowtest.DB¶
burrowtest.DB returns a file-backed SQLite database wrapped in a *den.DB, created under t.TempDir() and closed automatically when the test finishes:
func TestListNotes(t *testing.T) {
db := burrowtest.DB(t)
require.NoError(t, den.Register(t.Context(), db, &Note{}))
// ... test your handlers/repositories with db
}
Document types are registered explicitly via den.Register after the DB is open — pass the same &Type{} zero-values your app passes from HasDocuments.Documents().
burrowtest.TempDSN¶
burrowtest.TempDSN returns a file-backed SQLite DSN under t.TempDir(), for tests that need the DSN string rather than an open *den.DB — for example, when handing --database-dsn to a cli.Command you're driving from a test:
dsn := burrowtest.TempDSN(t)
// dsn ⇒ "sqlite:///tmp/.../test.db"
require.NoError(t, myCmd.Run(t.Context(), []string{"myapp", "--database-dsn", dsn, "migrate"}))
burrowtest.ErrorExecContext¶
burrowtest.ErrorExecContext injects a minimal TemplateExecutor into the context that renders error templates. You need this whenever your test triggers an error through Handle() or calls RenderError() directly:
func TestNotFound(t *testing.T) {
handler := burrow.Handle(func(w http.ResponseWriter, r *http.Request) error {
return burrow.NewHTTPError(http.StatusNotFound, "note not found")
})
ctx := burrowtest.ErrorExecContext(t.Context())
req := httptest.NewRequestWithContext(ctx, http.MethodGet, "/notes/999", nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
assert.Equal(t, http.StatusNotFound, rec.Code)
assert.Contains(t, rec.Body.String(), "note not found")
}
In production, the template middleware provides the executor automatically. In tests, you provide this lightweight substitute.
burrowtest.ErrorExecMiddleware¶
burrowtest.ErrorExecMiddleware is the middleware version — useful when setting up a chi router for integration tests:
r := chi.NewRouter()
r.Use(burrowtest.ErrorExecMiddleware)
r.Use(auth.RequireAdmin())
r.Get("/admin", adminHandler)
burrowtest.StubApp¶
burrowtest.StubApp(name) returns a minimal burrow.App exposing only the given name. Use it in tests that wire up a registry but don't need the depended-on contrib to do real work — for example, satisfying auth's declared Dependencies() of csrf and staticfiles when only testing handlers:
registry := burrow.NewRegistry()
registry.Add(session.New())
registry.Add(burrowtest.StubApp("csrf"))
registry.Add(burrowtest.StubApp("staticfiles"))
registry.Add(auth.New[auth.EmptyProfile]())
Testing Handlers¶
Burrow handlers return errors (burrow.HandlerFunc). Use httptest.NewRecorder and httptest.NewRequestWithContext to test them:
func TestGreetHandler(t *testing.T) {
handler := burrow.Handle(func(w http.ResponseWriter, r *http.Request) error {
return burrow.Text(w, http.StatusOK, "hello")
})
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
assert.Equal(t, http.StatusOK, rec.Code)
assert.Equal(t, "hello", rec.Body.String())
}
Testing Error Responses¶
Handlers signal errors by returning a *burrow.HTTPError. When wrapped with burrow.Handle(), the error is rendered through RenderError(), which needs a TemplateExecutor in the context. Use burrowtest.ErrorExecContext (see Framework Test Helpers):
func TestNotFound(t *testing.T) {
handler := burrow.Handle(func(w http.ResponseWriter, r *http.Request) error {
return burrow.NewHTTPError(http.StatusNotFound, "note not found")
})
ctx := burrowtest.ErrorExecContext(t.Context())
req := httptest.NewRequestWithContext(ctx, http.MethodGet, "/notes/999", nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
assert.Equal(t, http.StatusNotFound, rec.Code)
assert.Contains(t, rec.Body.String(), "note not found")
}
Form Submissions¶
Set the Content-Type header and pass form data as the request body:
func TestBindForm(t *testing.T) {
body := strings.NewReader("title=My+Note&content=Some+text")
req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/notes", body)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
var form struct {
Title string `form:"title"`
Content string `form:"content"`
}
err := burrow.Bind(req, &form)
require.NoError(t, err)
assert.Equal(t, "My Note", form.Title)
}
JSON Requests¶
For JSON APIs, set the content type and pass a JSON body:
func TestBindJSON(t *testing.T) {
body := strings.NewReader(`{"title":"My Note","content":"Some text"}`)
req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/api/notes", body)
req.Header.Set("Content-Type", "application/json")
var payload struct {
Title string `json:"title"`
Content string `json:"content"`
}
err := burrow.Bind(req, &payload)
require.NoError(t, err)
assert.Equal(t, "My Note", payload.Title)
}
URL Parameters¶
Chi URL parameters require a real chi router to populate the context:
func TestDeleteNote(t *testing.T) {
r := chi.NewRouter()
r.Delete("/notes/{id}", burrow.Handle(handler.Delete))
req := httptest.NewRequestWithContext(t.Context(), http.MethodDelete, "/notes/42", nil)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
assert.Equal(t, http.StatusOK, rec.Code)
}
Testing with Authentication¶
Use auth.WithUser() to inject an authenticated user into the request context:
func requestWithUser(req *http.Request, user *auth.User[auth.EmptyProfile]) *http.Request {
ctx := auth.WithUser(req.Context(), user)
return req.WithContext(ctx)
}
Use session.Inject() when your handler reads or writes session data:
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/notes", nil)
req = requestWithUser(req, &auth.User[auth.EmptyProfile]{ID: "01J000000000000000000042", Role: auth.RoleUser})
req = session.Inject(req, map[string]any{})
session.Inject sets up an in-memory session store — no cookie middleware needed. Pass initial session values via the map:
// Pre-populate session with flash messages, return URL, etc.
req = session.Inject(req, map[string]any{
"return_to": "/dashboard",
})
Testing HTMX Responses¶
Burrow automatically detects HTMX requests via the HX-Request header and skips the layout, returning only the template fragment. Test both paths to verify layout behaviour:
func TestListNotes(t *testing.T) {
db := burrowtest.DB(t)
require.NoError(t, den.Register(t.Context(), db, &Note{}))
repo := NewRepository(db)
require.NoError(t, repo.Create(t.Context(), &Note{Title: "Test", UserID: "01J000000000000000000042"}))
app := &App{repo: repo}
setup := func(t *testing.T) *http.Request {
t.Helper()
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/notes", nil)
req = requestWithUser(req, &auth.User[auth.EmptyProfile]{ID: "01J000000000000000000042"})
req = injectTemplateExecutor(t, req)
return req
}
t.Run("full page includes layout", func(t *testing.T) {
req := setup(t)
ctx := burrow.WithLayout(req.Context(), "test-layout")
req = req.WithContext(ctx)
rec := httptest.NewRecorder()
err := app.List(rec, req)
require.NoError(t, err)
assert.Contains(t, rec.Body.String(), "<html")
})
t.Run("htmx returns fragment only", func(t *testing.T) {
req := setup(t)
req.Header.Set("HX-Request", "true")
ctx := burrow.WithLayout(req.Context(), "test-layout")
req = req.WithContext(ctx)
rec := httptest.NewRecorder()
err := app.List(rec, req)
require.NoError(t, err)
assert.NotContains(t, rec.Body.String(), "<html")
assert.Contains(t, rec.Body.String(), "Test")
})
}
For HTMX responses with out-of-band swaps (e.g., flash messages after creating a record), check for the hx-swap-oob attribute:
Testing Redirects¶
Non-HTMX form submissions typically redirect. Check the status code and Location header:
assert.Equal(t, http.StatusSeeOther, rec.Code)
assert.Equal(t, "/notes", rec.Header().Get("Location"))
Testing Document Registration¶
Den creates collections and indexes from your struct tags. In tests, open a DB with burrowtest.DB and call den.Register with the same zero-value pointers your app passes from HasDocuments.Documents():
func TestDocumentRegistration(t *testing.T) {
db := burrowtest.DB(t)
require.NoError(t, den.Register(t.Context(), db, &Item{}))
// Verify the collection works by inserting a document.
item := &Item{Name: "test"}
err := den.Save(t.Context(), db, item)
require.NoError(t, err)
assert.NotEmpty(t, item.ID)
}
Testing Middleware¶
Use a chi router to test middleware with the full request pipeline:
func TestRequireAuth(t *testing.T) {
t.Run("redirects unauthenticated", func(t *testing.T) {
r := chi.NewRouter()
r.Use(auth.RequireAuth())
r.Get("/protected", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/protected", nil)
req = session.Inject(req, map[string]any{})
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
assert.Equal(t, http.StatusSeeOther, rec.Code)
assert.Equal(t, "/auth/login", rec.Header().Get("Location"))
})
t.Run("allows authenticated", func(t *testing.T) {
r := chi.NewRouter()
// Inject user before the middleware.
r.Use(func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := auth.WithUser(r.Context(), &auth.User[auth.EmptyProfile]{ID: "01J000000000000000000001"})
next.ServeHTTP(w, r.WithContext(ctx))
})
})
r.Use(auth.RequireAuth())
r.Get("/protected", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/protected", nil)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
assert.Equal(t, http.StatusOK, rec.Code)
})
}
Use table-driven tests for middleware that checks roles or permissions. RequireAdmin redirects unauthenticated users to login and renders 403 for authenticated non-admins — so you need burrowtest.ErrorExecContext for the 403 case:
func TestRequireAdmin(t *testing.T) {
tests := []struct {
name string
role string
wantStatus int
}{
{"forbids non-admin", auth.RoleUser, http.StatusForbidden},
{"allows admin", auth.RoleAdmin, http.StatusOK},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := chi.NewRouter()
r.Use(func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := auth.WithUser(r.Context(), &auth.User[auth.EmptyProfile]{ID: "01J000000000000000000001", Role: tt.role})
ctx = burrowtest.ErrorExecContext(ctx)
next.ServeHTTP(w, r.WithContext(ctx))
})
})
r.Use(auth.RequireAdmin())
r.Get("/admin", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/admin", nil)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
assert.Equal(t, tt.wantStatus, rec.Code)
})
}
}
Testing Validation¶
burrow.Validate() returns a *burrow.ValidationError with per-field errors:
func TestValidation(t *testing.T) {
form := struct {
Email string `validate:"required,email"`
Age int `validate:"min=18"`
}{
Email: "",
Age: 15,
}
err := burrow.Validate(form)
require.Error(t, err)
var ve *burrow.ValidationError
require.ErrorAs(t, err, &ve)
assert.Len(t, ve.Errors, 2)
assert.True(t, ve.HasField("Email"))
assert.True(t, ve.HasField("Age"))
}
Integration Tests¶
The examples above test individual pieces in isolation. A complete integration test wires up the chi router with middleware, database, and handler — similar to how the real server processes a request:
func TestCreateNoteIntegration(t *testing.T) {
db := burrowtest.DB(t)
require.NoError(t, den.Register(t.Context(), db, &Note{}))
app := New()
app.repo = NewRepository(db)
r := chi.NewRouter()
r.Use(burrowtest.ErrorExecMiddleware)
r.Use(session.New().Middleware()...)
r.Use(func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := auth.WithUser(r.Context(), &auth.User[auth.EmptyProfile]{ID: "01J000000000000000000001"})
next.ServeHTTP(w, r.WithContext(ctx))
})
})
r.Post("/notes", burrow.Handle(app.Create))
body := strings.NewReader("title=Integration+Test&content=Works")
req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/notes", body)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("HX-Request", "true")
req = session.Inject(req, map[string]any{})
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
assert.Equal(t, http.StatusOK, rec.Code)
assert.Contains(t, rec.Body.String(), "Integration Test")
// Verify the note was persisted.
notes, err := app.repo.ListByUserID(t.Context(), "01J000000000000000000001")
require.NoError(t, err)
assert.Len(t, notes, 1)
}
Testing Templates¶
At runtime, the server parses all app templates into a single global *template.Template and injects a TemplateExecutor function into every request context. In tests, you must build this yourself because there is no running server. You only need this when testing handlers that call burrow.Render and you want to verify the rendered HTML.
Build a TemplateExecutor from your app's template files with stub functions for dependencies from other apps (like csrfToken from CSRF or t from i18n):
func testTemplateExecutor(t *testing.T) burrow.TemplateExecutor {
t.Helper()
// New() is your app's constructor. FuncMap() and TemplateFS() come from
// the HasFuncMap and HasTemplates interfaces — see the Interfaces reference.
app := New()
fm := app.FuncMap()
// Stub request-scoped functions provided by other apps at runtime.
fm["t"] = func(key string) string { return key }
fm["csrfToken"] = func() string { return "test-token" }
fm["staticURL"] = func(name string) string { return "/static/" + name }
tmpl := template.New("").Funcs(fm)
// Parse templates from the app's embedded FS (the templates/ directory).
fsys := app.TemplateFS()
err := fs.WalkDir(fsys, ".", func(path string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() {
return err
}
data, readErr := fs.ReadFile(fsys, path)
if readErr != nil {
return readErr
}
_, parseErr := tmpl.Parse(string(data))
return parseErr
})
require.NoError(t, err)
return func(_ context.Context, name string, data map[string]any) (template.HTML, error) {
var buf strings.Builder
if err := tmpl.ExecuteTemplate(&buf, name, data); err != nil {
return "", err
}
return template.HTML(buf.String()), nil
}
}
Inject it into the request context:
func injectTemplateExecutor(t *testing.T, ctx context.Context) context.Context {
t.Helper()
exec := testTemplateExecutor(t)
return burrow.WithTemplateExecutor(ctx, exec)
}
If your templates reference templates from other apps (e.g., {{ template "app/alerts_oob" . }}), add stubs for those too:
Test Helpers (authtest)¶
The contrib/auth/authtest package provides shared helpers for tests that depend on the auth app — following the convention of net/http/httptest.
Database with Auth Migrations¶
authtest.NewDB returns a file-backed SQLite database with all auth document types already registered. If your test also needs your app's own document types, register them on top:
import "github.com/oliverandrich/burrow/contrib/auth/authtest"
func testDB(t *testing.T) *den.DB {
t.Helper()
db := authtest.NewDB(t)
require.NoError(t, den.Register(t.Context(), db, &Note{}))
return db
}
Creating Test Users¶
authtest.CreateUser inserts a user with sensible defaults (unique auto-incremented username, role "user", active). Use functional options to override:
// Default user — unique username auto-generated.
user := authtest.CreateUser(t, db)
// Fully customised user.
admin := authtest.CreateUser(t, db,
authtest.WithID("01J000000000000000000001"),
authtest.WithUsername("admin"),
authtest.WithEmail("admin@example.com"),
authtest.WithName("Admin"),
authtest.WithRole(auth.RoleAdmin),
)
Available options: WithID, WithUsername, WithEmail, WithName, WithRole, WithActive. WithID takes a string (ULIDs in production; any unique string works in tests).
Testing with Authentication¶
Inject a user into the request context with auth.WithUser:
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/notes", nil)
ctx := auth.WithUser(req.Context(), &auth.User[auth.EmptyProfile]{ID: "01J000000000000000000042", Role: auth.RoleUser})
req = req.WithContext(ctx)
Use session.Inject when your handler reads or writes session data:
Summary¶
| What to test | Key tools |
|---|---|
| Database | burrowtest.DB(t) + den.Register(ctx, db, &Type{}) |
| Custom DSN | burrowtest.TempDSN(t) — file-backed SQLite under t.TempDir() |
| Auth test DB | authtest.NewDB(t) — DB with auth documents pre-registered |
| Test users | authtest.CreateUser(t, db, ...options) |
| Handlers | httptest.NewRecorder, httptest.NewRequestWithContext(t.Context(), ...) |
| Error responses | burrowtest.ErrorExecContext(ctx), burrowtest.ErrorExecMiddleware |
| Stub registered apps | burrowtest.StubApp(name) |
| Auth context | auth.WithUser(ctx, user), session.Inject(req, values) |
| HTMX responses | req.Header.Set("HX-Request", "true"), check hx-swap-oob |
| Middleware | Chi router with r.Use(), table-driven tests |
| Validation | burrow.Validate(), errors.As(err, &ve), ve.HasField() |
| Templates | TemplateExecutor with stubbed functions, burrow.WithTemplateExecutor |