Skip to content
Testing reference

Testing reference

The APIs of packages anetostest and db/factory, and the hooks they use. See Test your app for a walkthrough.

Starting the app (anetostest)

APIDoes
anetostest.New(t, setup, opts...)*anetostest.App: builds the app with setup (func(*anetos.App) (*web.Server, error), or nil), boots it, runs its migrations, starts the test’s transaction; closes the app when the test ends. Fails the test on any error. One per test or subtest: it reports to t
anetostest.Env(map[string]string)Option: settings over everything else
anetostest.WithoutMigrations()Option: don’t run the migrations
anetostest.WithoutTransaction()Option: don’t wrap the test in a transaction; its writes are committed
anetostest.LogLevel(level)Option: minimum level of the app’s logs in the test’s log. Default Info
anetostest.FakeQueue()Option: dispatched jobs are recorded only (queue.Queue.Fake): not stored or run. Fails the test if setup has no queue
anetostest.FakeEvents(events...)Option: events of the types of the values (all, with none) are recorded only (events.Bus.Fake): their listeners don’t run. Fails the test if setup has no bus
anetostest.FakePubSub()Option: published messages are recorded only (pubsub.PubSub.Fake): the broker doesn’t get them. Fails the test if setup has no pub/sub
anetostest.FakeAI(replies...)Option: the model’s answers, one per request to the app’s AI client, in order (ai.FakeText, ai.FakeObject, ai.FakeToolCall, ai.FakeError, or an ai.FakeReply function). Options add up. Fails the test if setup has no AI client (ai.ForApp). See AI
anetostest.FakeSocial()Option: social login (auth/social) signs in through a stand-in OpenID Connect provider on a local TLS server, for every provider (GitHub’s API and other Provider.Profile functions aren’t called); social.Configured keeps every provider, with test credentials where settings are missing. Sign in with app.SocialSignIn
app.Context()The context of the test’s requests: the app’s services, the database and the test’s transaction. Pass it to your own code. (It hides the embedded anetos.App.Context(parent); call app.App.Context for that)
app.Router()The app’s *web.Router, or nil
app.AppThe embedded *anetos.App: app.Config(), anetos.Resolve[T](app.App), …

Settings

Highest priority first. .env is never read.

SourceHolds
anetostest.EnvWhatever the test passes
ForcedAPP_ENV=testing, a random APP_KEY, a CACHE_PREFIX, SESSION_PREFIX, QUEUE_PREFIX and PUBSUB_PREFIX of the app’s own (its items, server-side sessions, Redis jobs and streams are removed by shutdown hooks when the app stops: at the end of the test, or when the test’s app.Run returns); MAIL_DRIVER=memory (emails are kept in the mailer’s *mailer.MemoryTransport, not sent); STORAGE_DRIVER=memory (files are kept in memory, on every disk without a driver of its own); AI_PROVIDER=fake (no model is called: FakeAI scripts the answers; anetostest.Env can set another) and an empty AI_EMBEDDING_PROVIDER (embeddings are AI_PROVIDER’s: the fake’s)
Process environmentDB_* in CI, …
.env.testingNext to the test’s go.mod; optional
DefaultsHTTP_ACCESS_LOG=false, APP_URL=http://example.test (the test client’s site), MAIL_FROM_ADDRESS=test@example.com

With DB_CONNECTION unset or sqlite, and DB_DATABASE and DB_URL unset or empty in every source, DB_DATABASE=:memory: wins over all of them: never the default database/app.db.

Database

DatabaseEach test gets
SQLite in memory (the default; checked with SQLite after connecting)Its own database, migrated; no transaction, so db.AfterCommit callbacks run at once
SQLite file, PostgreSQL, MySQLThe migrated database, and a transaction rolled back when the test ends (db.WithTestTx: AfterCommit callbacks run when a db.Tx inside it commits, or at once outside one). Each request runs in a savepoint (anetostest_request), rolled back if the request left the transaction failed. With WithoutTransaction(), neither

Requests (anetostest)

Every method returns a *anetostest.Response. Paths may have a query; an URL on another site fails the test. Requests go to the router as http://example.test/…, with:

  • the jar’s cookies (Path and expiry honored; Secure and Domain ignored);
  • the headers set with WithHeader, which replace the method’s Accept and Content-Type;
  • as Referer, the last HTML page: a GET answered 200 with text/html, not an htmx fragment (HX-Request without HX-Boosted);
  • for methods other than GET, HEAD, OPTIONS and TRACE, the session’s CSRF token in X-CSRF-Token, unless the request has the header or (forms) a _token field.
