Skip to main content
Version: 1.31.0

Testing your application

End-to-end tests do not need to build a binary, launch a child process, or poll /healthz by hand.

The pkg/nucleustest kit boots your full application inside the test process and stops it on cleanup, and gives you a client that speaks your API's language. This page covers booting a test server, making requests and reading their answers, cookies, CSRF and sessions, protected routes, giving each test its own database, asserting against the data afterwards, and checking a module against what the framework expects of it.

import (
"net/http"
"testing"

"github.com/jcsvwinston/nucleus/pkg/nucleus"
"github.com/jcsvwinston/nucleus/pkg/nucleustest"

"example.com/myapp/internal/modules"
)

func TestWidgetsAPI(t *testing.T) {
srv := nucleustest.Start(t, nucleus.New().
FromConfigFile("testdata/nucleus.yml").
Mount(modules.WidgetModule()))

resp := srv.Get("/widgets")
if resp.Status != http.StatusOK {
t.Fatalf("want 200, got %d: %s", resp.Status, resp)
}
}

Start does four things. It builds the application from the builder; replaces the configured port with a free loopback port, so parallel tests never collide; runs the full startup sequence (modules, jobs, webhooks, middleware); and waits for /healthz before returning. A registered t.Cleanup shuts the application down gracefully, and an unexpected run error fails the test.

StartApp is the direct-struct counterpart, for a hand-built nucleus.App.

The tests the CLI writes are built this way. The suite starter's shop/module_test.go (nucleus new --template suite) and the test nucleus generate module writes next to a slice boot the module through the kit and speak to it through its client, decoding the answers into the module's own types — a working example of everything below, in your own project.

Making requests​

Request sends one request and reads the whole answer; Get, Post, Put, Patch and Delete fill in the method. A body that is not nil, a []byte, a string or an io.Reader is encoded as JSON, and Accept defaults to application/json:

resp := srv.Post("/widgets", map[string]any{"name": "gear", "size": 3})
if resp.Status != http.StatusCreated {
t.Fatalf("want 201, got %d: %s", resp.Status, resp)
}

var widget struct {
ID string `json:"id"`
Name string `json:"name"`
}
resp.JSON(t, &widget) // fails the test when the body is not the JSON expected

Options adjust one request: nucleustest.WithHeader, WithQuery, WithBearer and srv.WithCSRF() (below). A transport failure is fatal — the application is in this process, so an unreachable server is a bug in the test, never a condition to assert on. srv.Client() still returns the underlying *http.Client for anything the helpers do not cover.

Cookies, CSRF and sessions​

The client keeps the cookies the application sets — session, CSRF — across requests, Secure ones included: the application issues them with the flag, the test server speaks plain HTTP on loopback, and the kit makes the same exception browsers make for localhost. srv.Cookies() shows what it holds; srv.SetCookie stores one as if the application had.

When the application runs the CSRF middleware (csrf_enabled: true), a state-changing request needs the token the middleware issued. srv.WithCSRF() fetches it when the client holds none and sends it in the header the middleware reads; srv.CSRFToken() returns it for a form field:

resp := srv.Post("/widgets", widget, srv.WithCSRF())

To act as a signed-in user on routes that read the session, open a session in the application's own store — no password, no login form, no flows around them:

srv.SignInAccount("acc-7", "seven@example.test") // the keys pkg/accounts reads
resp := srv.Get("/me") // sees account acc-7
srv.SignOut()

SignIn(values) opens a session with any keys your own modules read.

Exercising protected routes​

MintToken issues a bearer token signed with the application's own jwt_secret — the same material the framework's JWT middleware validates — and WithBearer sends it:

token := srv.MintToken("user-1", "tester", "admin")
resp := srv.Get("/api/admin/stats", nucleustest.WithBearer(token))

Applications configured with asymmetric keysets (jwt_keys) should mint through auth.NewJWTManagerFromKeys directly.

Test data with one call​

Make persists one record of a registered model with sensible defaults and returns it, primary key filled — strings become <field>-<n>, numbers n, times now, from a per-process sequence so two records never collide on a value the test did not choose. Booleans, pointers and foreign keys stay zero for the test to set. MakeN makes several; an override function sets what the test cares about:

