The vukemtest package boots your real application, bootstrap.New, in the test environment and sends requests in-process: no port, no network.
func TestCreatePost(t *testing.T) {
t.Parallel()
app := vukemtest.New(t, bootstrap.New)
app.Client(t).
PostForm("/posts", url.Values{"title": {"Hello"}, "body": {"World"}}).
AssertRedirect("/posts/1").
AssertSessionHas("success")
vukemtest.AssertDatabaseHas(t, app, "posts", map[string]any{"title": "Hello"})
}vukemtest.New#
vukemtest.New(t, build, opts...):
- sets
APP_ENV=test, a randomAPP_KEYand cookie sessions; - points
DATABASE_URLat a private in-memory SQLite database per test, so parallel tests never share rows; - does not start the HTTP listener and discards logs;
- boots providers, runs the registered migrations and shuts the app down in
t.Cleanup.
clock := vukemtest.NewFakeClock(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC))
app := vukemtest.New(t, bootstrap.New, vukemtest.Options{
Env: map[string]string{"MAIL_FROM": "test@example.com"},
Clock: clock.Now, // app.Now() and sessions use the fake clock
SkipMigrations: false,
AppOptions: []vukem.Option{}, // extra options passed to build
})
clock.Advance(3 * time.Hour) // e.g. expire the idle session timeoutThe fake clock drives every session driver: cookie expiry and the expiry checks of the memory and database stores both follow it, so a clock fixed in the past keeps sessions alive. The client's cookie jar never checks Expires; a cookie is dropped only when the response deletes it.
The returned *vukemtest.App embeds *vukem.App (app.DB(), app.Config…) and adds Client, Handler and RefreshDatabase(t) (drop everything and migrate again).
vukemtest.New fails the test on a build, boot or migration error. To check that a bad configuration fails closed, use vukemtest.Build: same defaults, but it returns the error.
_, err := vukemtest.Build(t, bootstrap.New, vukemtest.Options{
Env: map[string]string{"SESSION_DRIVER": "bogus"},
})
if err == nil {
t.Fatal("app booted with an unknown session driver")
}The client#
Each app.Client(t) has its own cookie jar, like a browser, so sessions and flash messages carry over between its requests.
| Requests | Options |
|---|---|
Get, GetJSON, Delete, PostForm(url, values), PostJSON, PutJSON, PatchJSON, Do(method, url, body, contentType) | WithHeader, WithToken (Bearer), WithCookie, AcceptJSON(), HTMX() (partial htmx request) |
Assertions#
Assertions chain and, on failure, print the request, status, headers and body.
| Group | Methods |
|---|---|
| Status | AssertStatus(n), AssertOK, AssertCreated, AssertNoContent, AssertNotFound, AssertForbidden, AssertUnauthorized, AssertUnprocessable |
| Redirects | AssertRedirect(path) (3xx or HX-Location), AssertRedirectRoute(name, params...) |
| Body and headers | AssertSee, AssertDontSee, AssertHeader, AssertFragment (no <html> document) |
| JSON | AssertJSON(v), AssertJSONPath("data.0.title", v), AssertJSONCount(path, n), JSONPath(path) |
| APIs | AssertProblem(status) (RFC 9457), AssertJSONValidationErrors(fields...) |
| htmx | AssertHXTrigger(event) |
| Session | AssertSessionHas, AssertSessionMissing, AssertSessionHasErrors(fields...), Session() |
| Flash | AssertFlash(key, v), AssertNoFlash(key): c.Flash or inertia.Flash data, in the session after a redirect or in an Inertia page's flash object (the key must be present, so AssertFlash(key, nil) does not match a missing value) |
| Raw | Status(), Body(), Header(key), Recorder() |
api := app.Client(t).WithToken(token)
api.PostJSON("/api/posts", map[string]any{"title": ""}).
AssertJSONValidationErrors("title")
api.GetJSON("/api/posts/99").AssertProblem(http.StatusNotFound)
app.Client(t).HTMX().Get("/posts?q=go").AssertOK().AssertFragment()Database assertions#
vukemtest.AssertDatabaseHas(t, app, "posts", map[string]any{"title": "Hello", "deleted_at": nil}) // nil → IS NULL
vukemtest.AssertDatabaseMissing(t, app, "posts", map[string]any{"title": "Spam"})
vukemtest.AssertDatabaseCount(t, app, "posts", 1)Testing against PostgreSQL#
The in-memory SQLite database is fast and needs nothing installed, but an app that relies on PostgreSQL features (jsonb, ILIKE, arrays, RETURNING quirks) should run its tests on PostgreSQL too. The pgtest package, shipped with the Postgres driver, gives each test its own empty database and drops it when the test ends:
go get github.com/AlexGalhardo/vukem/drivers/postgres1. Start a server. Any PostgreSQL works; locally, Docker is the quickest:
docker run -d --name pg-test -p 5432:5432 -e POSTGRES_PASSWORD=secret postgres:172. Tell the tests where it is. pgtest reads an admin URL from TEST_DATABASE_URL (or POSTGRES_URL). It must be a postgres:// or postgresql:// URL (keyword/value DSNs like host=... dbname=... are rejected), the server PostgreSQL 13 or later, and its role needs the CREATEDB privilege; the database in the path is only used to connect.
# macOS / Linux
export TEST_DATABASE_URL="postgres://postgres:secret@localhost:5432/postgres?sslmode=disable"# Windows PowerShell
$env:TEST_DATABASE_URL = "postgres://postgres:secret@localhost:5432/postgres?sslmode=disable"3. Pass the database to vukemtest.New. pgtest.Database(t, "") creates pgtest_<random>, returns its URL and drops it in t.Cleanup:
import (
"net/url"
"testing"
"github.com/AlexGalhardo/vukem"
"github.com/AlexGalhardo/vukem/drivers/postgres"
"github.com/AlexGalhardo/vukem/drivers/postgres/pgtest"
"github.com/AlexGalhardo/vukem/vukemtest"
"example.com/blog/bootstrap"
)
func TestCreatePost_Postgres(t *testing.T) {
t.Parallel() // safe: every test gets its own database
app := vukemtest.New(t, bootstrap.New, vukemtest.Options{
Env: map[string]string{"DATABASE_URL": pgtest.Database(t, "")},
// Only needed if bootstrap.New does not register the driver already.
AppOptions: []vukem.Option{vukem.WithDatabaseDrivers(postgres.Driver())},
})
app.Client(t).
PostForm("/posts", url.Values{"title": {"Hello"}, "body": {"World"}}).
AssertRedirect("/posts/1")
vukemtest.AssertDatabaseHas(t, app, "posts", map[string]any{"title": "Hello"})
}Migrations run on the fresh database exactly as they do on SQLite. Call pgtest.Database before vukemtest.New: cleanups run in reverse order, so the app closes its connections before the database is dropped (any connection left open is terminated first).
When no URL is set or the server is unreachable, the test fails with a message saying so, rather than silently passing. To keep go test ./... green on machines without PostgreSQL, opt in explicitly:
func TestCreatePost_Postgres(t *testing.T) {
if testing.Short() || os.Getenv("TEST_DATABASE_URL") == "" {
t.Skip("set TEST_DATABASE_URL to run PostgreSQL tests")
}
// ...
}Or put Postgres tests behind a build tag (//go:build integration at the top of the file) and run them with go test -tags integration ./.... In CI, start PostgreSQL as a service container and set TEST_DATABASE_URL in the job environment.
Generating tests#
vukem make:test Comment # tests/comment_test.goPlain httptest#
Handlers are net/http handlers, so httptest works too: h, err := app.Handler() then h.ServeHTTP(rec, req). Time-dependent code without an app can use testing/synctest.
Authentication, mail and data#
app.Client(t).ActingAs("web", user).Get("/dashboard").AssertOK() // logged in on the session guard
app.Client(t).ActingAs("web", user).WithSession(map[string]any{"stamp": s}) // extra session values
app.Client(t).WithToken(plain).GetJSON("/api/v1/me").AssertOK() // bearer token
app.Mails().SentTo("ana@example.com") // MAIL_MAILER=array in tests
posts, err := factories.Post.Count(3).Create(t.Context(), db)
err = app.Seed(t.Context())vukemtest.FakeSocial(user)returnsOptions.Enventries that sign in through a fake provider in apps scaffolded withinstall:auth --social.vukemtest.App.CookieValue(name)reads the client's cookie jar.- See Factories and seeders and Events for
events.NewFake().