Skip to content

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/apptest

New 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

OptionEffect
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.

A native HTML form posted by the browser carries none of this. If a page submits with <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.

Run your suite both ways. Moving 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. Send returns 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 directly

Limitations

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 it

Next steps

  • Inertia — asserting on props rather than on rendered HTML.
  • Migrations — what WithScratchDB() runs against the scratch database.