Testkit: isolated Postgres helpers for integration tests
framework/testkit provides public test helpers for host apps that need
real Postgres isolation in integration tests. The package carves a fresh
database per test, optionally runs your migration callback, and drops the
database on t.Cleanup.
Framework-internal vs public. The internal helpers in
framework/internal/testdbfollow a schema-based isolation strategy and are not exported.testkitis the stable public API for host-app test code.
Harness identity: AsUser and AsTenant
framework.TestHarness(t, app) sends requests straight through the router
with no session. Two methods return a copy whose requests carry an identity
in their context; each keeps whatever the other set, so they chain:
ta := framework.TestHarness(t, app). AsUser(struct{ ID string }{ID: "u1"}). AsTenant("acme") ta.Post("/invoices", body).AssertStatus(t, http.StatusCreated)
AsUser(user any)sets the request user, which passes the default session gate. Use a real user type when the test also needs an owner extractor or anaccess.Policyto resolve roles from it.AsTenant(t any)sets the request tenant. A string value is also stored as the tenant id, which is whatMultiTenantentities scope by and stamp on insert. A non-string value only reaches code that reads the tenant object itself.
Request body encoding failures are retained on the response: every assertion
helper reports a request-creation test failure instead of panicking, and
TestResponse.JSON returns the original creation error without decoding.
This applies to both direct request helpers and TestRequest.WithBody.
Render failures
framework.TestHarness(t, app) fails t when a component or layout
panics while rendering a harness request, even if an error boundary
returns normal-looking HTML and the response is 200. The failure names
the component type and panic message. AsUser and the request builder
keep this check.
Plain UI host requests in Go test binaries also return HTTP 500 after a recovered render panic, preserving the fallback body. This covers full pages, navigation partials, intercepted screens, fills envelopes, and deferred parts. Production binaries keep the screen's recovery status.
For a test that panics on purpose to check production recovery, wrap its
handler with testkit.AllowRenderPanics(t, handler):
srv := httptest.NewServer(testkit.AllowRenderPanics(t, app.Router())) t.Cleanup(srv.Close)
AllowRenderPanics(t testing.TB, next http.Handler) http.Handler changes
only requests through the returned handler. t.Cleanup revokes the
exemption: later requests again get the test-only 500. Requests already
admitted may finish with production status. Register server cleanup
after creating the wrapper so the server closes before revocation.
Parallel tests are safe when each owns its wrapper and server; do not
share them with tests that did not opt out. There is no process-wide
switch, header, or query parameter. Calling the helper outside a Go test
binary panics. Error logs and TestHarness failure reporting remain active;
use plain HTTP requests to assert intentional recovery.
Generated app end-to-end tests capture the child server's logs and fail on recovered render panics, including those reached by browser requests. Recovered render panics log at error level with the component type, scrubbed panic message, and stack. Logging also runs in production; the existing fallback HTML and HTTP status behavior do not change.
Retired markup
TestHarness also fails t when a rendered response carries markup
the upgrade registry has retired: a class name or data-cui-*
attribute no longer emitted by the kit. This is what catches the names
a source scan cannot see — a class built at run time
(fmt.Sprintf("ui-%s", kind)), one read from the database, a template
value. The failure reads:
GET /orders: retired markup: class "ui-button" (v0.86.0: the button classes are fui-button*; the ui-button class no longer exists in any emitted markup or stylesheet); run gofastr upgrade
Attribute values never match — data-cui-comp="ui-sidebar" is a kept
component marker, not the retired ui-sidebar class — and a migrated
spelling reports nothing.
Every response on the app router is read by its Content-Type: full
pages, navigation partials, deferred parts, 404/405/error documents,
island RPC answers, and widget chrome are scanned as markup (text/html
and text/plain, which the runtime applies to an html-mode signal as
markup). JSON answers and widget /state snapshots are walked string
by string, and each string holding a tag is scanned, so an island that
renders a retired class only after a click still fails the test.
Stylesheets, scripts, event streams, and downloads are not read, and a
hijacked connection is dropped from the scan.
The scan runs only in test binaries and under gofastr dev (which
warns once per path and name, so a livereload loop cannot flood the
console). Production never scans and never loads the registry. Apps
that build their own httptest.Server instead of the harness get the
same check: findings log at warn level in the test binary.
Pages that work without JavaScript
Every GoFastr page is server-rendered, its links are real hrefs and
its forms post natively; runtime.js adds client navigation, islands
and widgets on top. framework/testkit/nojstest pins that as a test.
It loads each page in a Chrome tab whose script execution is disabled
through DevTools before the first byte arrives, so neither the runtime
nor any inline script runs, and reports what a visitor without script
would hit:
| Kind | Meaning |
|---|---|
empty-main | <main> (or <body> without one) shows no text, or is not rendered at all (hidden, display: none) |
dead-link | a visible link to # or a javascript: URL, or an <a> with no href acting as a button |
form-no-submit | a visible form with no enabled submit control (a button in a disabled <fieldset> counts as disabled; an image button counts as a submit control) that is not a single-field form Enter submits, or whose every enabled submit control lands on a javascript: URL. A button's formaction overrides the form's action, so formaction="/save" rescues a javascript: form and formaction="javascript:…" sinks a real one. A single-field form whose only submit buttons are disabled counts: Enter clicks the first one, and a disabled one does nothing. An action of # submits to the current page and passes |
http-status | the page answered 4xx or 5xx, so it is an error page, not the page |
script-ran | the GoFastr runtime executed, so the tab did not hold and the other results mean nothing |
Hidden elements are skipped (a menu toggle the runtime reveals is not
a dead link), and <form method="dialog"> is skipped.
import "github.com/DonaldMurillo/gofastr/framework/testkit/nojstest" func TestNoJSPagesWork(t *testing.T) { browser := axetest.NewBrowser(t) tab, done := nojstest.NewTab(t, browser) defer done() for _, p := range pages { // derive from your screen catalog problems, err := nojstest.Check(tab, base+p) if err != nil { t.Errorf("%s: %v", p, err) continue } nojstest.Report(t, p, problems) } }
Each Check and Inspect gets its own 30-second budget, so one tab
can walk hundreds of pages. Every navigation in the tab keeps script off, including a followed link
or a native form submission, so signing in through the real login form
in that tab proves the form works without the runtime. Call
nojstest.Inspect(tab) to check the page the submission landed on
without navigating again. examples/meridian/nojs_test.go does exactly
that before checking the app and admin screens, and
examples/site/nojs_test.go checks every page the site's axe gate
scans.
CSS weight per page
framework/testkit/cssweight reports how much CSS a page makes the
browser fetch or parse: the text of every <style> element plus every
<link rel="stylesheet"> the document names, each linked sheet fetched
once. It runs over plain HTTP with no browser, so a gate weighs
hundreds of pages in a second or two. Pick a cap a little above your
heaviest page today, and the test then fails when CSS grows unless the
same change raises the cap.
import "github.com/DonaldMurillo/gofastr/framework/testkit/cssweight" func TestCSSWeight(t *testing.T) { const cap = 17 << 10 // gzip bytes for _, p := range pages { w, err := cssweight.Measure(http.DefaultClient, base+p) if err != nil { t.Errorf("%s: %v", p, err) continue } if w.Gzip() > cap { t.Errorf("%s ships %s, over the %d B cap", p, w, cap) } } }
Weight.Gzip() adds up each sheet's gzip size, compressed one sheet
at a time the way each crosses the wire. Weight.Bytes() is the raw
total and Weight.Inline() the raw <style> part, which a return
visit downloads again inside every document while a linked sheet
comes from the cache. A page or sheet that answers anything but 200,
or redirects, is an error, so a signed-in gate whose session lapsed
fails instead of weighing the login page. Pass a client with a cookie
jar to weigh signed-in pages. examples/meridian/cssweight_test.go
and examples/site/cssweight_test.go are the two gates in this repo.
Isolated databases
import ( "testing" _ "github.com/lib/pq" "github.com/DonaldMurillo/gofastr/framework/testkit" ) func TestMyFeature(t *testing.T) { db := testkit.NewIsolatedDB(t, adminDSN, func(db *sql.DB) error { _, err := db.ExecContext(ctx, `CREATE TABLE posts (id TEXT PRIMARY KEY)`) return err }) // db is a *sql.DB pointing at the fresh database. // The database and connection are automatically closed + dropped on t.Cleanup. }
NewIsolatedDB
func NewIsolatedDB(t *testing.T, adminDSN string, migrate func(*sql.DB) error) *sql.DB- Validates
adminDSN: hard-fails (t.Fatalf) if empty or wrong scheme. Tests that skip on a missing DB prove nothing; this helper refuses to skip. - Opens a connection to
adminDSNand pings it (retries for up to 3s). - Creates a uniquely-named database:
ftest_<sanitised-test-name>_<random>. - Calls
migrate(carved)if non-nil: run your schema DDL here. - Registers
t.Cleanupto terminate lingering connections andDROP DATABASE.
Returns the *sql.DB for the carved database.
NewIsolatedDBWithName
Same as NewIsolatedDB but also returns the database name as a string,
useful when a test wants to assert the database exists (or is gone) via a
separate admin connection.
db, name := testkit.NewIsolatedDBWithName(t, adminDSN, migrate)Admin DSN
Pass a Postgres DSN with permission to CREATE DATABASE and DROP DATABASE
(typically a superuser connecting to the postgres maintenance database):
postgres://postgres:secret@localhost:5432/postgres?sslmode=disableBoth postgres:// and postgresql:// schemes are accepted. The libpq
key-value form (host=… dbname=…) is not supported: the helper
rewrites the path component of the URL to carve the new database name, which
requires a URL-parseable DSN.
A common pattern is to read the DSN from an environment variable:
adminDSN := os.Getenv("GOFASTR_TEST_POSTGRES_DSN") if adminDSN == "" { t.Skip("GOFASTR_TEST_POSTGRES_DSN unset; skipping live-PG test") }
The helper itself does not skip on a missing DSN: it hard-fails. Skipping is the caller's responsibility. The framework's own self-tests accept both
GOFASTR_TEST_POSTGRES_DSNandWTF_TEST_DATABASE_URL.
Using testkit with factory
Combine testkit with framework/factory to create fixture rows against
the isolated database:
db := testkit.NewIsolatedDB(t, adminDSN, migrate) app := framework.NewApp(framework.WithDB(db)) app.Entity("posts", postsConfig) postFactory, err := factory.New(app.Registry, "posts", func() map[string]any { return map[string]any{"title": "test post", "status": "draft"} }) if err != nil { t.Fatal(err) } post, err := postFactory.Create(ctx)
Because factory goes through the CRUD handler's full pipeline, hooks
and validations fire as they would for real HTTP traffic.
ValidateAdminDSN
Exported so tests can assert on the error wording:
err := testkit.ValidateAdminDSN("") // err.Error() contains "empty" err = testkit.ValidateAdminDSN("mysql://...") // err.Error() contains "postgres:// scheme"
RewriteDBNameForTest
Exposed for white-box testing of the DSN-rewrite logic. Not for production callers:
out, err := testkit.RewriteDBNameForTest("postgres://u:p@host/db", "new_db")Returns an error for libpq key-value DSNs, non-postgres schemes, or unparseable inputs: by design, to prevent the carved connection from accidentally pointing at the admin database on parse failure.
registry.IsolateForTest
The component style registry (core-ui/registry) is process-global. A test
that asserts on what it contains — which stylesheets a page links, the exact
names in a bundle — is asserting about every package linked into that test
binary, not just the component under test.
That bites in a specific way: framework/ui's package init registers
ui-button as LoadAlways and every other kit sheet besides. So merely
importing framework/ui anywhere in a package changes the eager set and the
catalog every test in it sees, and a registry assertion two files away turns
red with nothing in its own file changed.
IsolateForTest swaps the registry for a fresh one and restores the previous
contents when the test finishes:
func TestComponentEmitsItsOwnLink(t *testing.T) { registry.IsolateForTest(t) st := registry.RegisterStyle("my-component", myCSS) // ... assert on what the host emits for st, with nothing else registered }
Two properties worth knowing:
- Registrations made during isolation are dropped on restore. They cannot leak into a later test. Without isolation they accumulate for the whole package run.
- Restore runs through
t.Cleanup, so it also runs ont.Fataland on a panic.
Sequential tests only, and misuse is silent. With t.Parallel(), a sibling
test's registrations land in the isolated test's throwaway map, and the restore
destroys them — neither test fails, the sibling just quietly loses its styles.
Nothing structural can prevent that without core-ui/registry importing
testing, so this is a constraint you have to keep, not one the compiler keeps
for you.
Common mistakes
- Using the libpq key-value form for
adminDSN.host=localhost user=postgres dbname=postgresis not URL-parseable. The helper rejects it with a scheme error. Use the URL form:postgres://postgres@localhost/postgres. - Not closing the
*sql.DBbefore assertions that check the DB was dropped.t.Cleanupcloses the connection and drops the database. If you open an extra connection before cleanup runs, Postgres refuses theDROP DATABASEwhile that connection is open. The cleanup kills lingering backends withpg_terminate_backend, but a connection inside the same process thatt.Cleanuphasn't had a chance to close will race. - Calling
NewIsolatedDBwithout thegithub.com/lib/pq(orpgx) driver blank-import. The helper usesdatabase/sqlwith the"postgres"driver name. Import_ "github.com/lib/pq"or_ "github.com/jackc/pgx/v5/stdlib"to register the driver; without it,sql.Open("postgres", …)returns an error immediately.