Skip to content

Testing

Set entrest.Config.WithTesting to true at generation time and entrest adds request helpers to your existing <ent>/enttest package. They wrap httptest around the generated handler so you can exercise real routes, query params, and JSON bodies against an in-memory database.

WithTesting is ignored when Handler is HandlerNone — there is no handler to test.

entc.go
ex, err := entrest.NewExtension(&entrest.Config{
Handler: entrest.HandlerStdlib,
WithTesting: true,
})
if err != nil {
log.Fatal(err)
}
err = entc.Generate("./schema", &gen.Config{
Target: "./ent",
Package: "example.com/app/ent",
}, entc.Extensions(ex))

Run go generate as usual. The helpers land in ent/enttest alongside Ent's own enttest.Open helpers.

After enabling WithTesting, a complete test looks like this:

handler_test.go
func TestGetPet(t *testing.T) {
ctx := context.Background()
db := enttest.Open(t, "sqlite3", "file:ent?mode=memory&cache=shared&_pragma=foreign_keys(1)")
t.Cleanup(func() { db.Close() })
srv := enttest.NewServer(t, db, nil)
pet := db.Pet.Create().SetName("Riley").SetAge(3).SaveX(ctx)
resp := srv.Request[ent.Pet](ctx, http.MethodGet, "/pets/"+strconv.Itoa(pet.ID), nil).Must(t)
assert.Equal(t, pet.ID, resp.Value.ID)
}

Paths are relative to the handler root — with the stdlib handler, BasePath stripping is applied for you, so use /pets rather than /v1/pets when BasePath is /v1.

For shared setup across tests, extract a helper:

handler_test.go
func newTestServer(t *testing.T, cfg *rest.ServerConfig) (context.Context, *ent.Client, *enttest.TestServer) {
t.Helper()
ctx := context.Background()
db := enttest.Open(t, "sqlite3", "file:ent?mode=memory&cache=shared&_pragma=foreign_keys(1)")
t.Cleanup(func() { db.Close() })
srv := enttest.NewServer(t, db, cfg)
return ctx, db, srv
}

NewServer calls rest.NewServer and serves the returned handler. Pass nil for ServerConfig when defaults are fine — see Runtime Configuration.

Seed data through the Ent client (db.Pet.Create()...SaveX(ctx)), then hit the API with srv.Request.

Request is generic over the expected JSON response type:

handler_test.go
ctx, db, srv := newTestServer(t, nil)
pet := db.Pet.Create().SetName("Riley").SetAge(3).SaveX(ctx)
// GET a single entity.
resp := srv.Request[ent.Pet](ctx, http.MethodGet, "/pets/"+strconv.Itoa(pet.ID), nil).Must(t)
assert.Equal(t, http.StatusOK, resp.Data.Code)
assert.Equal(t, pet.ID, resp.Value.ID)
// POST with a JSON body (Content-Type is set automatically).
created := srv.Request[ent.Pet](ctx, http.MethodPost, "/pets", map[string]any{
"name": "Orea",
"age": 2,
}).Must(t)
assert.Equal(t, http.StatusCreated, created.Data.Code)

Arguments:

ArgumentPurpose
ctxRequest context — forwarded to httptest.NewRequest
methodHTTP method (http.MethodGet, http.MethodPost, …)
pathPath relative to the handler (no host)
bodyJSON-marshalable value, nil, or http.NoBody

Use http.NoBody on GET/DELETE when you want an explicit empty body instead of nil.

List endpoints decode into rest.PagedResponse[T]:

handler_test.go
resp := srv.Request[rest.PagedResponse[ent.Pet]](
ctx, http.MethodGet, "/pets?page=2&per_page=10", nil,
).Must(t)
assert.Equal(t, 2, resp.Value.Page)
assert.Len(t, resp.Value.Content, 10)

See Pagination for the response fields and query parameters.

Pass rest.PagedResponse[ent.Pet] (or any type) for successful 204 No Content responses — the body is not decoded. For deletes where you only care about the status, Request[string] also works.

Response[T] holds three fields:

FieldContents
Data*httptest.ResponseRecorder — status, headers, raw body
ValueUnmarshalled JSON on 2xx (except 204)
Error*rest.ErrorResponse on non-2xx

Call .Must(t) when you expect success — it fails the test if Error is set:

handler_test.go
resp := srv.Request[ent.Pet](ctx, http.MethodGet, "/pets/99999", nil)
assert.Equal(t, http.StatusNotFound, resp.Data.Code)
require.NotNil(t, resp.Error)
assert.Equal(t, http.StatusNotFound, resp.Error.Code)

Skip .Must(t) whenever you are asserting on a specific error status. The error shape matches Configuration — Error handling.

If your production server wraps the generated handler in middleware, use enttest.WithExisting instead of NewServer:

handler_test.go
srv, err := rest.NewServer(db, &rest.ServerConfig{MaxRequestBodyBytes: 100})
require.NoError(t, err)
ts := enttest.WithExisting(t, srv.Handler())
resp := ts.Request[ent.User](ctx, http.MethodPost, "/users", strings.Repeat("x", 101))
require.NotNil(t, resp.Error)
assert.Equal(t, http.StatusRequestEntityTooLarge, resp.Data.Code)

Build the same router you use in production and pass it to WithExisting. Set BasePath on ServerConfig to match your mount point, and include the mount prefix in test paths:

handler_test.go
cfg := &rest.ServerConfig{BasePath: "/v1"}
srv, err := rest.NewServer(db, cfg)
require.NoError(t, err)
r := chi.NewRouter()
r.Route("/v1", func(r chi.Router) {
srv.Handler(r)
})
ts := enttest.WithExisting(t, r)
resp := ts.Request[rest.PagedResponse[ent.Pet]](ctx, http.MethodGet, "/v1/pets", nil).Must(t)
assert.Equal(t, http.StatusOK, resp.Data.Code)

See Configuration — Stdlib vs chi for mount setup.

enttest.Multiple runs a creator function n times and returns the results. Useful with CreateBulk:

handler_test.go
pets := db.Pet.CreateBulk(enttest.Multiple(newPet, db, 25)...).SaveX(ctx)
_ = pets // use in assertions

where newPet returns *ent.PetCreate:

handler_test.go
func newPet(db *ent.Client) *ent.PetCreate {
return db.Pet.Create().SetName("test").SetAge(1)
}

Chain WithLogResponses(true) to print method, path, status, and body for every request:

handler_test.go
srv := enttest.NewServer(t, db, nil).WithLogResponses(true)
llms.txtdocumentation for LLMs