Documentation
¶
Overview ¶
Package anetostest tests Anetos applications: it boots the app with test settings and a clean database, sends requests through the router like a browser (cookies, CSRF tokens, the Referer), and asserts on responses, sessions, database rows, files, and the jobs, events, email and pub/sub messages the app sent (AssertDispatched, AssertEmitted, AssertMailSent, AssertPublished; faked with FakeQueue, FakeEvents, FakePubSub), and the model requests it made, with scripted answers (FakeAI, App.AssertPrompted). App.Freeze and App.Travel control the app's clock.
func TestCreatePost(t *testing.T) {
app := anetostest.New(t, setup) // setup: the app's own wiring
author := anetostest.Create(app, factories.Authors)
app.Get("/posts/new")
app.PostForm("/posts", url.Values{"title": {"Hello"}, "author_id": {fmt.Sprint(author.ID)}}).
AssertRedirect("/posts").
AssertSessionHas("status", "Post created.")
anetostest.AssertDatabaseHas[models.Post](app, models.PostCols.Title.Eq("Hello"))
}
Make one App per test or subtest: it reports to the t it was made with. With SQLite and no DB_DATABASE, each App gets its own in-memory database; with PostgreSQL or MySQL (DB_* in the environment or .env.testing) or a SQLite file, migrations run and everything the test does happens in a transaction that is rolled back at the end.
Index ¶
- func AssertDatabaseCount[T any](a *App, want int64, conds ...db.Expr)
- func AssertDatabaseHas[T any](a *App, conds ...db.Expr)
- func AssertDatabaseMissing[T any](a *App, conds ...db.Expr)
- func AssertDispatched[J queue.Job](a *App, match func(J) bool)
- func AssertEmitted[E any](a *App, match func(E) bool)
- func AssertMailNotSent[M mailer.Mailable](a *App, match func(M) bool)
- func AssertMailQueued[M mailer.Mailable](a *App, match func(M) bool)
- func AssertMailSent[M mailer.Mailable](a *App, match func(M) bool)
- func AssertNotDispatched[J queue.Job](a *App, match func(J) bool)
- func AssertNotEmitted[E any](a *App, match func(E) bool)
- func AssertNotPublished[T any](a *App, topic string, match func(T) bool)
- func AssertPublished[T any](a *App, topic string, match func(T) bool)
- func AssertSoftDeleted[T any](a *App, conds ...db.Expr)
- func Create[T any](a *App, f *factory.Factory[T]) T
- func CreateMany[T any](a *App, f *factory.Factory[T], n int) []T
- func Events[E any](a *App) []E
- func Jobs[J queue.Job](a *App) []J
- func Mailables[M mailer.Mailable](a *App) []M
- func Messages[T any](a *App, topic string) []T
- type App
- func (a *App) AI() *ai.Fake
- func (a *App) AssertNoMail() *App
- func (a *App) AssertNoRepeatedQueries() *App
- func (a *App) AssertNotPrompted() *App
- func (a *App) AssertNothingDispatched() *App
- func (a *App) AssertNothingEmitted() *App
- func (a *App) AssertPrompted(match func(ai.Request) bool) *App
- func (a *App) Context() context.Context
- func (a *App) Delete(path string) *Response
- func (a *App) DeleteForm(path string, form url.Values) *Response
- func (a *App) DeleteJSON(path string) *Response
- func (a *App) Disk(name ...string) *Disk
- func (a *App) Dispatched() []queue.Dispatched
- func (a *App) Do(req *http.Request) *Response
- func (a *App) Emitted() []any
- func (a *App) Freeze(t time.Time) time.Time
- func (a *App) Get(path string) *Response
- func (a *App) GetJSON(path string) *Response
- func (a *App) Head(path string) *Response
- func (a *App) Mail() []mailer.Record
- func (a *App) PatchForm(path string, form url.Values) *Response
- func (a *App) PatchJSON(path string, body any) *Response
- func (a *App) PostForm(path string, form url.Values) *Response
- func (a *App) PostJSON(path string, body any) *Response
- func (a *App) PostMultipart(path string, form url.Values, files ...Upload) *Response
- func (a *App) Published() []pubsub.Published
- func (a *App) PutForm(path string, form url.Values) *Response
- func (a *App) PutJSON(path string, body any) *Response
- func (a *App) RepeatedQueries() []db.RepeatedQuery
- func (a *App) Router() *web.Router
- func (a *App) Session() *session.Session
- func (a *App) SocialSignIn(redirect string, acct SocialAccount) *Response
- func (a *App) Travel(d time.Duration)
- func (a *App) Unfreeze()
- func (a *App) WithHeader(name, value string) *App
- func (a *App) WithSession(fn func(s *session.Session)) *App
- type Disk
- type Option
- type Response
- func (r *Response) AssertCreated() *Response
- func (r *Response) AssertDontSee(texts ...string) *Response
- func (r *Response) AssertForbidden() *Response
- func (r *Response) AssertHeader(name, want string) *Response
- func (r *Response) AssertJSON(want any) *Response
- func (r *Response) AssertJSONPath(path string, want any) *Response
- func (r *Response) AssertNoContent() *Response
- func (r *Response) AssertNoValidationErrors() *Response
- func (r *Response) AssertNotFound() *Response
- func (r *Response) AssertOK() *Response
- func (r *Response) AssertRedirect(path string) *Response
- func (r *Response) AssertRedirectRoute(name string, args ...any) *Response
- func (r *Response) AssertSee(texts ...string) *Response
- func (r *Response) AssertSessionHas(key string, value ...any) *Response
- func (r *Response) AssertSessionMissing(key string) *Response
- func (r *Response) AssertStatus(want int) *Response
- func (r *Response) AssertUnprocessable() *Response
- func (r *Response) AssertValidationErrors(fields ...string) *Response
- func (r *Response) Follow() *Response
- func (r *Response) JSON(v any) *Response
- func (r *Response) Text() string
- type SocialAccount
- type Upload
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
func AssertDatabaseCount ¶
AssertDatabaseCount checks the number of T rows matching conds.
func AssertDatabaseHas ¶
AssertDatabaseHas checks that a T row matches conds, as db.Query[T] sees it (soft-deleted rows don't count; see AssertSoftDeleted).
anetostest.AssertDatabaseHas[models.Post](app, models.PostCols.Title.Eq("Hello"))
func AssertDatabaseMissing ¶
AssertDatabaseMissing checks that no T row matches conds.
func AssertDispatched ¶ added in v0.2.0
AssertDispatched checks that a job of type J for which match returns true (any, for a nil match) was dispatched.
anetostest.AssertDispatched(app, func(j ChargeOrder) bool { return j.OrderID == id })
func AssertEmitted ¶ added in v0.2.0
AssertEmitted checks that an event of type E for which match returns true (any, for a nil match) was emitted.
anetostest.AssertEmitted(app, func(e OrderPlaced) bool { return e.Cents == 1500 })
func AssertMailNotSent ¶ added in v0.2.0
AssertMailNotSent checks that no mailable of type M for which match returns true (none at all, for a nil match) was sent or queued.
func AssertMailQueued ¶ added in v0.2.0
AssertMailQueued checks that a mailable of type M for which match returns true (any, for a nil match) was queued with mailer.Queue.
func AssertMailSent ¶ added in v0.2.0
AssertMailSent checks that a mailable of type M for which match returns true (any, for a nil match) was sent with mailer.Send.
anetostest.AssertMailSent(app, func(m mails.Welcome) bool { return m.User.ID == u.ID })
func AssertNotDispatched ¶ added in v0.2.0
AssertNotDispatched checks that no job of type J for which match returns true (none at all, for a nil match) was dispatched.
func AssertNotEmitted ¶ added in v0.2.0
AssertNotEmitted checks that no event of type E for which match returns true (none at all, for a nil match) was emitted.
func AssertNotPublished ¶ added in v0.2.0
AssertNotPublished checks that no message for which match returns true (none at all, for a nil match) was published to topic, decoded as T (messages that don't decode as T don't match).
func AssertPublished ¶ added in v0.2.0
AssertPublished checks that a message for which match returns true (any, for a nil match) was published to topic, decoded as T (messages that don't decode as T don't match).
anetostest.AssertPublished(app, "orders.created", func(m OrderCreated) bool { return m.ID == id })
func AssertSoftDeleted ¶
AssertSoftDeleted checks that a soft-deleted T row matches conds. T must embed db.SoftDeletes.
func Create ¶
Create inserts a row made by f, in the test's database and transaction.
post := anetostest.Create(app, factories.Post.With(func(p *models.Post) { p.Draft = true }))
func CreateMany ¶
CreateMany inserts n rows made by f.
func Events ¶ added in v0.2.0
Events returns the events of type E (or, for an interface type, that implement it) emitted so far, oldest first.
func Jobs ¶ added in v0.2.0
Jobs returns the jobs of type J dispatched so far, oldest first, decoded from what was dispatched (as a worker would).
jobs := anetostest.Jobs[ChargeOrder](app)
Types ¶
type App ¶
App is an application under test. It embeds the *anetos.App, and sends requests to the app's router with the methods of this type. Requests run one at a time; an App isn't safe for concurrent use (the database transaction isn't), but tests with their own App may run in parallel.
func ActingAs ¶ added in v0.3.0
func ActingAs[U auth.Authenticatable](a *App, u U) *App
ActingAs signs u in for the requests that follow, as a password sign-in without remember-me would, so a test needn't post the login form. It replaces whoever was signed in, and drops a remember-me cookie. U is the user type given to auth.ForApp in setup (*models.User):
app := anetostest.New(t, setup)
ada := anetostest.Create(app, factories.Users)
anetostest.ActingAs(app, &ada).Get("/dashboard").AssertOK()
It needs sessions (session.ForApp) and auth.ForApp in setup; a disabled user fails the test.
func New ¶
New builds the app with setup (the function main uses to connect the database and add the server and routes; nil for none), boots it and prepares its database. It closes the app when the test ends.
Settings, from highest priority: Env options; APP_ENV=testing, a random APP_KEY, and a CACHE_PREFIX, SESSION_PREFIX, QUEUE_PREFIX and PUBSUB_PREFIX of the App's own (so tests sharing a store don't see each other's items; New removes the App's items, sessions, Redis jobs and streams when the test ends), MAIL_DRIVER=memory (emails are kept, not sent: check them with the mailer's MemoryTransport), STORAGE_DRIVER=memory (files are kept in memory, for every disk that doesn't set its own driver), AI_PROVIDER=fake (no model is called: FakeAI scripts the answers) and an empty AI_EMBEDDING_PROVIDER (so embeddings are AI_PROVIDER's: the fake's); the process environment; the .env.testing file next to go.mod, if there is one (say, DB_DATABASE=blog_test); then HTTP_ACCESS_LOG=false, APP_URL=http://example.test (the test client's site, for absolute links, in emails say) and MAIL_FROM_ADDRESS=test@example.com. The settings in .env are not used (New only looks at its DB_CONNECTION, to stop a test that would use SQLite by mistake: when .env uses PostgreSQL, say, the test settings must name a DB_CONNECTION too, if only DB_CONNECTION=sqlite). With SQLite and neither DB_DATABASE nor DB_URL set (or set to ""), the database is in memory, not database/app.db.
After setup, New records what the app's queue, event bus, mailer and pub/sub dispatch, emit, mail and publish, for AssertDispatched and the other assertions; FakeQueue, FakeEvents and FakePubSub make them record only. The app's clock is the test's (App.Freeze, App.Travel).
func (*App) AI ¶ added in v0.3.0
AI returns the fake provider of the app's AI client: its requests so far (Requests), and more replies (Add). The test fails if the app has no AI client, or AI_PROVIDER isn't fake and FakeAI wasn't used.
func (*App) AssertNoMail ¶ added in v0.2.0
AssertNoMail checks that no email was sent or queued.
func (*App) AssertNoRepeatedQueries ¶ added in v0.2.0
AssertNoRepeatedQueries checks that no request (or job, listener…) of the test ran a query repeatedly: no N+1.
app.GetJSON("/posts?with=author").AssertOK()
app.AssertNoRepeatedQueries()
func (*App) AssertNotPrompted ¶ added in v0.3.0
AssertNotPrompted checks that the app made no request to the model.
func (*App) AssertNothingDispatched ¶ added in v0.2.0
AssertNothingDispatched checks that no job was dispatched.
func (*App) AssertNothingEmitted ¶ added in v0.2.0
AssertNothingEmitted checks that no event was emitted.
func (*App) AssertPrompted ¶ added in v0.3.0
AssertPrompted checks that a request to the model matched match (nil matches any request).
app.AssertPrompted(func(r ai.Request) bool { return strings.Contains(r.Prompt(), "1042") })
func (*App) Context ¶
Context returns the context the test's requests run with: it carries what the app provides (the database) and the test's transaction. Use it to call the app's code and the db package directly.
func (*App) DeleteForm ¶
DeleteForm sends a DELETE request with an URL-encoded form.
func (*App) DeleteJSON ¶
DeleteJSON sends a DELETE request accepting JSON, without a body.
func (*App) Disk ¶ added in v0.2.0
Disk returns the app's default disk, or the disk name (one of STORAGE_DISKS), for assertions:
app.Disk("avatars").AssertExists("users/1.png")
func (*App) Dispatched ¶ added in v0.2.0
func (a *App) Dispatched() []queue.Dispatched
Dispatched returns the jobs dispatched so far, oldest first: those of every type, function jobs ("mail:send") included.
func (*App) Do ¶
Do sends req, adding what the other methods add when it lacks them: the jar's cookies (by name), the headers set with App.WithHeader, the Referer, and (for methods other than GET, HEAD, OPTIONS and TRACE) the X-CSRF-Token header, which takes precedence over a "_token" form field. It runs with the test's context (App.Context). Build req with httptest.NewRequest and a path:
req := httptest.NewRequest(http.MethodDelete, "/notes/1", nil)
req.Header.Set("HX-Request", "true")
app.Do(req).AssertOK()
func (*App) Freeze ¶ added in v0.2.0
Freeze stops the app's clock at t (at the current time, for the zero time), in UTC and to the microsecond (as databases store times), and returns it. Everything that reads the time from the app's clock (anetos.Now, app.Now) sees it: model timestamps, the expiry of sessions, cookies, tokens, signed URLs and cached items, dates in validation rules, emails' Date. Time kept by database and Redis servers, and timeouts, aren't affected. The clock stays frozen until App.Unfreeze.
now := app.Freeze(time.Time{})
app.PostJSON("/posts", post)
anetostest.AssertDatabaseHas[models.Post](app, models.PostCols.CreatedAt.Eq(now))
func (*App) Mail ¶ added in v0.2.0
Mail returns the emails sent with mailer.Send and queued with mailer.Queue so far, oldest first, with their mailables. (The emails the transport got, queued ones the queue ran included, are the mailer's MemoryTransport's.)
func (*App) PostForm ¶
PostForm sends a POST request with an URL-encoded form, as a browser submitting a form. The session's CSRF token is sent in the X-CSRF-Token header, unless form has a "_token" field or the header is set.
func (*App) PostMultipart ¶ added in v0.2.0
PostMultipart sends a POST request with a multipart form, as a browser uploading files: the fields of form, then the files. The CSRF token is sent as with App.PostForm.
app.PostMultipart("/documents", nil, anetostest.Upload{Field: "file", Filename: "a.pdf", Content: pdf})
func (*App) Published ¶ added in v0.2.0
Published returns the messages published so far, oldest first.
func (*App) PutForm ¶
PutForm sends a PUT request with an URL-encoded form. Browsers send forms with POST only; to test a form using a "_method" field (see web.MethodOverride), use App.PostForm with the field.
func (*App) RepeatedQueries ¶ added in v0.2.0
func (a *App) RepeatedQueries() []db.RepeatedQuery
RepeatedQueries returns the queries a request (or job, listener…) of the test ran repeatedly (DB_REPEATED_QUERIES times or more, default 5 in tests), oldest first: each is an N+1 to fix. They are also logged as warnings.
func (*App) Session ¶
Session returns the session the next request will carry, to inspect. Changes to it are not saved; use App.WithSession.
func (*App) SocialSignIn ¶ added in v0.2.0
func (a *App) SocialSignIn(redirect string, acct SocialAccount) *Response
SocialSignIn signs in with acct through social login, as a browser would: it follows redirect (the app's route that sends users to the provider, "/auth/google/redirect", with ?remember=1 if you like), plays the provider's sign-in page for acct, and returns the app's answer to the callback: a redirect to the intended page or AUTH_HOME_URL, or back to the login page with a "social" error. It needs FakeSocial.
func (*App) Travel ¶ added in v0.2.0
Travel moves the app's clock by d (back, if d is negative): a frozen clock stays frozen at its new time; a running one keeps running, d ahead.
app.Travel(31 * time.Minute) // past the reset link's lifetime
func (*App) Unfreeze ¶ added in v0.2.0
func (a *App) Unfreeze()
Unfreeze returns the app's clock to the system's time.
func (*App) WithHeader ¶
WithHeader sets a header on every later request.
type Disk ¶ added in v0.2.0
type Disk struct {
// contains filtered or unexported fields
}
Disk checks the files of one of the app's disks. In tests, disks keep their files in memory (STORAGE_DRIVER=memory), so each test starts with empty disks, unless a named disk sets its own driver (STORAGE_<NAME>_DRIVER).
func (*Disk) AssertContent ¶ added in v0.2.0
AssertContent checks that the file exists with the content want.
func (*Disk) AssertExists ¶ added in v0.2.0
AssertExists checks that the files exist.
func (*Disk) AssertMissing ¶ added in v0.2.0
AssertMissing checks that the files don't exist.
type Option ¶
type Option func(*options)
Option configures New.
func FakeAI ¶ added in v0.3.0
FakeAI scripts the model's answers: each request to the app's AI client gets the next reply, in order (ai.FakeText, ai.FakeObject, ai.FakeToolCall, ai.FakeError), and no model is called: New sets AI_PROVIDER=fake, and FakeAI puts the fake in place of a provider an Env option chose. A request with no reply left fails. Several FakeAI options add up; App.AI adds more during the test.
app := anetostest.New(t, setup, anetostest.FakeAI(
ai.FakeToolCall("find_order", map[string]int{"number": 1042}),
ai.FakeText("Order 1042 shipped yesterday."),
))
func FakeEvents ¶ added in v0.2.0
FakeEvents keeps events of the types of the given values (all events, with none) from reaching their listeners (a pointer type and its value type are different event types): they are only recorded, for AssertEmitted and Events. Without it, events are recorded and delivered as usual.
app := anetostest.New(t, setup, anetostest.FakeEvents(OrderPlaced{}))
func FakePubSub ¶ added in v0.2.0
func FakePubSub() Option
FakePubSub keeps published messages from reaching the broker: they are only recorded, for AssertPublished and Messages. Without it, messages are recorded and published as usual.
func FakeQueue ¶ added in v0.2.0
func FakeQueue() Option
FakeQueue keeps dispatched jobs from running or being stored: they are only recorded, for AssertDispatched and Jobs. Without it, jobs are recorded and handled by the queue as usual (QUEUE_DRIVER: with sync, they run at once). Queued email and queued event listeners are jobs too.
func FakeSocial ¶ added in v0.2.0
func FakeSocial() Option
FakeSocial makes social login (package auth/social) sign in through a stand-in provider the test controls, for every provider the app has: App.SocialSignIn signs in with an account of your choosing. Every provider counts as configured (social.Configured), with test credentials when its SOCIAL_<NAME>_* settings are missing. Nothing reaches Google or GitHub.
app := anetostest.New(t, setup, anetostest.FakeSocial())
app.SocialSignIn("/auth/google/redirect", anetostest.SocialAccount{
ID: "g-1", Email: "ada@example.com", EmailVerified: true, Name: "Ada",
}).AssertRedirect("/dashboard")
func LogLevel ¶
LogLevel sets the minimum level of the app's logs, which go to the test's log (shown for failed tests and with -v). Default Info.
func WithoutMigrations ¶
func WithoutMigrations() Option
WithoutMigrations skips running the app's migrations.
func WithoutTransaction ¶
func WithoutTransaction() Option
WithoutTransaction lets the test's writes be committed (with PostgreSQL, MySQL or a SQLite file), for code that needs its own connections or AfterCommit callbacks. The test must clean up after itself.
type Response ¶
type Response struct {
StatusCode int // the status code
Header http.Header // the response headers
Body []byte // the whole body
Request *http.Request // the request that was sent
// contains filtered or unexported fields
}
Response is the app's response to a test request. Its assertion methods report failures with t.Errorf and return the response, so they chain:
app.Get("/posts").AssertOK().AssertSee("Hello")
func (*Response) AssertCreated ¶
AssertCreated checks for 201 Created.
func (*Response) AssertDontSee ¶
AssertDontSee checks that the body contains none of texts, as they are or HTML-escaped.
func (*Response) AssertForbidden ¶
AssertForbidden checks for 403 Forbidden.
func (*Response) AssertHeader ¶
AssertHeader checks that the response has the header with the value.
func (*Response) AssertJSON ¶
AssertJSON checks that the body is JSON equal to want (encoded as JSON to compare): a struct, a map or a slice.
res.AssertJSON(map[string]any{"id": 1, "title": "Hello"})
func (*Response) AssertJSONPath ¶
AssertJSONPath checks the value at path in the JSON body: keys and array indexes separated by dots, as in "data.0.title". want is compared after encoding it as JSON, so 1 matches 1.0 and a struct matches an object.
func (*Response) AssertNoContent ¶
AssertNoContent checks for 204 No Content.
func (*Response) AssertNoValidationErrors ¶
AssertNoValidationErrors checks that the request didn't fail validation: not a 422, no 400 with field errors, and no errors flashed to the session.
func (*Response) AssertNotFound ¶
AssertNotFound checks for 404 Not Found.
func (*Response) AssertRedirect ¶
AssertRedirect checks for a redirect (3xx) to path, such as "/posts" or "/posts?page=2".
func (*Response) AssertRedirectRoute ¶
AssertRedirectRoute checks for a redirect to the named route, with args as for web.Router.URL.
func (*Response) AssertSee ¶
AssertSee checks that the body contains each text, as it is or HTML-escaped as templates write it: AssertSee(`"Go" & you`) also finds ""Go" & you". For JSON, use Response.AssertJSONPath.
func (*Response) AssertSessionHas ¶
AssertSessionHas checks that the session the next request will carry has key and, if a value is given, that it holds that value (compared as JSON).
res.AssertSessionHas("status", "Post created.")
func (*Response) AssertSessionMissing ¶
AssertSessionMissing checks that the session the next request will carry doesn't have key.
func (*Response) AssertStatus ¶
AssertStatus checks the status code.
func (*Response) AssertUnprocessable ¶
AssertUnprocessable checks for 422 Unprocessable Content, the status of failed validation for API clients.
func (*Response) AssertValidationErrors ¶
AssertValidationErrors checks that validation failed with an error for each of fields (the form's or the JSON body's field names; none: any error). For an API client, the response must be a 422 (or 400) problem with the fields in its "errors" member; for a browser's form, a redirect back with the errors flashed to the session.
func (*Response) Follow ¶
Follow sends a GET request to the redirect's Location, as a browser does. The response must be a redirect.
type SocialAccount ¶ added in v0.2.0
type SocialAccount struct {
// ID is the account's identifier at the provider (the profile's
// Subject). Required.
ID string
// Email is the account's address.
Email string
// EmailVerified says the provider verified Email.
EmailVerified bool
// Name is the account's display name.
Name string
// AvatarURL is the account's picture.
AvatarURL string
}
SocialAccount is the account a test signs in with at the stand-in provider (App.SocialSignIn): the profile the app's resolver gets.
type Upload ¶ added in v0.2.0
type Upload struct {
// Field is the form field's name.
Field string
// Filename is the file's name, as the client sends it.
Filename string
// Content is the file's content.
Content []byte
// ContentType is the part's media type. Default
// application/octet-stream.
ContentType string
}
Upload is a file for App.PostMultipart.