w := nucleustest.Make[Widget](srv) // name "name-1", size 1
gear := nucleustest.Make[Widget](srv, func(w *Widget) { w.Name = "gear" })
many := nucleustest.MakeN[Widget](srv, 5)

The record is written through model.CRUD against the model's database with the application's dialect, so what the test reads back is what a handler would have written. The model has to be registered — mounted in a module's Models — and its table has to exist (srv.MigrateDir, or srv.Runtime().AutoMigrate in a test).

A transaction per test​

Transactional makes every database run the whole test inside one transaction that is rolled back when the test ends — for the application's routes, not only the test's own handle. Two tests can share one database without seeing each other, and a suite on the application's PostgreSQL needs no cleanup between them:

dbs := nucleustest.Transactional(t, map[string]app.DatabaseConfig{
"default": {URL: os.Getenv("TEST_DATABASE_URL")},
})
srv := nucleustest.Start(t, nucleus.New().WithDatabases(dbs).Mount(modules.WidgetModule()))

It works one level below the pool: each database is opened through a driver registered for this test alone, which hands the pool a single connection with a transaction already begun. Transactions the application begins become savepoints, so its own commits and rollbacks keep their meaning. On engines whose DDL is transactional (PostgreSQL, SQLite, SQL Server) the schema the test created is rolled back too; on MySQL a CREATE TABLE commits, so create tables outside the scope there. Two consequences of "one connection": statements are serialised, and a handler that reads a result set while issuing another statement on the same connection behaves as the engine allows (SQLite interleaves, PostgreSQL does not). TempSQLite stays the right tool for a test that needs the pool's real concurrency.

What the application sends out​

A flow sends a mail, stores a file, enqueues a job, calls another service. The kit keeps each of those in the process and reads it back:

srv.Post("/signup", map[string]string{"email": "ana@example.test"})

mails := srv.SentMail() // what the application sent (mail_driver: memory, the kit's default for tests)
data := srv.Stored("avatars/ana.png") // what it stored (storage.provider: memory, or whatever it runs)
keys := srv.StoredKeys("avatars/")
jobs := srv.EnqueuedTasks() // what it enqueued, whether or not a worker ran it

Mail: when the application would discard mail (mail_driver empty or noop) the kit selects the memory driver, so SentMail works without configuration; a driver you chose — smtp, a plugin — is kept. Storage: Stored and StoredKeys read the application's own store; storage.provider: memory keeps files in the process. Jobs: EnqueuedTasks is the record the in-process provider keeps of every enqueue; the jobs runtime exists once a module registers a job.

For the HTTP the application makes to other services, NewHTTPRecorder starts a server that records every request and answers what you tell it:

rec := nucleustest.NewHTTPRecorder(t)
rec.Respond(http.StatusAccepted, `{"queued":true}`)
// point the application at rec.URL through its configuration …
srv.Post("/orders", order)
reqs := rec.Requests() // method, path, query, headers, body

Code that takes an *http.Client can be given rec.Client(), which sends every request to the recorder whatever host it names.

Holding a response to the API document​

An application that serves its OpenAPI document (WithOpenAPIDocument) can be held to it from any test. AssertConforms looks up the operation the document declares for the request that produced a response and checks the response against it: a declared status, a declared content type, a body that matches the schema.

resp := srv.Get("/api/articles/1")
srv.AssertConforms(t, resp) // fails naming each field that departs: "/title: is required"

A handler that stops writing a field the document promises, or answers a status the operation does not declare, fails the test that calls it rather than the client that reads it. srv.Document() returns the document itself.

A per-test database, with your real schema​

