Test factories
Tests need data, and building that data inline gets tedious fast. Every test that needs a user has to spell out a name, an email, an age, and whatever else the schema requires — even when the test cares about only one of those fields. Factories fix this: a factory knows the boring defaults so each test can state only the interesting differences.
This guide ports Ecto’s “Test factories” guide. The running example is the
same: a User and a Post, a factory that builds them with sensible defaults,
overrides that change a field or two, and a small set of conveniences for
inserting and for keeping unique fields unique. The shape of the solution is
quite different from Elixir’s, though — and the differences are the interesting
part, so we will call them out as we go.
In Ecto, a factory is a module of macro-flavored functions (build/2,
insert!/2, params_for/2) that pattern-match on an atom naming the schema
and merge an attribute map of overrides. lode has no macros and no
runtime reflection (see DESIGN.md in the repository), so a factory here is
just a module of ordinary functions returning ordinary records. There is no DSL
to learn: the language already gives us defaults (a plain constructor) and
overrides (record-update syntax).
The schemas
We will reuse two small schemas. They are plain values, exactly as in Testing with Lode:
import lode/association
import lode/schema.{type Schema}
import lode/types/primitive
import lode/value.{VInt, VString}
import gleam/dict
import gleam/result
pub type User {
User(id: Int, name: String, email: String, age: Int)
}
pub type Post {
Post(id: Int, title: String, body: String, author_id: Int)
}
pub fn user_schema() -> Schema(User) {
schema.new(
source: "users",
fields: [
schema.primary_key(name: "id", of: primitive.id()),
schema.field(name: "name", of: primitive.string()),
schema.field(name: "email", of: primitive.string()),
schema.field(name: "age", of: primitive.integer()),
],
load: fn(row) {
use id <- result.try(schema.require_int(row, "id"))
use name <- result.try(schema.require_string(row, "name"))
use email <- result.try(schema.require_string(row, "email"))
use age <- result.try(schema.require_int(row, "age"))
Ok(User(id:, name:, email:, age:))
},
dump: fn(u: User) {
dict.from_list([
#("id", VInt(u.id)),
#("name", VString(u.name)),
#("email", VString(u.email)),
#("age", VInt(u.age)),
])
},
)
}
pub fn post_schema() -> Schema(Post) {
schema.new(
source: "posts",
fields: [
schema.primary_key(name: "id", of: primitive.id()),
schema.field(name: "title", of: primitive.string()),
schema.field(name: "body", of: primitive.string()),
schema.field(name: "author_id", of: primitive.integer()),
],
load: fn(row) {
use id <- result.try(schema.require_int(row, "id"))
use title <- result.try(schema.require_string(row, "title"))
use body <- result.try(schema.require_string(row, "body"))
use author_id <- result.try(schema.require_int(row, "author_id"))
Ok(Post(id:, title:, body:, author_id:))
},
dump: fn(p: Post) {
dict.from_list([
#("id", VInt(p.id)),
#("title", VString(p.title)),
#("body", VString(p.body)),
#("author_id", VInt(p.author_id)),
])
},
)
}
A factory is plain functions
Ecto’s guide defines one build/2 head per schema:
def build(:user) do
%MyApp.User{name: "Jane Smith", email: sequence(:email, &"email-#{&1}@example.com")}
end
def build(factory_name, attributes) do
factory_name |> build() |> struct!(attributes)
end
Two things are doing work there: the atom :user selects which struct to
build, and struct!/2 merges a map of overrides onto the result. In Gleam we
don’t need either. The “which struct” question is answered by which function
you call, and the merge is record-update syntax baked into the language. So
the whole build/2 machinery collapses into one plain constructor per schema:
pub fn user_factory() -> User {
User(id: 0, name: "Jane Smith", email: "jane@example.com", age: 30)
}
pub fn post_factory() -> Post {
Post(id: 0, title: "My post", body: "Some content", author_id: 0)
}
The id: 0 placeholder stands in until the row is inserted; the database
assigns the real key, because id is the autogenerated primary key. (If you
build a row only to assert on its in-memory shape, 0 is a fine sentinel; if
you insert it, the stored row comes back with a real id.)
To override a field, use record-update syntax — this is lode’s answer to
Ecto’s build(:user, name: "Other"):
let alice = User(..user_factory(), name: "Alice", age: 42)
That single expression is the override merge. Every field you don’t mention keeps its factory default; the ones you do mention win. There is no merge function to write and no attribute map to thread through, because the compiler checks the field names and types for you.
This is a design re-expression, not a missing feature. Ecto needs
build/2+struct!/2because a runtime attribute map has to be reconciled with a struct; Gleam’s record-update syntax does that reconciliation at compile time, so the factory is simpler and safer here. SeeDESIGN.mdin the repository for the no-macros stance.
Inserting: there is no insert!
Ecto’s factory also offers insert!/2, which builds a struct and persists it,
raising on failure:
def insert!(factory_name, attributes \\ []) do
factory_name |> build(attributes) |> MyApp.Repo.insert!()
end
lode has no bang variants — every repo call returns a Result, and there
is no globally registered repo to call into, so the repo is an explicit
argument (again, see DESIGN.md). A factory insert is therefore a function
that takes a Repo, builds a changeset, and hands back whatever repo.insert
returns:
import lode/changeset
import lode/error.{type LodeError}
import lode/repo.{type Repo}
pub fn insert_user(r: Repo, build: fn(User) -> User) -> Result(User, LodeError) {
let user = build(user_factory())
let cs =
changeset.change(user, user_schema())
|> changeset.put_change(field.field("name"), VString(user.name))
|> changeset.put_change(field.field("email"), VString(user.email))
|> changeset.put_change(field.field("age"), VInt(user.age))
repo.insert(repo: r, schema: user_schema(), changeset: cs)
}
The build argument is the override hook. A test that doesn’t care about any
field passes the identity function; a test that does passes a record update:
// All defaults:
let assert Ok(user) = insert_user(r, fn(u) { u })
// Override two fields:
let assert Ok(adult) = insert_user(r, fn(u) { User(..u, name: "Alice", age: 42) })
Because insert_user returns a Result, you decide per call how strict to be.
In a test, let assert Ok(user) = ... plays the role of Ecto’s insert! — it
turns a failed insert into a panic with a useful message, which is exactly what
a bang function does. Outside a test you would pattern-match the Error and
handle it. The bang is recovered as a one-line assertion at the call site
rather than baked into the factory.
If you prefer the attribute-map flavor of Ecto’s factory (handy when overrides
arrive as data rather than as code), build the changeset with changeset.cast
over a params Dict instead — the factory then supplies defaults and the
caller supplies a partial map:
import gleam/dict.{type Dict}
pub fn insert_user_with(
r: Repo,
overrides: Dict(String, value.Value),
) -> Result(User, LodeError) {
let defaults =
dict.from_list([
#("name", VString("Jane Smith")),
#("email", VString("jane@example.com")),
#("age", VInt(30)),
])
let params = dict.merge(defaults, overrides)
let cs =
changeset.cast(
data: User(id: 0, name: "", email: "", age: 0),
schema: user_schema(),
params: params,
permitted: field.fields(["name", "email", "age"]),
)
|> changeset.validate_required(field.fields(["name", "email"]))
repo.insert(repo: r, schema: user_schema(), changeset: cs)
}
dict.merge(defaults, overrides) is the literal counterpart of Ecto’s
struct!(build(...), attributes): later keys win, so any field present in
overrides replaces the default. Use whichever style fits — record updates
when overrides are known at compile time, the params dict when they are data.
Associated data
Ecto’s guide builds a post whose author is itself produced by the user factory:
def build(:post) do
%MyApp.Post{title: "My post", user: build(:user)}
end
Because factories are just functions, “build an associated record” is an ordinary function call. We insert the parent first to obtain its real id, then point the child at it:
pub fn insert_post(
r: Repo,
build: fn(Post) -> Post,
) -> Result(Post, LodeError) {
use author <- result.try(insert_user(r, fn(u) { u }))
let post = build(Post(..post_factory(), author_id: author.id))
let cs =
changeset.change(post, post_schema())
|> changeset.put_change(field.field("title"), VString(post.title))
|> changeset.put_change(field.field("body"), VString(post.body))
|> changeset.put_change(field.field("author_id"), VInt(post.author_id))
repo.insert(repo: r, schema: post_schema(), changeset: cs)
}
If a test already has an author it wants to reuse, let it override author_id
through the build hook so no second user is created:
let assert Ok(post) =
insert_post(r, fn(p) { Post(..p, author_id: existing.id) })
For nested changeset associations written in one insert (Ecto’s
cast_assoc/put_assoc territory), see
Constraints and Upserts; the factory pattern
above deliberately inserts parent and child separately, which keeps the
defaults composable and the ids real.
Keeping unique fields unique
A schema with a unique index on email will reject the second user a naive
factory inserts, because every call produces "jane@example.com". Ecto solves
this with Factory.sequence/2, backed by a process-dictionary counter:
sequence(:email, &"email-#{&1}@example.com")
lode has no module-global mutable counter and no process dictionary
(another deliberate omission — see DESIGN.md), so there is nothing to hide a
sequence inside. The unique value comes from somewhere explicit instead, and
there are two idiomatic choices.
Pass an index in. The cleanest analogue of sequence is a parameter: the
caller supplies an integer and the factory weaves it into the unique fields.
The counter lives in the test (often a list.range or a fold), where it is
visible rather than ambient:
import gleam/int
import gleam/list
pub fn user_factory_n(i: Int) -> User {
User(
id: 0,
name: "User " <> int.to_string(i),
email: "user-" <> int.to_string(i) <> "@example.com",
age: 30,
)
}
pub fn insert_user_n(r: Repo, i: Int) -> Result(User, LodeError) {
insert_user(r, fn(_) { user_factory_n(i) })
}
// Insert five users with distinct emails:
let assert Ok(users) =
list.range(0, 4)
|> list.try_map(fn(i) { insert_user_n(r, i) })
Or use a UUID. When you need a globally unique value but don’t care what it
is, lode/types/uuid generates one without any counter to thread:
import lode/types/uuid
pub fn unique_user_factory() -> User {
let token = uuid.generate_v4()
User(id: 0, name: "User " <> token, email: token <> "@example.com", age: 30)
}
This is a design re-expression, not a gap. Ecto’s
sequence/2is convenient precisely because it hides global state; lode’s stance is that test data generators should be referentially transparent, so the source of uniqueness is always an argument or an explicit generator call. The result is that two test runs, or two concurrent stores, can never collide on a shared counter — there isn’t one. SeeDESIGN.mdin the repository.
Putting it together in a test
A factory pays off when a test reads as a list of only the things that test cares about. Against the in-memory adapter, the whole flow needs no database:
import lode/adapters/memory
pub fn post_belongs_to_author_test() {
let r = repo.new(memory.new())
// We care that the post has *an* author; the factory fills the rest.
let assert Ok(post) = insert_post(r, fn(p) { p })
let assert Ok(option.Some(author)) =
repo.get(repo: r, schema: user_schema(), id: VInt(post.author_id))
assert author.name == "Jane Smith"
}
Notice what the test does not say: no email, no age, no post title or body.
Those are the factory’s defaults, and leaving them out is the entire point —
the test states the relationship it is checking and nothing else. If a default
ever needs to change for one test, the build hook overrides it in place
without disturbing any other test.
For where these factories run — the in-memory adapter for unit tests and live PostgreSQL for integration tests, plus the absence of an SQL Sandbox — see Testing with Lode. For the complete list of differences from Elixir’s Ecto, see Divergences from Ecto.