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!/2 because 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. See DESIGN.md in 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/2 is 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. See DESIGN.md in 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.

Search Document