nucleustest.TempSQLite(t) gives every test its own database file (removed with the test's temp dir), and srv.MigrateDir applies your project's SQL migrations through the real migrator — ledger and checksums included, so a second call is a no-op, exactly like nucleus migrate up:

cfg := app.DefaultConfig()
cfg.Databases = nucleustest.TempSQLite(t)

srv := nucleustest.StartApp(t, nucleus.App{Config: cfg, Modules: myModules})
srv.MigrateDir("../../migrations")

From the builder, pin it with WithDatabases:

srv := nucleustest.Start(t, nucleus.New().
FromConfigFile("testdata/nucleus.yml").
WithDatabases(nucleustest.TempSQLite(t)).
Mount(modules.WidgetModule()))

WithDatabases beats both the file and the NUCLEUS_* environment layer. That last part matters more than it looks: the environment layer is applied after the file, so in a shell carrying your project's variables — the ordinary development loop — a test that thought it had its own SQLite file would open your development database instead, and MigrateDir would write to it. The kit now logs a warning when it sees NUCLEUS_DATABASES__* set, but pinning is the way to be sure.

Asserting against the database​

srv.DB() is the application's managed *sql.DB — the same pool your modules use — so a test can close the loop an HTTP assertion alone cannot:

resp := srv.Post("/widgets", map[string]any{"name": "x"})
// status assertions…

var n int
_ = srv.DB().QueryRow("SELECT COUNT(*) FROM widgets WHERE name = 'x'").Scan(&n)
// …and the row is REALLY there.

srv.Runtime() exposes the full module-facing handle (logger, authorizer, dialect-aware database handles, storage, mailer) when a test needs more than the pool. Under the hood the kit captures it by mounting one extra module — the name nucleustest_probe is reserved for it.

Proving persistence​

Because starting and stopping is cheap, the restart pattern — the only test that distinguishes a real repository from an in-memory one — is three lines:

first := nucleustest.StartApp(t, app())
// ... create a record over HTTP ...
first.Stop()

second := nucleustest.StartApp(t, app())
// ... the record must still be served ...

With TempSQLite, point both boots at the same map (call it once, reuse the value) so the second boot sees the first boot's file.

Checking a module against the framework​

A module's mistakes used to surface as a boot failure in some application's test, one boot at a time: a malformed policy row stopped the boot before anyone learnt that two routes conflicted. CheckModule checks a module on its own and names every defect at once:

func TestNotesModule(t *testing.T) {
nucleustest.CheckModule(t, notes.Module())
}

It runs, one at a time, the checks the framework applies to a module when it boots, and fails the test once for every check the module does not pass, with the framework's own words for the defect:

checkwhat it asks
namenon-empty, not another module's, and lowercase letters, digits and underscores — the name is a config key, an environment variable, a template namespace and a webhook path segment
prefixempty, or a clean absolute path: policy rows and CSRF exemptions resolve against it as written
configthe typed configuration binds, takes its default: tags and passes its validate: tags
depends-onevery module DependsOn names is mounted, and no declaration closes a cycle
requiresevery database Requires names, and DefaultDB, is configured
policiesevery row is well formed, loads, and grants something the module serves
csrf-exemptevery exemption is well formed, stays under the module, and covers a route it serves
templatesthe embedded templates parse
modelsevery model registers
startOnStart returns nil
migrationsthe embedded migrations apply on a fresh database
jobs, webhooksthe registrations are valid
routesthe routes register — no duplicate, no conflict with the framework's own, no Resource verb the controller lacks
shutdownOnShutdown returns nil within the shutdown budget, before the framework closes the database

Three of them catch what boot lets through without a word: a policy row or a CSRF exemption about a path the module does not serve loads and never applies; a prefix without its leading slash serves the routes at /api while its rows name api/…; and a DefaultDB nobody configured hands OnStart a nil database.

CheckModule uses a default application with a temporary SQLite database. A module that requires a database by name, reads modules.<name> from a configuration file, or ships migrations for another engine is checked against the application it belongs to, with the other modules mounted so their names are taken:

nucleustest.CheckModuleIn(t, nucleus.New().
FromConfigFile("testdata/nucleus.yml").
WithDatabases(dbs).
Mount(accounts.Module()),
billing.Module())

Both return every verdict — a []nucleus.ModuleCheck, a name and an error each. nucleus.CheckModule runs the same checks without a test, for a tool that wants them.

Under the hood​

The kit is a thin wrapper over nucleus.RunContext(ctx, app): Run with a caller-owned lifetime, where cancelling the context triggers the same graceful shutdown a SIGTERM does. Embedders with their own harness can use it directly.

For fast unit tests of a generated resource, the scaffold already ships a self-contained test file with an in-memory fake of the repository interface — no database, no HTTP server. The kit is for the layer above: booting the real thing.