Testing
lemmego/apptest boots a real Lemmego application inside a test and drives it
over HTTP without binding a port. Providers run, middleware installs, routes
mount — so a test exercises the application that ships rather than a
reassembled approximation of it.
func TestAProfileIsPublic(t *testing.T) {
a := apptest.New(t,
apptest.WithConfig(configs.All()),
apptest.WithProviders(bootstrap.LoadProviders()...),
apptest.WithRoutes(bootstrap.LoadRoutes()...),
apptest.WithScratchDB(),
)
a.Get("/@grace").Send().
AssertStatus(200).
AssertHeadContains(`<title>Grace Hopper`)
}Install
go get github.com/lemmego/apptestNew goes through app.Configure() and engine.Handler() — the same sequence
Run performs before it starts listening — and fails the test if the
application cannot boot.
Options
| Option | Effect |
|---|---|
WithConfig(configs.All()) | The real configuration map |
WithProviders(...) | bootstrap.LoadProviders()... for the real stack, or a subset to isolate one concern |
WithRoutes(...) | bootstrap.LoadRoutes()... |
WithMiddlewares(...) | Application middleware |
WithHTTPMiddlewares(...) | net/http-level middleware |
WithErrMap(...) | So a test sees the statuses and pages the application really returns |
WithScratchDB() | A scratch database, migrated, then dropped |
WithScratchDBNoMigrate() | For testing the migrator itself |
WithStrictSSR() | A failed server-side render becomes a 500 |
WithoutCSRFToken() | Send unsafe requests with no token, to assert the protection exists |
WithSharedCache() | Opt out of per-App cache isolation |
WithWorkingDir(dir) / WithoutChdir() | Override the chdir to the project root |
Without WithConfig the application gets only the minimum needed to boot,
which is enough for routing tests and nothing else.
Requests
a.Get("/launchpad")
a.Post("/launch").JSON(map[string]any{"title": "A thing"})
a.Put("/profile").Form(map[string]string{"headline": "Building things"})
a.Patch(path).Raw(body).Header("Content-Type", writer.FormDataContentType())
a.Delete("/launch/x")
a.Get("/feed").Inertia().ActingAs("42").Send()Raw is what a multipart upload needs: the boundary is part of the
Content-Type and is generated by multipart.Writer, so neither JSON nor
Form can express one.
Cookies are carried across requests on one App, so a login followed by a
page load behaves as a browser would.
Authenticating a request
a.Get("/settings").ActingAs("42").Send()ActingAs sets a header that a middleware installed only by this package
turns into a verified subject via auth.SetSubject. Your application’s own
UserLoader then runs, so the handler receives the same concrete
*models.User it would in production — which is what keeps a test of
auth.UserAs[*models.User] meaningful.
It is per-request rather than a boot option, because one App serves many
requests.
Pass whatever UserProvider.GetID returns: a string, even when the column is
an integer.
CSRF and the Inertia asset version
Both are handled for you, and neither weakens what a test proves.
CSRF. The harness presents the token on unsafe requests the way a browser does. The token is session-backed — the middleware compares what the request presents against what the session holds — so a request can only satisfy it by having obtained a session first. A route that is not behind the middleware is still caught.
Use WithoutCSRFToken() to assert on the rejection itself. A test that only
ever sends a valid token cannot tell a guarded route from an unguarded one.
<form method="post"> rather than through Inertia’s router, the
harness still passes — it sets the header itself — while the browser is
answered 419. Assert on the form, not only on the endpoint.The Inertia asset version is discovered once per App and sent
automatically. Inertia answers 409 Conflict whenever the version a client
sends differs from the server’s, and sending none differs from any non-empty
version — so without this, an application that hashes its Vite manifest (the
default) would answer 409 to every Inertia request and the test would assert
against an empty body.
InertiaVersion(v) overrides it, for a test deliberately exercising the
mismatch.
Assertions
resp := a.Get("/launch/a-thing").Send()
resp.AssertStatus(200).
AssertHeader("Content-Type", "text/html; charset=utf-8").
AssertSee("A thing").
AssertDontSee("draft").
AssertHeadContains(`<title>A thing`, `"@type":"SoftwareApplication"`).
AssertServerRendered()
a.Post("/launch").JSON(body).Send().
AssertStatus(302).
AssertRedirect("/launch/a-thing").
AssertCookie("lemmego_session")
a.Get("/api/me").JSON(nil).Send().
AssertJSONPath("user.handle", "grace").
AssertJSONPathAbsent("user.password")AssertHeadContains looks only inside <head>. Prefer it to a
whole-body substring check when asserting on metadata: a page that echoes a
query back makes a body-wide check true for almost any string.
AssertJSONPathAbsent is for “this field must not be published” — a
leaderboard with no score, a match with no contact details.
JSONValue(path) returns the raw value for an assertion this package does not
model.
A Postgres database per test
WithScratchDB() creates a uniquely named database on the server your
application is already configured to use, points the configuration at it, runs
every migration registered with migration.GetMigrator, and drops it when the
test ends. Your tests do not see each other’s rows and can run in any order.
The server comes from your own configuration, which under a test binary
means .env.test. There is no separate environment variable naming a
database, and that is the point: two ways to say which server to use is two
things that can disagree. Point .env.test at a Postgres you are running.
It fails rather than skipping when that server is unreachable. Skipping looks kinder and costs more: a skipped package and a passing one are identical in the exit code, so the only thing that tells them apart is the clock. A test that needs a database and has none has not passed.
The habit is still worth keeping for every other t.Skip in a suite — if a
package that boots your application finishes in a second, it did nothing.
A scratch database per test is heavier than a transaction rolled back at the end, and it is the only approach that tells the truth about a migration, a trigger, a generated column or a deferred constraint.
Ctx() is the test’s own context for seeding a fixture or asserting against a
row afterwards. It is cancelled when the test ends, so a query left running by
a failed assertion does not outlive it.
Server-side rendering in tests
apptest.New(t, …, apptest.WithStrictSSR())A test boot is lenient by default: a failed server render degrades to client rendering instead of answering 500.
That default exists because a test does not control whether a Node renderer is
running — it depends on whether somebody has npm run dev open, and in CI
nothing does. Strict mode would make the same assertion pass on one machine
and fail on another, so the suite would report where it ran rather than
whether the code works.
Opt in with WithStrictSSR() when you want to assert on rendering, and pair
it with AssertServerRendered. It needs a renderer actually running.
public/hot aside for a full run catches
a test asserting on a string that only exists inside a component: green with a
renderer up, red without one.Testing streams and WebSockets
Send drives httptest.NewRecorder, which is right for almost every test and
cannot represent two kinds of request:
- A stream.
Sendreturns when the handler returns, so an endpoint that holds its response open never returns at all. The test hangs until the binary times out rather than failing. - An upgrade. A recorder does not implement
http.Hijacker, so a WebSocket handshake fails on the hijack rather than on anything the test meant to exercise.
server := a.Server() // a real listener over the same handler
client := a.Client() // shares the App's cookie jar
request, _ := http.NewRequest("GET", server.URL+"/broadcast/sse?channel=…", nil)
request.Header.Set(apptest.ActingAsHeader, "42")
response, err := client.Do(request)Server is idempotent — a helper may ask for it without coordinating with the
test that already did — and it closes when the test ends.
Client shares the App’s cookie jar, which matters because an EventSource
and a browser WebSocket cannot set an Authorization header — a cookie is
the only credential those endpoints have in production. So a sign-in through
the request builder authenticates a stream opened afterwards.
Client does not follow redirects: a stream endpoint answering 302 is a
failure the test should see.
Escape hatches
a.App() // the booted app.App, to reach into the container
a.Handler() // the http.Handler, to drive it directlyLimitations
No parallel-safe containers, no HTTP recording, no snapshot testing. Model factories belong next to your models rather than here.
Running a suite
go test ./...
GOWORK=off go test ./... # as a published module would resolve itNext steps
- Inertia — asserting on props rather than on rendered HTML.
- Migrations — what
WithScratchDB()runs against the scratch database.