APISendsAccept
app.Get(path), app.Head(path), app.Delete(path)No bodytext/html
app.PostForm(path, url.Values), PutForm, PatchForm, DeleteFormURL-encoded formtext/html
app.PostMultipart(path, url.Values, anetostest.Upload{Field, Filename, Content, ContentType}…)Multipart form: the fields, then the filestext/html
app.GetJSON(path), app.DeleteJSON(path)No bodyapplication/json
app.PostJSON(path, v), PutJSON, PatchJSONv encoded as JSONapplication/json
app.Do(req)req (from httptest.NewRequest with a path), adding the jar’s cookies it doesn’t have, headers it doesn’t have, Referer and tokenreq’s
res.Follow()GET to the redirect’s Location; fails the test for another sitethe request’s
app.WithHeader(name, value)Sets a header on every later request; returns app
app.WithSession(func(*session.Session))Changes the session later requests carry; returns app. Needs session.ForApp in setup
app.Session()The *session.Session the next request will carry (flash values and errors from the last response included), to read
app.SocialSignIn(redirect, anetostest.SocialAccount{ID, Email, EmailVerified, Name, AvatarURL})GET redirect (the app’s route to the provider, such as /auth/google/redirect), the stand-in provider’s sign-in as the account, then GET the app’s callback; returns the callback’s response. Needs FakeSocial; ID is requiredtext/html

Responses (anetostest.Response)

Fields: StatusCode, Header, Body []byte, Request. Assertions report failures with t.Errorf (naming the request, with the start of the body) and return the response.

