Skip to content

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:

assert.Contains(t, rec.Body.String(), `hx-swap-oob="true"`)

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:

_, err = tmpl.Parse(`{{ define "app/alerts_oob" }}{{ end }}`)
require.NoError(t, err)

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:

req = session.Inject(req, map[string]any{})

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