Skip to content
gohacks.dev
Databases

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

11 min read
Summarize in AI
Flat illustration of a database beside the table it holds.

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.

library.goGo
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. Using ent.Driver with your own *sql.DB works with any engine and driver, which enttest.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:

library_test.goGo
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")
	}
}
Text
=== 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.804s

sqliteClient 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:

ent/schema/author.goGo
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)} }
ent/schema/book.goGo
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()} }
ent/generate.goGo
package ent

//go:generate go run -mod=mod entgo.io/ent/cmd/ent generate ./schema
Shell
set -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.*/)" 
Text
    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.746s

Reading 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 whenUse PostgreSQL when
The code under test only does CRUD and relationsThe code relies on constraints, JSON, locking or upserts
You need the fastest feedback loopProduction runs PostgreSQL
CI has no database serviceBehaviour 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-code guards 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.Open with a driver name Ent does not know. Use NewClient with ent.Driver.

Checklist

  • Test your code, through the real client, not Ent itself.
  • Use enttest.NewClient with ent.Driver and 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.

Also inTesting
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.