APIChecks
AssertStatus(code)The status
AssertOK(), AssertCreated(), AssertNoContent()200, 201, 204
AssertNotFound(), AssertForbidden(), AssertUnprocessable()404, 403, 422
AssertRedirect(path)A 3xx to path (http://example.test dropped from Location)
AssertRedirectRoute(name, args...)A 3xx to the named route
AssertHeader(name, value)One of the header’s values is value
AssertSee(texts...), AssertDontSee(texts...)The body contains (none of) the texts, as they are or HTML-escaped (html.EscapeString, or html/template’s, which also escapes +)
AssertJSON(v)The body is JSON equal to v, compared as JSON (numbers exactly, by value)
AssertJSONPath(path, v)The value at path ("data.0.title"; "" for the whole body) equals v, compared as JSON
AssertValidationErrors(fields...)A 422 (or 400) problem JSON with the fields in errors, or a redirect with the fields’ errors flashed to the session. No fields: any error. Send JSON requests to check a 422
AssertNoValidationErrors()Not a 422, not a 400 with field errors, no errors flashed
AssertSessionHas(key, value?)The next request’s session has key (holding value, compared as JSON)
AssertSessionMissing(key)It doesn’t
JSON(&v)Decodes the body; fails the test if it can’t
Text()The body as a string

Database (anetostest)

Generic functions, run on app.Context(). Conditions are db.Exprs: PostCols.Title.Eq("Hello").

APIDoes
anetostest.AssertDatabaseHas[T](app, conds...)A T row matches (soft-deleted rows excluded)
anetostest.AssertDatabaseMissing[T](app, conds...)No T row matches
anetostest.AssertDatabaseCount[T](app, n, conds...)n T rows match
anetostest.AssertSoftDeleted[T](app, conds...)A soft-deleted T row matches; T embeds db.SoftDeletes
anetostest.Create(app, factory)Inserts a row made by the factory; returns it
anetostest.CreateMany(app, factory, n)Inserts n rows

Jobs, events, email and messages (anetostest)

anetostest.New records, from the end of setup on, what the app dispatches, emits, mails and publishes, through the services’ Observe hooks, whether or not they are faked. match is a func(T) bool; nil matches any. Assertions report with t.Errorf and list what was recorded.

APIDoes
app.Dispatched()[]queue.Dispatched (ID, Job, Queue, Delay, Data; Decode(&v)), every job, function jobs (mail:send, event:…) included, oldest first. A job is recorded once dispatched: when the store has it (the database driver writes it in the request’s transaction: once that commits), with AfterCommit after the commit, with the sync driver before it runs; a dispatch that fails or is rolled back isn’t recorded
anetostest.Jobs[J](app)The J jobs dispatched, decoded. J must be registered (queue.Register); fails the test otherwise
anetostest.AssertDispatched[J](app, match)A J job matches
anetostest.AssertNotDispatched[J](app, match)None matches
app.AssertNothingDispatched()No job of any type
app.Emitted()[]any: every event, oldest first, recorded when Emit is called, before its listeners
anetostest.Events[E](app)The events of type E (or implementing E, an interface)
anetostest.AssertEmitted[E](app, match), AssertNotEmitted[E], app.AssertNothingEmitted()As for jobs
app.Mail()[]mailer.Record (Mailable, rendered Message, Queued): emails sent with mailer.Send (once the transport took them) or queued with mailer.Queue (once the job is dispatched; with the sync driver, once the job sent it)
anetostest.Mailables[M](app)The mailables of type M, sent or queued
anetostest.AssertMailSent[M](app, match)An M sent with mailer.Send matches
anetostest.AssertMailQueued[M](app, match)An M queued with mailer.Queue matches
anetostest.AssertMailNotSent[M](app, match)No M sent or queued matches
app.AssertNoMail()Nothing sent or queued
app.Published()[]pubsub.Published (Topic, Data, Attributes; Decode(&v)), oldest first
anetostest.Messages[T](app, topic)The messages of topic, decoded from JSON as T
anetostest.AssertPublished[T](app, topic, match), AssertNotPublished[T]A message of topic matches; none does

The emails the transport got (queued ones the queue ran included) stay available from the mailer: anetos.MustResolve[*mailer.Mailer](app.App).Transport().(*mailer.MemoryTransport).Sent().

AI (anetostest)

The app’s AI client (ai.ForApp) uses the fake provider in tests (*ai.Fake): requests get the replies FakeAI scripted, in order, and a request with no reply left fails with an error saying so. A test that sets another AI_PROVIDER with anetostest.Env uses that provider, unless it also uses FakeAI, which puts the fake in its place. The fake makes embeddings too (ai.Embed, ai.Embeddings), without replies: vectors of the texts’ words, so texts sharing words are near, of the size asked for (or, for a FixedSize model, the size expected).

APIDoes
app.AI()The *ai.Fake: Requests() ([]ai.Request, oldest first: Prompt(), System, Messages, Tools, Output, Model…), Add(replies...) for more replies, Remaining(), Embeddings() ([]ai.EmbedRequest: Model, Inputs, Dimensions, Purpose). Fails the test if the app has no AI client, or its provider isn’t the fake
app.AssertPrompted(match)A request matched (func(ai.Request) bool; nil matches any); otherwise reports the last prompt
app.AssertNotPrompted()No request was made
ai.FakeText(text)Reply: text
ai.FakeObject(v)Reply: v as JSON, the answer to ai.GenerateObject
ai.FakeToolCall(name, input)Reply: a call of tool name with input (encoded as JSON); its result goes to the next request
ai.FakeError(err)Reply: the request fails with err

Replies’ usage counts words, as a stand-in for tokens.

Repeated queries (anetostest)

APIDoes
app.RepeatedQueries()[]db.RepeatedQuery (Unit, SQL, Count, Caller; String()): the queries a request, job, listener, task or AI tool call of the test ran DB_REPEATED_QUERIES times or more (5 by default in tests), oldest first. Also logged as warnings
app.AssertNoRepeatedQueries()None: no N+1. See Find N+1 queries

Files (anetostest)

APIDoes
app.Disk(name...)*anetostest.Disk: the default disk, or the named one (STORAGE_DISKS). Fails the test without storage.ForApp or for an unknown disk
d.AssertExists(paths...)The files exist
d.AssertMissing(paths...)They don’t
d.AssertContent(path, want)The file exists with that content
d.Files(prefix)The paths under prefix ("": all), in order
d.Storage()The *storage.Disk, to add or read files

Clock (anetostest, anetos)

APIDoes
app.Freeze(t)Stops the app’s clock at t (now, for the zero time), truncated to microseconds; returns it
app.Travel(d)Moves the clock by d (back if negative): a frozen clock stays frozen, a running one runs d ahead
app.Unfreeze()Back to the system’s time
app.Now(), anetos.Now(ctx)The time on the app’s clock (anetos.Now without an app in ctx: time.Now())
app.App.SetClock(fn)The hook underneath: the app reads the time from fn (nil: the system’s clock)
anetos.WithClock(ctx, fn)A context with its own clock, for code without an app

Read from the app’s clock: model timestamps (created_at, updated_at, deleted_at); session lifetimes (whatever the store); the expiry of the test client’s cookies, password reset, verification and remember-me tokens, API tokens (and their last_used_at), temporary URLs signed with APP_KEY, social login state, and the memory cache’s items and locks; rate-limit windows; after:now-style validation rules; emails’ Date; memory disks’ modification times. Not affected: the clocks of database and Redis servers, which expire the items of the database and Redis cache stores (rate-limit counters there included) and time their queues; the clock of a store that signs its own URLs (S3, which checks them in real time); queue delays and leases, in every driver; timeouts and durations; the scheduler’s and workers’ loops; log timestamps.

Factories (db/factory)

APIDoes
factory.New(func(n int) T)*factory.Factory[T]; n is a sequence number from 1, unique per factory (shared by the factories With derives)
f.With(fns...)A new factory that also applies func(*T)s to every value
f.Make(), f.MakeMany(n)Values, not saved
f.Create(ctx), f.CreateMany(ctx, n)Inserted with db.Create (hooks run), one row at a time; stops at the first error

Sessions (session)

APIDoes
m.Load(r)The request’s *session.Session as the session middleware would load it (a new one if the cookie is missing or invalid)
m.Edit(r, fn)(*http.Cookie, error): the cookie of the request’s session after fn changed it. fn sees the session as a handler would; what was flashed for the next request is kept for it (Reflash)
session.ForApp, migrate.ForAppAlso provide the manager and the runner to the app (anetos.Resolve), which is how anetostest finds them