Ent Testing With Enttest and Real Databases in Go Tests
Test Ent code with enttest on in memory SQLite and on PostgreSQL with a schema per test, check atomic writes, foreign keys and parallel isolation

Ent has a head start in testing: the generator writes a package for it. enttest opens a client, runs the migration and hands you a database that matches your schema, in a function call. What it does not decide is which database to test on, how to isolate tests from each other, or what a test of generated code should check. This article answers those on PostgreSQL 18.6 and in memory SQLite, with a small service under test and the output of the run.
Tested with Go 1.25.6, Ent v0.14.5, pgx v5.11.0, modernc.org/sqlite v1.50.0, PostgreSQL 18.6 in Docker, macOS on Apple Silicon (arm64)
It follows the Ent tutorial. The GORM version of the same strategy is GORM testing, and the general techniques are in testing with SQLite, transaction rollback in tests and testcontainers.
What to test
Do not test Ent. Test the code that uses it: the decisions a service makes, the transactions it opens, the errors it maps. A Library type in the lab does three things worth testing: it creates an author and a book in one transaction, it enforces a publishing rule (a book longer than 1000 pages cannot be published) and it lists published titles.
package lab
import (
"context"
"errors"
"fmt"
"example.test/lab/ent"
"example.test/lab/ent/author"
"example.test/lab/ent/book"
)
var ErrTooLong = errors.New("book is too long to publish")
type Library struct{ client *ent.Client }
func NewLibrary(c *ent.Client) *Library { return &Library{c} }
// AddBook finds or creates the author and files the book, all or nothing.
func (l *Library) AddBook(ctx context.Context, email, name, title string, pages int) (*ent.Book, error) {
tx, err := l.client.Tx(ctx)
if err != nil {
return nil, err
}
a, err := tx.Author.Query().Where(author.Email(email)).Only(ctx)
if ent.IsNotFound(err) {
a, err = tx.Author.Create().SetEmail(email).SetName(name).Save(ctx)
}
if err != nil {
return nil, rollback(tx, err)
}
b, err := tx.Book.Create().SetTitle(title).SetPages(pages).SetAuthor(a).Save(ctx)
if err != nil {
return nil, rollback(tx, err)
}
return b, tx.Commit()
}
func rollback(tx *ent.Tx, err error) error {
if rerr := tx.Rollback(); rerr != nil {
err = fmt.Errorf("%w: %v", err, rerr)
}
return err
}
// Publish marks a book published unless it is longer than 1000 pages.
func (l *Library) Publish(ctx context.Context, id int) error {
b, err := l.client.Book.Get(ctx, id)
if err != nil {
return err
}
if b.Pages > 1000 {
return ErrTooLong
}
return l.client.Book.UpdateOne(b).SetPublished(true).Exec(ctx)
}
func (l *Library) PublishedTitles(ctx context.Context) ([]string, error) {
return l.client.Book.Query().Where(book.Published(true)).Order(ent.Asc(book.FieldTitle)).Select(book.FieldTitle).Strings(ctx)
}AddBook begins a transaction, finds or creates the author, creates the book and commits, rolling back on any error. That is the unit of work whose atomicity the tests check.
The test helpers
The package enttest is generated next to the client. enttest.NewClient(t, opts...) opens a client from options, auto migrates the schema, and fails the test on error. The options that matter:
enttest.WithOptions(ent.Driver(drv))supplies the driver. Usingent.Driverwith your own*sql.DBworks with any engine and driver, whichenttest.Open(t, "sqlite3", dsn)does not (it accepts only the driver names Ent knows).enttest.WithMigrateOptions(...)passes migration options such as dropping columns.
Two helpers cover the two engines:
package lab
import (
"database/sql"
"errors"
"fmt"
"sync/atomic"
"testing"
"entgo.io/ent/dialect"
entsql "entgo.io/ent/dialect/sql"
"example.test/lab/ent"
"example.test/lab/ent/enttest"
_ "github.com/jackc/pgx/v5/stdlib"
_ "modernc.org/sqlite"
)
var n atomic.Int64
// sqliteClient: an in memory database per test, migrated by enttest.
func sqliteClient(t *testing.T) *ent.Client {
t.Helper()
dsn := fmt.Sprintf("file:t%d?mode=memory&cache=shared&_pragma=foreign_keys(1)", n.Add(1))
db, err := sql.Open("sqlite", dsn)
if err != nil {
t.Fatal(err)
}
c := enttest.NewClient(t, enttest.WithOptions(ent.Driver(entsql.OpenDB(dialect.SQLite, db))))
t.Cleanup(func() { c.Close() })
return c
}
// pgClient: a schema per test on a shared PostgreSQL, dropped afterwards.
func pgClient(t *testing.T) *ent.Client {
t.Helper()
base := "postgres://postgres:[email protected]:55432/lab?sslmode=disable"
admin, err := sql.Open("pgx", base)
if err != nil {
t.Fatal(err)
}
schema := fmt.Sprintf("l0748_t%d", n.Add(1))
admin.Exec("create schema " + schema)
t.Cleanup(func() { admin.Exec("drop schema " + schema + " cascade"); admin.Close() })
db, err := sql.Open("pgx", base+"&search_path="+schema)
if err != nil {
t.Fatal(err)
}
c := enttest.NewClient(t, enttest.WithOptions(ent.Driver(entsql.OpenDB(dialect.Postgres, db))))
t.Cleanup(func() { c.Close() })
return c
}
func TestAddBookIsAtomic(t *testing.T) {
for name, mk := range map[string]func(*testing.T) *ent.Client{"sqlite": sqliteClient, "postgres": pgClient} {
t.Run(name, func(t *testing.T) {
c := mk(t)
lib := NewLibrary(c)
ctx := t.Context()
if _, err := lib.AddBook(ctx, "[email protected]", "Ada", "Go in Practice", 300); err != nil {
t.Fatal(err)
}
// A second book with the same title for the same author violates the unique index.
_, err := lib.AddBook(ctx, "[email protected]", "Ada", "Go in Practice", 10)
t.Logf("%s: duplicate title is a constraint error: %v", name, ent.IsConstraintError(err))
if !ent.IsConstraintError(err) {
t.Fatal("expected a constraint error")
}
// A failing validator must not leave a half created author behind.
_, err = lib.AddBook(ctx, "[email protected]", "New", "", 10)
t.Logf("%s: empty title is a validation error: %v", name, ent.IsValidationError(err))
if got := c.Author.Query().CountX(ctx); got != 1 {
t.Fatalf("%s: %d authors, the failed request left one behind", name, got)
}
})
}
}
func TestPublishRules(t *testing.T) {
c := sqliteClient(t)
lib := NewLibrary(c)
ctx := t.Context()
short, _ := lib.AddBook(ctx, "[email protected]", "A", "Short", 100)
long, _ := lib.AddBook(ctx, "[email protected]", "A", "Long", 2000)
if err := lib.Publish(ctx, short.ID); err != nil {
t.Fatal(err)
}
err := lib.Publish(ctx, long.ID)
t.Logf("publishing a 2000 page book: %v", err)
if !errors.Is(err, ErrTooLong) {
t.Fatal("expected ErrTooLong")
}
err = lib.Publish(ctx, 9999)
t.Logf("publishing a missing book is NotFound: %v", ent.IsNotFound(err))
titles, _ := lib.PublishedTitles(ctx)
t.Logf("published: %v", titles)
}
func TestParallelSchemas(t *testing.T) {
for i := 0; i < 4; i++ {
t.Run(fmt.Sprintf("p%d", i), func(t *testing.T) {
t.Parallel()
c := pgClient(t)
NewLibrary(c).AddBook(t.Context(), "[email protected]", "Same", "Same title", 10)
if got := c.Book.Query().CountX(t.Context()); got != 1 {
t.Fatalf("saw %d books: tests share state", got)
}
})
}
}
func TestSQLiteForeignKeysAreOn(t *testing.T) {
c := sqliteClient(t)
_, err := c.Book.Create().SetTitle("Orphan").SetPages(1).SetAuthorID(4242).Save(t.Context())
t.Logf("book for a missing author: constraint error %v", ent.IsConstraintError(err))
if err == nil {
t.Fatal("foreign keys are not enforced")
}
}=== RUN TestAddBookIsAtomic
=== RUN TestAddBookIsAtomic/sqlite
library_test.go:64: sqlite: duplicate title is a constraint error: true
library_test.go:70: sqlite: empty title is a validation error: true
=== RUN TestAddBookIsAtomic/postgres
library_test.go:64: postgres: duplicate title is a constraint error: true
library_test.go:70: postgres: empty title is a validation error: true
--- PASS: TestAddBookIsAtomic (0.10s)
--- PASS: TestAddBookIsAtomic/sqlite (0.02s)
--- PASS: TestAddBookIsAtomic/postgres (0.08s)
=== RUN TestPublishRules
library_test.go:88: publishing a 2000 page book: book is too long to publish
library_test.go:93: publishing a missing book is NotFound: true
library_test.go:95: published: [Short]
--- PASS: TestPublishRules (0.01s)
=== RUN TestParallelSchemas
=== RUN TestParallelSchemas/p0
=== PAUSE TestParallelSchemas/p0
=== RUN TestParallelSchemas/p1
=== PAUSE TestParallelSchemas/p1
=== RUN TestParallelSchemas/p2
=== PAUSE TestParallelSchemas/p2
=== RUN TestParallelSchemas/p3
=== PAUSE TestParallelSchemas/p3
=== CONT TestParallelSchemas/p0
=== CONT TestParallelSchemas/p1
=== CONT TestParallelSchemas/p3
=== CONT TestParallelSchemas/p2
--- PASS: TestParallelSchemas (0.00s)
--- PASS: TestParallelSchemas/p1 (0.07s)
--- PASS: TestParallelSchemas/p0 (0.07s)
--- PASS: TestParallelSchemas/p3 (0.08s)
--- PASS: TestParallelSchemas/p2 (0.08s)
=== RUN TestSQLiteForeignKeysAreOn
library_test.go:114: book for a missing author: constraint error true
--- PASS: TestSQLiteForeignKeysAreOn (0.01s)
PASS
ok example.test/lab 1.804ssqliteClient opens an in memory database named per test, with cache=shared so every connection in the pool sees the same data, and _pragma=foreign_keys(1) so foreign keys are enforced. pgClient creates a schema named for the test on a shared server, points search_path at it with the DSN and drops it in t.Cleanup. Both register c.Close() in cleanup, and neither leaves anything behind.
The schema files
The tests run against the author and book schemas from the tutorial:
package schema
import (
"time"
"entgo.io/ent"
"entgo.io/ent/schema/edge"
"entgo.io/ent/schema/field"
)
type Author struct{ ent.Schema }
func (Author) Fields() []ent.Field {
return []ent.Field{
field.String("name").NotEmpty(),
field.String("email").Unique(),
field.Time("created_at").Default(time.Now).Immutable(),
}
}
func (Author) Edges() []ent.Edge { return []ent.Edge{edge.To("books", Book.Type)} }package schema
import (
"entgo.io/ent"
"entgo.io/ent/schema/edge"
"entgo.io/ent/schema/field"
"entgo.io/ent/schema/index"
)
type Book struct{ ent.Schema }
func (Book) Fields() []ent.Field {
return []ent.Field{
field.String("title").NotEmpty(),
field.Int("pages").Positive(),
field.Bool("published").Default(false),
}
}
func (Book) Edges() []ent.Edge {
return []ent.Edge{edge.From("author", Author.Type).Ref("books").Unique().Required()}
}
func (Book) Indexes() []ent.Index { return []ent.Index{index.Fields("title").Edges("author").Unique()} }package ent
//go:generate go run -mod=mod entgo.io/ent/cmd/ent generate ./schemaset -e
GOFLAGS=-mod=mod go generate ./ent
go mod tidy > /dev/null 2>&1
go test -count=1 -race -v . 2>&1 | grep -vE "^(=== |--- PASS: Test.*/)" library_test.go:64: sqlite: duplicate title is a constraint error: true
library_test.go:70: sqlite: empty title is a validation error: true
library_test.go:64: postgres: duplicate title is a constraint error: true
library_test.go:70: postgres: empty title is a validation error: true
--- PASS: TestAddBookIsAtomic (0.09s)
--- PASS: TestAddBookIsAtomic/sqlite (0.02s)
--- PASS: TestAddBookIsAtomic/postgres (0.08s)
library_test.go:88: publishing a 2000 page book: book is too long to publish
library_test.go:93: publishing a missing book is NotFound: true
library_test.go:95: published: [Short]
--- PASS: TestPublishRules (0.01s)
--- PASS: TestParallelSchemas (0.00s)
--- PASS: TestParallelSchemas/p2 (0.08s)
--- PASS: TestParallelSchemas/p1 (0.09s)
--- PASS: TestParallelSchemas/p3 (0.09s)
--- PASS: TestParallelSchemas/p0 (0.09s)
library_test.go:114: book for a missing author: constraint error true
--- PASS: TestSQLiteForeignKeysAreOn (0.01s)
PASS
ok example.test/lab 1.746sReading the run
One test, two engines. TestAddBookIsAtomic loops over both helpers. On each, a second AddBook with the same title and author failed with a constraint error (sqlite: duplicate title is a constraint error: true, then postgres: ...), and a call with an empty title failed Ent's validator (empty title is a validation error: true). After that failed call, the test counts authors and expects 1: the request for a brand new author had created one inside the transaction and the validation failure rolled it back. If AddBook used the client instead of tx, the extra author would remain and the count would be 2. That is a test worth having for every transactional method.
Running the same assertion on both engines is useful in the opposite direction too: where they disagree, the test shows which behaviour is engine specific.
Domain rules. TestPublishRules created a short and a long book and published both. The short one succeeded, the long one returned ErrTooLong, a missing id returned NotFound (ent.IsNotFound(err)), and the published list was [Short]. This is plain Go logic over the client, tested through the real database, which is the shape most Ent tests take. It ran on SQLite because it depends on nothing engine specific, which is the good use of the fast engine.
Isolation. TestParallelSchemas starts four t.Parallel() subtests. Each calls pgClient, gets its own schema, adds a book with the same author email and title, and counts books. Each saw exactly 1. Had they shared a schema, the unique index on (title, author) would have failed three of them. This is the property that lets a suite grow without a global ordering.
Foreign keys. TestSQLiteForeignKeysAreOn created a book pointing at author 4242 and got a constraint error: book for a missing author: constraint error true. That passes only because the helper enables the pragma. SQLite ignores foreign keys by default, and a helper without it would accept the orphan. Put the pragma in the one helper every test uses.
Speed. The package ran in 1.75 s with -race: 4 top level tests and 7 subtests, with PostgreSQL schema creation and migration per test. The cost of a schema per test is tens of milliseconds, which is why a real database does not slow the suite. Container startup (if you use testcontainers) is paid once per package in TestMain.
SQLite or PostgreSQL
| Use SQLite when | Use PostgreSQL when |
|---|---|
| The code under test only does CRUD and relations | The code relies on constraints, JSON, locking or upserts |
| You need the fastest feedback loop | Production runs PostgreSQL |
| CI has no database service | Behaviour differs by engine (case, time precision, transactions) |
The differences between engines are listed in Ent database support. A reasonable split is: unit-ish tests on SQLite for speed, and an integration package on PostgreSQL that runs in CI with a service container. The same helper signature (func(*testing.T) *ent.Client) lets one test body run on both.
More things to test
- Hooks and privacy. Test them through the client: a rejected write should return the hook's error and leave no row; a viewer must not read another viewer's data (privacy).
- Generated code drift. A CI step that runs
go generate ./... && git diff --exit-codeguards against a stale client (workflow). - Migrations. If you use versioned migrations, apply them to an empty database in a test and diff against the schema.
- Statement counts. Wrap the driver to count queries and assert a bound for list endpoints, to catch N plus one (queries).
Mistakes
- Sharing one database across tests and relying on cleanup. Use a schema or database per test.
- Testing only on SQLite when production is PostgreSQL.
- Forgetting
t.Cleanup(c.Close), which leaks connections and eventually exhausts the pool. - Forgetting the foreign key pragma on SQLite.
- Using
enttest.Openwith a driver name Ent does not know. UseNewClientwithent.Driver.
Checklist
- Test your code, through the real client, not Ent itself.
- Use
enttest.NewClientwithent.Driverand register cleanup. - Isolate with a schema per test on PostgreSQL and a named memory database on SQLite.
- Run the transactional tests on both engines when you can.
- Count statements for list paths, and check generated code is current in CI.
The package is described in the Ent testing guide, and the SQLite pragma in the SQLite foreign key documentation.


