Testing
Test against a real database. ant has no in-memory fake, on purpose: a fake accepts SQL the real server would refuse, and hides what constraints, triggers, and transactions actually do. PostgreSQL in a container starts in seconds anyway.
A database for tests
Build the schema from your real migrations, so a broken migration fails a test before it reaches production.
var shared *models.AppDB
func TestMain(m *testing.M) {
ctx := context.Background()
db, err := ant.Open[models.AppDB](ctx, postgres.Dialect, os.Getenv("TEST_DATABASE_URL"))
if err != nil {
log.Fatal(err)
}
if err := migrate.Up(ctx, db, migrate.Config{FS: migrations.Files}); err != nil {
log.Fatal(err)
}
shared = db
os.Exit(m.Run())
}
For quick experiments, db.EnsureCreated(ctx) builds tables straight from the structs.
Isolating tests
anttest.Isolate gives a test a context bound to a transaction that rolls back when the test ends, so tests can share a database and never need cleaning up after.
func TestSignup(t *testing.T) {
db := anttest.Isolate(t, shared)
err := db.Users.Insert(t.Context(), &models.User{Name: "Ann", Email: "ann@example.com"})
if err != nil {
t.Fatal(err)
}
}
A test of concurrency needs a schema of its own instead, because other connections can't see an open transaction.
Factories
A factory makes valid entities, so each test only spells out what it cares about. The number it passes in keeps values unique.
var Users = anttest.NewFactory(func(n int) *models.User {
return &models.User{
Name: fmt.Sprintf("user %d", n),
Email: fmt.Sprintf("u%d@example.com", n),
}
})
func TestFactories(t *testing.T) {
db := anttest.Isolate(t, shared)
user := Users.Create(t, &db.Users) // in the database
draft := Users.Build() // in memory
many := Users.CreateMany(t, &db.Users, 10) // several
young := Users.Create(t, &db.Users, func(u *models.User) { u.Age = 5 }) // one override
// A state is a factory, and leaves the original alone.
admin := Users.With(func(u *models.User) { u.Admin = true })
boss := admin.Create(t, &db.Users)
}
AfterBuild and AfterCreate add callbacks. anttest.Recycle shares one parent among many children.
func TestRecycle(t *testing.T) {
db := anttest.Isolate(t, shared)
author := anttest.Recycle(Users, &db.Users)
posts := anttest.NewFactory(func(n int) *models.Post {
return &models.Post{Title: fmt.Sprintf("post %d", n), UserID: author.Get(t).ID}
})
posts.CreateMany(t, &db.Posts, 3) // one author
}
Fixtures
anttest.Load loads rows from JSON files named after their tables, for data that doesn't change. Rows go in as written, with no hooks, so parents come first.
//go:embed testdata
var data embed.FS
func TestWithFixtures(t *testing.T) {
db := anttest.Isolate(t, shared)
anttest.Load(t, db, data, "testdata/users.json", "testdata/posts.json")
}
Checking what code does
q.ToSQL() returns a query's SQL with no database, which suits a test of a query builder. For anything else, run it: what matters is what the database returns. ant.DryRun shows what a unit of work decides to write, and antvet catches mistakes before tests run.
SQLite is tempting for speed, but it has no FOR UPDATE, no enum types, different date handling, and looser typing, so a test can pass there and fail in production. Test on the database you actually deploy.
See it tested: tests/features/testing