- Docs
- Operate
- Test your app
Test your app
Test a Zever app with in-memory adapters, httptest routes, per-test sqlite files, conformance kits, and leak checks.
Zever apps test without Docker, network, or shared state. config.Default() picks zero-infra adapters (memory, sqlite, local, log, noop), the container resolves them lazily, and every test below runs offline and deterministic. Patterns here mirror the runnable samples in docs/examples/testing_test.go and the todo example’s internal/api/api_test.go.
Start from zevertest
Section titled “Start from zevertest”github.com/zenta-dev/zever/zevertest bundles the setup below into three small helpers. NewTestContainer registers the zero-infrastructure adapters, builds a container from config.Default(), gives it a per-test sqlite file and storage directory under t.TempDir(), silences logging and observability, sets a test JWT secret, and closes it in t.Cleanup. Clock and IDs make time and identifiers deterministic, and Client drives any http.Handler in-process.
import ( "net/http" "testing"
"github.com/zenta-dev/zever/zevertest")
func TestCreateThing(t *testing.T) { c := zevertest.NewTestContainer(t)
r, err := c.Router() if err != nil { t.Fatalf("Router: %v", err) } ids := zevertest.NewIDs("thing") // thing-000001, thing-000002, ... r.Handle("POST", "/things", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusCreated) _, _ = w.Write([]byte(`{"id":"` + ids.Next() + `"}`)) })
api := zevertest.NewClient(t, r).WithBearer("token")
var out struct{ ID string } api.Post("/things", map[string]string{"name": "x"}). RequireStatus(http.StatusCreated). JSON(&out) if out.ID != "thing-000001" { t.Fatalf("id = %q", out.ID) }}Use zevertest.NewClock(start) where code takes a func() time.Time (Clock.Func()), then Advance instead of sleeping. WithConfig(func(*config.Config)) tweaks the config and WithRegister(func()) registers extra adapters; the rest of this page shows what the helpers do under the hood.
Open a container per test
Section titled “Open a container per test”Register the adapters under test, open a container from config.Default(), and close it in t.Cleanup. Closing only shuts down services the test actually resolved, in dependency order.
import ( "testing"
cachememory "github.com/zenta-dev/zever/adapters/cache/memory" "github.com/zenta-dev/zever/config" "github.com/zenta-dev/zever/container")
func TestCacheRoundTrip(t *testing.T) { cachememory.Register()
c := container.New(config.Default()) t.Cleanup(func() { _ = c.Close(t.Context()) })
ch, err := c.Cache() if err != nil { t.Fatalf("Cache: %v", err) } ctx := t.Context() if err := ch.Set(ctx, "k", []byte("v"), 0); err != nil { t.Fatalf("Set: %v", err) } got, err := ch.Get(ctx, "k") if err != nil { t.Fatalf("Get: %v", err) } if string(got) != "v" { t.Fatalf("Get = %q, want %q", got, "v") }}Hit routes with httptest
Section titled “Hit routes with httptest”Build the router the same way production does, register routes, and serve requests in-process. No port, no network.
import ( "fmt" "io" "net/http" "net/http/httptest" "testing"
slogadapter "github.com/zenta-dev/zever/adapters/log/slog" routerstdhttp "github.com/zenta-dev/zever/adapters/router/stdhttp" "github.com/zenta-dev/zever/core/log" "github.com/zenta-dev/zever/core/router")
func TestGetUser(t *testing.T) { logger := slogadapter.NewWithWriter(log.Options{}, io.Discard) r, err := routerstdhttp.New(router.Options{AppName: "test", Logger: logger}) if err != nil { t.Fatalf("New: %v", err) }
r.Handle("GET", "/users/{id}", func(w http.ResponseWriter, req *http.Request) { fmt.Fprintf(w, "user %s", router.Param(req, "id")) })
rec := httptest.NewRecorder() r.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/users/42", nil)) if rec.Body.String() != "user 42" { t.Fatalf("body = %q, want %q", rec.Body.String(), "user 42") }}Send authenticated requests with httptest.NewRequestWithContext(t.Context(), ...) and set the Authorization header, exactly like the todo example’s do helper does.
Give each test its own sqlite file
Section titled “Give each test its own sqlite file”:memory: sqlite is per-connection unless the DSN opts into shared cache, so neither behavior is safe to rely on across tests. Point DB.Options.Path at a file under t.TempDir() so parallel runs, -shuffle=on, and -count=N stay isolated.
import ( "path/filepath" "testing"
dbsqlite "github.com/zenta-dev/zever/adapters/db/sqlite" "github.com/zenta-dev/zever/core/db")
func TestDBRoundTrip(t *testing.T) { conn, err := dbsqlite.New(db.Options{Path: filepath.Join(t.TempDir(), "test.db")}) if err != nil { t.Fatalf("New: %v", err) } t.Cleanup(func() { _ = conn.Close(t.Context()) })
ctx := t.Context() if _, err := conn.Exec(ctx, "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)"); err != nil { t.Fatalf("create: %v", err) } if _, err := conn.Exec(ctx, "INSERT INTO users (name) VALUES (?)", "ada"); err != nil { t.Fatalf("insert: %v", err) } rows, err := conn.Query(ctx, "SELECT name FROM users") if err != nil { t.Fatalf("query: %v", err) } defer func() { _ = rows.Close() }() for rows.Next() { var name string if err := rows.Scan(&name); err != nil { t.Fatalf("scan: %v", err) } if name != "ada" { t.Fatalf("name = %q, want %q", name, "ada") } }}The sqlite adapter rewrites ? placeholders per dialect, so the same test body runs against postgres in the adapter’s own cover tests.
Run the conformance kits for custom adapters
Section titled “Run the conformance kits for custom adapters”Writing your own adapter? Don’t hand-roll contract tests. Each battery ships a conformance kit (dbtest, outboxtest, cachetest, authtest, …) that proves backend parity: round-trips, error wrapping, transactions, retry, DLQ, dedupe.
import ( "context" "testing"
"github.com/zenta-dev/zever/adapters/outbox/memory" coredb "github.com/zenta-dev/zever/core/db" "github.com/zenta-dev/zever/core/outbox/outboxtest")
func TestConformance(t *testing.T) { t.Parallel()
outboxtest.Conformance(t, func(_ *testing.T, pub *outboxtest.Recorder) outboxtest.Harness { s, err := memory.New(memory.Options{Publisher: pub}) if err != nil { t.Fatalf("New() error = %v", err) } return outboxtest.Harness{ Store: s, InTx: func(ctx context.Context, fn func(context.Context, coredb.Tx) error) error { return fn(ctx, nil) }, } })}The database kit has the same shape:
import ( "path/filepath" "testing"
dbsqlite "github.com/zenta-dev/zever/adapters/db/sqlite" "github.com/zenta-dev/zever/core/db" "github.com/zenta-dev/zever/core/db/dbtest")
func TestDBConformance(t *testing.T) { t.Parallel()
dbtest.Conformance(t, func(t *testing.T) db.DB { conn, err := dbsqlite.New(db.Options{Path: filepath.Join(t.TempDir(), "kit.db")}) if err != nil { t.Fatalf("New: %v", err) } t.Cleanup(func() { _ = conn.Close(t.Context()) }) return conn })}Keep time deterministic
Section titled “Keep time deterministic”No time.Sleep for synchronization, no timing assertions, no unseeded randomness, no network. Instead:
- Wait on channels,
syncprimitives, or awaitForpolling helper that asserts a condition with a deadline. - Prefer
testing/synctestbubbles for timeout and deadline assertions: the bubble’s fake clock makes “wait, then assert” instant and deterministic. - Inject clocks into code under test (a
Now func() time.Timefield or a small clock interface) so expiry and retry tests set time explicitly.
Catch goroutine leaks
Section titled “Catch goroutine leaks”Services that spawn background work (queue promoter, scheduler cron) must drain on Close. goleak.VerifyTestMain in a TestMain fails the package when a test leaks a goroutine:
import ( "testing"
"go.uber.org/goleak")
func TestMain(m *testing.M) { goleak.VerifyTestMain(m)}The container’s own leak tests live behind the leak tag (go test -tags=leak ./container/) so the default suite stays fast; filter only process-lifetime third-party init workers you don’t own. Keep timeout tests short (roughly 100ms parent, 500ms block) and assert on observable behavior, never on elapsed wall-clock time.
Contributor rules
Section titled “Contributor rules”Writing tests for Zever itself? The contributor testing rules (determinism, process-global state, stdout capture, per-test DB files, synctest, fork/exec races, reproducing flakes) live in .github/CONTRIBUTING-testing.md in the repo root.