Current section

Files

Jump to
factory_man lib factory_man.ex
Raw

lib/factory_man.ex

defmodule FactoryMan do
@moduledoc """
An Elixir library for generating test data. Define factories with `deffactory`, and FactoryMan
generates functions for building params, structs, and database records.
## Quick Start
```elixir
defmodule MyApp.Factory do
use FactoryMan, repo: MyApp.Repo
alias MyApp.Users.User
deffactory user(params \\\\ %{}), struct: User do
base_params = %{username: "user-\#{System.os_time()}"}
Map.merge(base_params, params)
end
end
```
## Generated Functions
For a factory named `:user` with `struct: User`:
| Function | Returns | Purpose |
| ---------------------------- | ---------------- | ---------------------------------- |
| `build_user_params/0,1` | `%{}` | Plain map (for changesets, APIs) |
| `build_user_struct/0,1` | `%User{}` | Struct in memory (not persisted) |
| `insert_user!/0,1` | `%User{}` | Inserted into database |
| `params_for_user/0,1` | `%{}` | Stripped params (no Ecto metadata) |
| `string_params_for_user/0,1` | `%{"" => ...}` | Stripped params with string keys |
| `build_user_params_list/1,2` | `[%{}, ...]` | List of params maps |
| `build_user_struct_list/1,2` | `[%User{}, ...]` | List of structs |
| `insert_user_list!/1,2` | `[%User{}, ...]` | List of inserted records |
All functions accept optional params for customization. Insert functions also accept repo
options. Each item in a list is evaluated independently (unique timestamps, sequences, etc.).
What gets generated depends on the options:
| Options | Params | Struct | Insert |
| ------------------------ | ------ | ------ | ------ |
| `struct: User` (default) | Yes | Yes | Yes |
| No `struct:` option | Yes | No | No |
| `insert?: false` | Yes | Yes | No |
| `build_struct?: false` | Yes | No | No |
| `build_params?: false` | No | Yes | Yes |
| Embedded schema | Yes | Yes | No |
## Defining Factories
The `deffactory` macro works like defining a function — specify a name, a parameter, and a body
that returns a **plain map**:
```elixir
deffactory user(params \\\\ %{}), struct: User do
base_params = %{username: "user-\#{System.os_time()}"}
Map.merge(base_params, params)
end
```
You can name the parameter anything, and use pattern matching:
```elixir
deffactory user_from_config(%{username: username} = params), struct: User do
base_params = %{username: username}
Map.merge(base_params, params)
end
```
### Struct vs. Non-Struct Factories
The `struct:` option controls both what functions are generated and how they're named:
```elixir
# Struct factory — generates build_user_params, build_user_struct, insert_user!, etc.
deffactory user(params \\\\ %{}), struct: User do
base_params = %{username: "user-\#{System.os_time()}"}
Map.merge(base_params, params)
end
# Non-struct factory — generates build_api_payload and build_api_payload_list only
deffactory api_payload(params \\\\ %{}) do
%{action: "create", data: params}
end
```
| Factory type | Generated functions |
| ----------------------------- | ---------------------------------------- |
| `struct: User` (`:user`) | `build_user_params`, `build_user_struct`, `insert_user!`, etc. |
| No `struct:` (`:api_payload`) | `build_api_payload`, `build_api_payload_list` |
Non-struct factories use simplified names (`build_*` instead of `build_*_params`) because
they can return any value — maps, strings, keyword lists, tuples, nil, etc.:
```elixir
deffactory greeting(name \\\\ "world") do
"Hello, \#{name}!"
end
deffactory search_opts(overrides \\\\ []) do
Keyword.merge([page: 1, per_page: 20], overrides)
end
```
Lazy evaluation works in keyword lists the same way it does in maps — 0-arity and 1-arity
functions are resolved at build time. Non-map, non-keyword-list values are passed through
unchanged.
**Associations** — call other factories to build related records:
```elixir
deffactory author(params \\\\ %{}), struct: Author do
base_params = %{
name: "Test Author",
user: params[:user] || build_user_struct()
}
Map.merge(base_params, params)
end
```
## Params For
For Ecto schema factories, FactoryMan generates `params_for_*` and `string_params_for_*`
functions that build a struct and strip Ecto metadata, returning a clean map suitable for
changesets or controller tests:
```elixir
# Returns %{username: "user-123", first_name: nil, ...}
# (no __struct__, __meta__, autogenerated :id, or NotLoaded associations)
params_for_user(%{username: "alice"})
# Same but with string keys: %{"username" => "alice", ...}
string_params_for_user(%{username: "alice"})
```
Unlike ExMachina, nil values are preserved (a nil field may be intentional), and struct values
like `DateTime` are left untouched.
## Factory Options
Options cascade: parent module -> child module -> individual factory.
**Module-level** (set with `use FactoryMan`):
- `:repo` — Ecto repo for database operations
- `:extends` — Parent factory module to inherit configuration from
- `:hooks` — Hooks applied to all factories in the module
- `:suppress_duplicate_option_warning` — Suppress warnings for redundant options
**Factory-level** (set with `deffactory`):
- `:struct` — Ecto schema module (enables struct and insert functions)
- `:insert?` — Set to `false` to skip insert functions
- `:build_struct?` — Set to `false` to skip struct builders
- `:build_params?` — Set to `false` to skip params builders (body returns struct directly,
requires `:struct`)
- `:hooks` — Merged with module-level hooks
- `:suppress_duplicate_option_warning` — Suppress warnings for redundant options
## Factory Inheritance
Child factories inherit the parent's repo, hooks, and helper functions via `:extends`:
```elixir
defmodule MyApp.Factory do
use FactoryMan, repo: MyApp.Repo
def generate_username, do: "user-\#{System.os_time()}"
end
defmodule MyApp.Factory.Accounts do
use FactoryMan, extends: MyApp.Factory
deffactory user(params \\\\ %{}), struct: User do
base_params = %{username: generate_username()}
Map.merge(base_params, params)
end
end
```
Inheritance chains are unlimited — a child factory can itself be extended.
## Sequences
Generate unique values across builds:
```elixir
sequence("user") # "user0", "user1", ...
sequence(:email, fn n -> "user\#{n}@example.com" end) # custom formatter
sequence(:role, ["admin", "moderator", "user"]) # cycles through list
sequence(:order, fn n -> "ORD-\#{n}" end, start_at: 1000) # custom start value
```
Reset in test setup: `FactoryMan.Sequence.reset()`
## Lazy Evaluation
Functions in factory params are evaluated at build time. This works in both maps and keyword
lists:
```elixir
# In maps
%{
created_at: fn -> DateTime.utc_now() end, # 0-arity: called with no args
display_name: fn user -> "\#{user.username} (User)" end # 1-arity: receives parent map
}
# In keyword lists
[
created_at: fn -> DateTime.utc_now() end,
label: fn kw -> "timeout-\#{kw[:timeout]}" end # 1-arity: receives parent keyword list
]
```
> #### Lazy evaluation ordering {: .warning}
>
> 1-arity functions receive the map or keyword list **before** lazy evaluation. Don't reference
> other lazy fields — they'll still be function references, not resolved values.
## Hooks
Transform data at specific stages. Every factory action has both a `before` and `after` hook.
### Hook Pipeline
Each generated function uses a subset of the pipeline. The full flow for `insert_user!` is:
```text
build_user_params:
before_build_params → [factory body + lazy eval] → after_build_params
build_user_struct (calls build_user_params internally):
→ before_build_struct → struct!() → after_build_struct
insert_user! (calls build_user_struct internally):
→ before_insert → Repo.insert!() → after_insert
```
### Hook Reference
| Hook | Receives | Returns | When to Use |
| ---------------------- | ------------ | ------------ | --------------------------------------------------------------- |
| `:before_build_params` | params (map) | params (map) | Transform or inject params before the factory body runs |
| `:after_build_params` | params (map) | params (map) | Modify params after the factory body (e.g. add computed fields) |
| `:before_build_struct` | params (map) | params (map) | Last chance to modify params before `struct!()` is called |
| `:after_build_struct` | struct | struct | Transform the struct after creation (e.g. set virtual fields) |
| `:before_insert` | struct | struct | Modify struct just before database insertion |
| `:after_insert` | struct | struct | Post-process after insertion (e.g. reset associations) |
### Hook Precedence
Hooks can be set at three levels. Later levels override earlier ones for the same hook key:
1. **Parent module**`use FactoryMan, hooks: [...]`
2. **Child module**`use FactoryMan, extends: Parent, hooks: [...]`
3. **Individual factory**`deffactory name(params), hooks: [...]`
### Examples
**Reset associations after insert** (most common hook usage):
```elixir
defmodule MyApp.Factory do
use FactoryMan,
repo: MyApp.Repo,
hooks: [after_insert: &__MODULE__.reset_assocs/1]
def reset_assocs(struct) do
Ecto.reset_fields(struct, struct.__struct__.__schema__(:associations))
end
end
```
**Log factory usage for debugging:**
```elixir
deffactory user(params \\\\ %{}), struct: User,
hooks: [after_build_params: &__MODULE__.log_params/1] do
base_params = %{username: "user-#{System.os_time()}"}
Map.merge(base_params, params)
end
def log_params(params) do
IO.inspect(params, label: "factory params")
params
end
```
## Embedded Schemas
Factories for embedded schemas work like regular struct factories but without database
insertion:
```elixir
defmodule MyApp.Factories.Settings do
use FactoryMan, extends: MyApp.Factory
alias MyApp.Users.Settings
deffactory settings(params \\\\ %{}), struct: Settings do
base_params = %{
theme: "dark",
notifications: true
}
Map.merge(base_params, params)
end
end
```
Embedded schemas generate `build_*_params` and `build_*_struct` functions only (as well as
the matching `*_list` functions), but do not generate any `insert_*` functions.
## Direct Struct Factories (`build_params?: false`)
For complex factories that need full control over struct construction, set `build_params?: false`.
The factory body returns a struct directly instead of a params map, and no `build_*_params`
functions are generated:
```elixir
deffactory invoice(params \\\\ %{}), struct: Invoice, build_params?: false do
customer =
case params[:customer] do
%Customer{} = customer -> customer
_ -> MyApp.Factory.Accounts.insert_customer!()
end
%Invoice{
customer: customer,
total: Map.get(params, :total, Enum.random(100..10_000))
}
end
```
This generates `build_invoice_struct/0,1`, `insert_invoice!/0,1,2`, and list variants, but
**not** `build_invoice_params`. The `after_build_struct`, `before_insert`, and `after_insert`
hooks still run. The `before_build_params`, `after_build_params`, and `before_build_struct`
hooks are skipped since there is no params-to-struct conversion stage.
`build_params?: false` can also be set at the module level with `use FactoryMan, build_params?: false`,
then overridden per-factory with `build_params?: true` if needed.
## Variant Factories (`defvariant`)
A variant wraps an existing base factory. It transforms the caller's params **before** passing
them to the base factory. Think of it as a preprocessor: the variant runs first, then the
base factory runs with the transformed params.
This ordering can be counterintuitive because the variant is defined **after** the base
factory in your code, but its logic executes **before** the base factory at runtime:
```text
Code order: deffactory user(...) -> defvariant admin(...), for: :user
Execution order: admin (preprocessor) -> user (base factory)
```
### Example
```elixir
deffactory user(params \\\\ %{}), struct: User do
base_params = %{username: sequence("user"), role: "member"}
Map.merge(base_params, params)
end
defvariant admin(params \\\\ %{}), for: :user do
base_params = %{role: "admin"}
Map.merge(base_params, params)
end
```
Calling `build_admin_user_struct()` is equivalent to `build_user_struct(%{role: "admin"})`.
Calling `build_admin_user_struct(%{role: "superadmin"})` passes `%{role: "superadmin"}` to
the base factory because the caller's params override the variant defaults.
Generated functions follow the pattern `{variant}_{base}`:
`build_admin_user_params/0,1`, `build_admin_user_struct/0,1`, `insert_admin_user!/0,1,2`,
plus list variants.
### Custom naming with `:as`
The `:as` option overrides the combined `{variant}_{base}` name:
```elixir
defvariant moderator(params \\\\ %{}), for: :user, as: :mod do
base_params = %{role: "moderator"}
Map.merge(base_params, params)
end
```
This generates `build_mod_struct/0,1`, `insert_mod!/0,1,2`, etc.
— instead of the default `build_moderator_user_struct`.
## Duplicate Option Warnings
If a child factory module specifies an option that is already defined by its parent with the
same value, FactoryMan will emit a compile-time warning. This helps catch redundant options
that were likely copy-pasted from the parent.
To suppress the warning for a specific module or factory, add
`suppress_duplicate_option_warning: true` to the options.
## Debugging
FactoryMan generates debug functions showing configured options:
```elixir
iex> MyApp.Factory._factory_opts()
[repo: MyApp.Repo]
iex> MyApp.Factories.Users._user_factory_opts()
[repo: MyApp.Repo, struct: User]
```
"""
require Logger
# Keys that should not trigger duplicate option warnings
@duplicate_warning_skip_keys [:extends, :suppress_duplicate_option_warning]
defmacro __using__(opts \\ []) do
quote do
import unquote(__MODULE__),
only: [deffactory: 2, deffactory: 3, defvariant: 3]
Module.register_attribute(__MODULE__, :factory_man_registry, accumulate: true)
parent_factory_opts =
case unquote(opts)[:extends] do
nil ->
# Use opts from current factory only
unquote(opts)
extends ->
# Extend base factory opts
parent_opts = extends.__info__(:attributes)[:parent_factory_opts] || []
FactoryMan._warn_duplicate_options(
parent_opts,
unquote(opts),
"module #{inspect(__MODULE__)}"
)
Keyword.merge(parent_opts, unquote(opts))
end
# Put factory module options into a module attribute that can be read by the child factories
Module.register_attribute(__MODULE__, :parent_factory_opts, persist: true)
Module.put_attribute(
__MODULE__,
:parent_factory_opts,
parent_factory_opts |> Keyword.delete(:suppress_duplicate_option_warning)
)
@doc """
A debug helper function that shows all the options for the `#{inspect(__MODULE__)}` factory
module.
"""
def _factory_opts, do: @parent_factory_opts
end
end
@doc """
Defines a factory that generates test data.
The `deffactory` macro creates a set of functions for building test data. It works like
defining a function, where you specify the factory name and a parameter (typically `params`).
## Options
- `:struct` - The Ecto schema module to build structs from. When provided, generates
struct and insert functions in addition to params builders.
- `:insert?` - Set to `false` to skip generating insert functions (default: `true` when
repo is configured and struct is insertable)
- `:build_struct?` - Set to `false` to skip generating struct builder functions (default: `true`)
- `:hooks` - A keyword list of hook functions to apply at different stages (see Hooks section)
- `:suppress_duplicate_option_warning` - Set to `true` to suppress warnings when this
factory specifies an option already defined by the module with the same value
## Generated Functions
For a factory named `user` with `struct: User`, the following functions are generated:
- `build_user_params/1` - Returns plain params
- `build_user_struct/1` - Returns an unsaved struct
- `insert_user!/1` - Inserts into the database (when repo is configured)
- `params_for_user/1` - Stripped params map (when struct is an Ecto schema)
- `string_params_for_user/1` - Stripped params with string keys (when struct is an Ecto schema)
- `build_user_params_list/2` - Builds multiple items
- `build_user_struct_list/2` - Builds multiple structs
- `insert_user_list!/2` - Inserts multiple items (when repo is configured)
For a factory named `greeting` without `struct:`, simplified names are used:
- `build_greeting/1` - Returns the factory's value
- `build_greeting_list/2` - Builds multiple items
## Examples
deffactory user(params \\\\ %{}), struct: User do
base_params = %{
username: sequence("user"),
email: sequence(:email, fn n -> "user\#{n}@example.com" end)
}
Map.merge(base_params, params)
end
iex> MyApp.Factory.build_user_params(%{username: "alice"})
%{username: "alice", email: "user0@example.com"}
iex> MyApp.Factory.insert_user!(%{role: "admin"})
%User{id: 1, username: "user1", email: "admin@example.com", role: "admin"}
"""
defmacro deffactory(factory_head, opts \\ [], do: block) do
# Recursively extract factory name and argument information from AST
extraction = extract_factory_args(factory_head)
factory_name = extraction.name
arg_ast = extraction.arg_ast
head_ast = extraction.head_ast
user_var = extraction.user_var
arg_ast_no_default = extraction.arg_no_default
has_pattern_match = extraction.has_pattern_match
has_default = extraction.has_default
plain_var_ast = extraction.plain_var
quote bind_quoted: [
factory_name: factory_name,
arg_ast: Macro.escape(arg_ast, unquote: true),
head_ast: Macro.escape(head_ast, unquote: true),
user_var: Macro.escape(user_var, unquote: true),
arg_ast_no_default: Macro.escape(arg_ast_no_default, unquote: true),
has_pattern_match: has_pattern_match,
has_default: has_default,
plain_var_ast: Macro.escape(plain_var_ast, unquote: true),
opts: opts,
block: Macro.escape(block, unquote: true)
] do
parent_factory_opts = Module.get_attribute(__MODULE__, :parent_factory_opts)
FactoryMan._warn_duplicate_options(
parent_factory_opts,
opts,
"factory :#{factory_name} in #{inspect(__MODULE__)}"
)
parent_factory_hooks = Keyword.get(parent_factory_opts, :hooks, [])
child_factory_hooks = Keyword.get(opts, :hooks, [])
merged_hooks = Keyword.merge(parent_factory_hooks, child_factory_hooks)
merged_opts = Keyword.merge(parent_factory_opts, opts) |> Keyword.put(:hooks, merged_hooks)
@doc "A debug helper function that shows all options for the `#{factory_name}` factory."
def unquote(String.to_atom("_#{factory_name}_factory_opts"))(), do: unquote(merged_opts)
# Extract hooks - used many times throughout
hooks = merged_hooks
# Validate: build_params?: false requires struct: option
if merged_opts[:build_params?] == false and is_nil(merged_opts[:struct]) do
raise ArgumentError,
"deffactory #{factory_name}: build_params?: false requires the struct: option, " <>
"otherwise no functions would be generated"
end
# Generate params builder functions (skipped when build_params?: false)
# Non-struct factories use `build_*` / `build_*_list` (no suffix).
# Struct factories use `build_*_params` / `build_*_params_list`.
if merged_opts[:build_params?] != false do
params_suffix = if merged_opts[:struct], do: "_params", else: ""
build_fn = :"build_#{factory_name}#{params_suffix}"
build_list_fn = :"build_#{factory_name}#{params_suffix}_list"
# Head declaration (simple variable with default if present)
def unquote({build_fn, [], [head_ast]})
# Implementation (with pattern matching if needed, no default)
def unquote({build_fn, [], [arg_ast_no_default]}) do
unquote(user_var) =
FactoryMan.get_hook_handler(unquote(hooks), :before_build_params).(unquote(user_var))
unquote(block)
|> FactoryMan.evaluate_lazy_attributes()
|> then(&FactoryMan.get_hook_handler(unquote(hooks), :after_build_params).(&1))
end
# Generate list builder function
# Only generate convenience function if argument has a default value
if has_default do
def unquote(build_list_fn)(count)
when is_integer(count) and count >= 0 do
Stream.repeatedly(fn -> unquote(build_fn)() end)
|> Enum.take(count)
end
end
def unquote(build_list_fn)(count, params)
when is_integer(count) and count >= 0 do
Stream.repeatedly(fn -> unquote(build_fn)(params) end)
|> Enum.take(count)
end
end
if merged_opts[:struct] != nil and merged_opts[:build_struct?] != false do
if merged_opts[:build_params?] != false do
# Generate struct builder function (standard: delegates to params builder)
# Head declaration (simple variable with default if present)
def unquote({:"build_#{factory_name}_struct", [], [head_ast]})
# Implementation - uses plain_var_ast since pattern match variables
# are only needed in the params builder body
def unquote({:"build_#{factory_name}_struct", [], [plain_var_ast]}) do
unquote(user_var)
|> unquote(:"build_#{factory_name}_params")()
|> then(&FactoryMan.get_hook_handler(unquote(hooks), :before_build_struct).(&1))
|> then(&struct!(unquote(merged_opts[:struct]), &1))
|> then(&FactoryMan.get_hook_handler(unquote(hooks), :after_build_struct).(&1))
end
else
# Generate struct builder function (build_params?: false: body returns struct directly)
# Head declaration (simple variable with default if present)
def unquote({:"build_#{factory_name}_struct", [], [head_ast]})
# Implementation - uses arg_ast_no_default since pattern matching
# is needed in the body (same role as params builder in standard mode)
def unquote({:"build_#{factory_name}_struct", [], [arg_ast_no_default]}) do
unquote(block)
|> then(&FactoryMan.get_hook_handler(unquote(hooks), :after_build_struct).(&1))
end
end
# Generate struct list builder function
# Only generate convenience function if there's no pattern match without default
if not has_pattern_match do
def unquote(:"build_#{factory_name}_struct_list")(count)
when is_integer(count) and count >= 0 do
unquote(:"build_#{factory_name}_struct_list")(count, %{})
end
end
def unquote(:"build_#{factory_name}_struct_list")(count, params)
when is_integer(count) and count >= 0 and is_map(params) do
Stream.repeatedly(fn ->
unquote(:"build_#{factory_name}_struct")(params)
end)
|> Enum.take(count)
end
struct_module = merged_opts[:struct]
repo = merged_opts[:repo]
insert? = merged_opts[:insert?]
is_ecto_schema? =
match?({:module, _}, Code.ensure_compiled(struct_module)) and
function_exported?(struct_module, :__schema__, 1)
# Generate params_for and string_params_for (only for Ecto schemas)
if is_ecto_schema? do
if has_default do
def unquote(:"params_for_#{factory_name}")() do
unquote(:"build_#{factory_name}_struct")()
|> FactoryMan.Params.strip()
end
end
def unquote(:"params_for_#{factory_name}")(unquote(plain_var_ast)) do
unquote(user_var)
|> unquote(:"build_#{factory_name}_struct")()
|> FactoryMan.Params.strip()
end
if has_default do
def unquote(:"string_params_for_#{factory_name}")() do
unquote(:"params_for_#{factory_name}")()
|> FactoryMan.Params.stringify_keys()
end
end
def unquote(:"string_params_for_#{factory_name}")(unquote(plain_var_ast)) do
unquote(:"params_for_#{factory_name}")(unquote(user_var))
|> FactoryMan.Params.stringify_keys()
end
end
is_insertable_ecto_schema_factory? =
is_ecto_schema? and not is_nil(repo) and
struct_module.__schema__(:source) != nil
if is_insertable_ecto_schema_factory? and insert? != false do
# Generate struct insert functions
# Head declaration (simple variable with default if present)
def unquote({:"insert_#{factory_name}!", [], [head_ast]})
# Only generate convenience function if there's no pattern match without default
# Pattern matches require specific keys, so we can't call with %{}
if not has_pattern_match do
def unquote(:"insert_#{factory_name}!")(repo_insert_opts)
when is_list(repo_insert_opts) do
unquote(:"insert_#{factory_name}!")(%{}, repo_insert_opts)
end
end
# Implementation - uses plain_var_ast since pattern match variables
# are only needed in the params builder body
def unquote(:"insert_#{factory_name}!")(unquote(plain_var_ast)) do
unquote(:"insert_#{factory_name}!")(unquote(user_var), [])
end
def unquote(:"insert_#{factory_name}!")(
unquote(plain_var_ast),
repo_insert_opts
)
when is_list(repo_insert_opts) do
unquote(user_var)
|> unquote(:"build_#{factory_name}_struct")()
|> then(&FactoryMan.get_hook_handler(unquote(hooks), :before_insert).(&1))
|> unquote(repo).insert!(repo_insert_opts)
|> then(&FactoryMan.get_hook_handler(unquote(hooks), :after_insert).(&1))
end
# Generate struct insert list functions
# Only generate convenience functions if there's no pattern match without default
if not has_pattern_match do
def unquote(:"insert_#{factory_name}_list!")(count)
when is_integer(count) and count >= 0 do
unquote(:"insert_#{factory_name}_list!")(count, %{}, [])
end
def unquote(:"insert_#{factory_name}_list!")(count, repo_insert_opts)
when is_integer(count) and count >= 0 and is_list(repo_insert_opts) do
unquote(:"insert_#{factory_name}_list!")(count, %{}, repo_insert_opts)
end
end
def unquote(:"insert_#{factory_name}_list!")(count, params)
when is_integer(count) and count >= 0 and is_map(params) do
unquote(:"insert_#{factory_name}_list!")(count, params, [])
end
def unquote(:"insert_#{factory_name}_list!")(count, params, repo_insert_opts)
when is_integer(count) and count >= 0 and is_map(params) and
is_list(repo_insert_opts) do
Stream.repeatedly(fn ->
unquote(:"insert_#{factory_name}!")(params, repo_insert_opts)
end)
|> Enum.take(count)
end
end
end
# Register factory metadata so defvariant can look up base factory capabilities
@factory_man_registry {factory_name, merged_opts}
end
end
@doc """
Defines a variant factory that wraps a base factory.
A variant is a preprocessor: it receives the caller's params, transforms them, and delegates
to the base factory. The variant body runs **before** the base factory, not after.
## Example
deffactory user(params \\\\ %{}), struct: User do
base_params = %{username: sequence("user"), role: "member"}
Map.merge(base_params, params)
end
defvariant admin(params \\\\ %{}), for: :user do
base_params = %{role: "admin"}
Map.merge(base_params, params)
end
# Generated: build_admin_user_struct/0,1, insert_admin_user!/0,1,2, etc.
# Calling build_admin_user_struct() is equivalent to:
# build_user_struct(%{role: "admin"})
## Options
- `:for` - (atom, required) The name of the base factory to wrap (e.g. `:user`)
- `:as` - Instead of the default `<variant>_<base>` structure (e.g. `admin_user`), you may
specify a custom name for the factory (e.g. `:admin`)
"""
defmacro defvariant(variant_head, opts, do: block) do
extraction = extract_factory_args(variant_head)
variant_name = extraction.name
arg_ast = extraction.arg_ast
head_ast = extraction.head_ast
user_var = extraction.user_var
arg_ast_no_default = extraction.arg_no_default
has_pattern_match = extraction.has_pattern_match
has_default = extraction.has_default
plain_var_ast = extraction.plain_var
base_factory_name = opts[:for] || raise ArgumentError, "defvariant requires the :for option"
as_name = opts[:as]
quote bind_quoted: [
variant_name: variant_name,
base_factory_name: base_factory_name,
as_name: as_name,
arg_ast: Macro.escape(arg_ast, unquote: true),
head_ast: Macro.escape(head_ast, unquote: true),
user_var: Macro.escape(user_var, unquote: true),
arg_ast_no_default: Macro.escape(arg_ast_no_default, unquote: true),
has_pattern_match: has_pattern_match,
has_default: has_default,
plain_var_ast: Macro.escape(plain_var_ast, unquote: true),
block: Macro.escape(block, unquote: true)
] do
# Look up the base factory's registered metadata
base_entry =
@factory_man_registry
|> Enum.find(fn {name, _opts} -> name == base_factory_name end)
if is_nil(base_entry) do
raise ArgumentError,
"defvariant #{variant_name}: base factory :#{base_factory_name} not found. " <>
"Ensure deffactory :#{base_factory_name} is defined before defvariant."
end
{_base_name, base_opts} = base_entry
# Variant function names combine variant + base: e.g. :admin + :user = :admin_user
# The :as option overrides this combined name.
full_name = as_name || :"#{variant_name}_#{base_factory_name}"
# Generate params builder variant (if base factory has params)
# Non-struct factories use `build_*` / `build_*_list` (no suffix).
if base_opts[:build_params?] != false do
params_suffix = if base_opts[:struct], do: "_params", else: ""
build_fn = :"build_#{full_name}#{params_suffix}"
build_list_fn = :"build_#{full_name}#{params_suffix}_list"
base_build_fn = :"build_#{base_factory_name}#{params_suffix}"
def unquote({build_fn, [], [head_ast]})
def unquote({build_fn, [], [arg_ast_no_default]}) do
unquote(block)
|> unquote(base_build_fn)()
end
if not has_pattern_match do
def unquote(build_list_fn)(count)
when is_integer(count) and count >= 0 do
unquote(build_list_fn)(count, %{})
end
end
def unquote(build_list_fn)(count, params)
when is_integer(count) and count >= 0 and is_map(params) do
Stream.repeatedly(fn ->
unquote(build_fn)(params)
end)
|> Enum.take(count)
end
end
# Generate struct builder variant (if base factory has struct)
if base_opts[:struct] != nil and base_opts[:build_struct?] != false do
def unquote({:"build_#{full_name}_struct", [], [head_ast]})
def unquote({:"build_#{full_name}_struct", [], [arg_ast_no_default]}) do
unquote(block)
|> unquote(:"build_#{base_factory_name}_struct")()
end
if not has_pattern_match do
def unquote(:"build_#{full_name}_struct_list")(count)
when is_integer(count) and count >= 0 do
unquote(:"build_#{full_name}_struct_list")(count, %{})
end
end
def unquote(:"build_#{full_name}_struct_list")(count, params)
when is_integer(count) and count >= 0 and is_map(params) do
Stream.repeatedly(fn ->
unquote(:"build_#{full_name}_struct")(params)
end)
|> Enum.take(count)
end
# Generate params_for variant (if base factory is an Ecto schema)
struct_module = base_opts[:struct]
repo = base_opts[:repo]
insert? = base_opts[:insert?]
is_ecto_schema? =
match?({:module, _}, Code.ensure_compiled(struct_module)) and
function_exported?(struct_module, :__schema__, 1)
if is_ecto_schema? do
if has_default do
def unquote(:"params_for_#{full_name}")() do
unquote(:"build_#{full_name}_struct")()
|> FactoryMan.Params.strip()
end
end
def unquote(:"params_for_#{full_name}")(unquote(plain_var_ast)) do
unquote(user_var)
|> unquote(:"build_#{full_name}_struct")()
|> FactoryMan.Params.strip()
end
if has_default do
def unquote(:"string_params_for_#{full_name}")() do
unquote(:"params_for_#{full_name}")()
|> FactoryMan.Params.stringify_keys()
end
end
def unquote(:"string_params_for_#{full_name}")(unquote(plain_var_ast)) do
unquote(:"params_for_#{full_name}")(unquote(user_var))
|> FactoryMan.Params.stringify_keys()
end
end
# Generate insert variant -- delegates to base factory's insert
# (reuses base factory's hooks, repo config, and insert pipeline)
is_insertable_ecto_schema_factory? =
is_ecto_schema? and not is_nil(repo) and
struct_module.__schema__(:source) != nil
if is_insertable_ecto_schema_factory? and insert? != false do
# Head declaration
def unquote({:"insert_#{full_name}!", [], [head_ast]})
if not has_pattern_match do
def unquote(:"insert_#{full_name}!")(repo_insert_opts)
when is_list(repo_insert_opts) do
unquote(:"insert_#{full_name}!")(%{}, repo_insert_opts)
end
end
def unquote(:"insert_#{full_name}!")(unquote(plain_var_ast)) do
unquote(:"insert_#{full_name}!")(unquote(user_var), [])
end
def unquote(:"insert_#{full_name}!")(
unquote(arg_ast_no_default),
repo_insert_opts
)
when is_list(repo_insert_opts) do
# Transform params via variant body, then delegate to base insert
unquote(block)
|> unquote(:"insert_#{base_factory_name}!")(repo_insert_opts)
end
# List insert variants
if not has_pattern_match do
def unquote(:"insert_#{full_name}_list!")(count)
when is_integer(count) and count >= 0 do
unquote(:"insert_#{full_name}_list!")(count, %{}, [])
end
def unquote(:"insert_#{full_name}_list!")(
count,
repo_insert_opts
)
when is_integer(count) and count >= 0 and is_list(repo_insert_opts) do
unquote(:"insert_#{full_name}_list!")(
count,
%{},
repo_insert_opts
)
end
end
def unquote(:"insert_#{full_name}_list!")(count, params)
when is_integer(count) and count >= 0 and is_map(params) do
unquote(:"insert_#{full_name}_list!")(count, params, [])
end
def unquote(:"insert_#{full_name}_list!")(
count,
params,
repo_insert_opts
)
when is_integer(count) and count >= 0 and is_map(params) and
is_list(repo_insert_opts) do
Stream.repeatedly(fn ->
unquote(:"insert_#{full_name}!")(params, repo_insert_opts)
end)
|> Enum.take(count)
end
end
end
end
end
# Extracts all necessary components from the factory_head AST recursively.
# Handles: params, params \\ %{}, %{key: val} = params, and variations.
defp extract_factory_args(factory_head) do
# First, extract the factory name and the argument list
{name, args} = extract_name_and_args(factory_head)
# Now recursively process the argument to extract all components
process_arg(args, name)
end
# Extract factory name and argument list from the head
defp extract_name_and_args({name, _, args}) when is_list(args) do
# Factory head is like: user(params) or user(params \\ %{})
{name, args}
end
# Process the argument AST recursively
defp process_arg([arg_ast], name) do
# Recursively walk the AST to extract components
components = walk_arg_ast(arg_ast)
# Build the result map
%{
name: name,
arg_ast: arg_ast,
head_ast: components.head_ast,
user_var: components.user_var,
arg_no_default: components.arg_no_default,
has_pattern_match: components.has_pattern_match,
has_default: components.has_default,
plain_var: components.plain_var
}
end
# Catch-all for invalid argument counts
defp process_arg(args, _name) when is_list(args) do
raise ArgumentError, """
Invalid factory definition: expected exactly one argument, got #{length(args)}
FactoryMan factories must have exactly one parameter (typically `params`).
Valid examples:
deffactory user(params \\\\ %{}), struct: User do ... end
deffactory author(%{name: name} = params), struct: Author do ... end
"""
end
# Recursively walk the argument AST and extract all necessary components
defp walk_arg_ast(ast) do
do_walk_arg_ast(ast, %{
head_ast: nil,
user_var: nil,
arg_no_default: nil,
has_pattern_match: false,
has_default: false,
plain_var: nil
})
end
# Case 1: Pattern match with default - %{key: val} = params \\ %{}
# AST: {:\\, _, [{:=, _, [pattern, {var, _, _}]}, default]}
defp do_walk_arg_ast({:\\, _, [{:=, _, [_pattern, var_ast]} = pattern, default]}, acc) do
var_name = extract_var_name(var_ast)
# For function head, use just the variable with default (no pattern match)
head_ast = {:\\, [], [Macro.var(var_name, nil), default]}
user_var = Macro.var(var_name, nil)
%{
acc
| head_ast: head_ast,
user_var: user_var,
# Keep the full pattern for implementation
arg_no_default: pattern,
has_pattern_match: true,
has_default: true,
plain_var: var_ast
}
end
# Case 2: Variable with default - params \\ %{}
# AST: {:\\, _, [{var, _, _}, default]}
defp do_walk_arg_ast({:\\, _, [var_ast, _default]} = ast, acc) do
var_name = extract_var_name(var_ast)
# Keep the full \\ expression for head
head_ast = ast
user_var = Macro.var(var_name, nil)
%{
acc
| head_ast: head_ast,
user_var: user_var,
arg_no_default: var_ast,
has_pattern_match: false,
has_default: true,
plain_var: var_ast
}
end
# Case 4: Pattern match without default - %{key: val} = params
# AST: {:=, _, [pattern, {var, _, _}]}
defp do_walk_arg_ast({:=, _, [_pattern, var_ast]} = ast, acc) do
var_name = extract_var_name(var_ast)
# Use just the var for head (no destructuring)
head_ast = Macro.var(var_name, nil)
user_var = Macro.var(var_name, nil)
%{
acc
| head_ast: head_ast,
user_var: user_var,
# Keep the full pattern for implementation
arg_no_default: ast,
has_pattern_match: true,
plain_var: var_ast
}
end
# Case 5: Simple variable - params
# AST: {var, _, _}
defp do_walk_arg_ast({var_name, _, _} = ast, acc) when is_atom(var_name) do
user_var = Macro.var(var_name, nil)
%{
acc
| head_ast: ast,
user_var: user_var,
arg_no_default: ast,
has_pattern_match: false,
plain_var: ast
}
end
# Catch-all for unsupported patterns
defp do_walk_arg_ast(ast, _acc) do
raise ArgumentError, """
Unsupported factory argument pattern: #{Macro.to_string(ast)}
FactoryMan supports these patterns:
- params
- params \\\\ %{}
- %{key: value} = params
- %{key: value} = params \\\\ %{key: default}
If you need a different pattern, please open an issue.
"""
end
# Extract variable name from a variable AST node
defp extract_var_name({var_name, _, _}) when is_atom(var_name), do: var_name
@doc """
Evaluate lazy attributes in a map, struct, or keyword list.
Functions with 0 arity are called with no arguments.
Functions with 1 arity receive the parent factory (map, struct, or keyword list) as their
argument.
Non-map, non-keyword-list values are passed through unchanged.
## Examples
iex> FactoryMan.evaluate_lazy_attributes(
...> %{name: "test", timestamp: fn -> System.os_time() end}
...> )
%{name: "test", timestamp: 12345}
iex> FactoryMan.evaluate_lazy_attributes(
...> %{first: "John", last: fn attrs -> attrs.first <> " Smith" end}
...> )
%{first: "John", last: "John Smith"}
iex> FactoryMan.evaluate_lazy_attributes(
...> [timeout: 5000, created_at: fn -> DateTime.utc_now() end]
...> )
[timeout: 5000, created_at: ~U[2026-01-01 00:00:00Z]]
iex> FactoryMan.evaluate_lazy_attributes("plain string")
"plain string"
"""
@spec evaluate_lazy_attributes(any) :: any
def evaluate_lazy_attributes(%{__struct__: record} = factory) do
struct!(record, factory |> Map.from_struct() |> do_evaluate_lazy_attributes(factory))
end
def evaluate_lazy_attributes(attrs) when is_map(attrs) do
do_evaluate_lazy_attributes(attrs, attrs)
end
def evaluate_lazy_attributes(attrs) when is_list(attrs) do
if Keyword.keyword?(attrs) do
resolve_lazy_pairs(attrs, attrs)
else
attrs
end
end
def evaluate_lazy_attributes(value), do: value
defp do_evaluate_lazy_attributes(attrs, parent_factory) do
resolve_lazy_pairs(attrs, parent_factory) |> Enum.into(%{})
end
defp resolve_lazy_pairs(pairs, parent) do
Enum.map(pairs, fn
{k, v} when is_function(v, 1) -> {k, v.(parent)}
{k, v} when is_function(v) -> {k, v.()}
{_, _} = tuple -> tuple
end)
end
@doc """
The default handler for hooks. This function is a no-op, and simply returns the given `value`
without any modifications.
## Examples
iex> FactoryMan.fallback_hook_handler(123)
123
"""
def fallback_hook_handler(value), do: value
@doc """
Get the configured handler for a `hook`, or fall back to `&FactoryMan.fallback_hook_handler/0`.
## Examples
iex> hooks = [after_insert: &YourProject.Factories.Users.user_after_insert_handler/1]
iex> FactoryMan.get_hook_handler(hooks, :before_build)
&FactoryMan.fallback_hook_handler/0
iex> FactoryMan.get_hook_handler(hooks, :after_insert)
&YourProject.Factories.Users.user_after_insert_handler/1
"""
def get_hook_handler(hooks, hook), do: hooks[hook] || (&FactoryMan.fallback_hook_handler/1)
@doc """
Warn at compile time if child opts contain options that are already defined by the parent with
the same value.
This is a FactoryMan internal function — called from macro-generated code. Use the underscore
prefix convention to signal that it is not part of the public API.
"""
def _warn_duplicate_options(parent_opts, child_opts, context) do
if Keyword.get(child_opts, :suppress_duplicate_option_warning) != true do
child_opts
|> Keyword.drop(@duplicate_warning_skip_keys)
|> Enum.each(fn {key, value} ->
if Keyword.has_key?(parent_opts, key) and Keyword.get(parent_opts, key) == value do
Logger.warning("""
FactoryMan: duplicate option in #{context}
The option `#{inspect(key)}: #{inspect(value)}` is already defined by the parent \
factory with the same value. This is redundant and can be removed.
To suppress this warning, add `suppress_duplicate_option_warning: true` to the options.\
""")
end
end)
end
end
@doc """
Generates a sequence of strings.
The sequence name is used as the beginning of the string. For example, if you
do `sequence("joe")`, you will get back `"joe0"`, then `"joe1"`, and so on.
## Example
def user_factory do
%{
username: sequence("joe")
}
end
If you want to customize the returned string you can use `sequence/2`.
"""
@spec sequence(String.t()) :: String.t()
def sequence(name), do: FactoryMan.Sequence.next(name)
@doc """
Generates and returns a unique sequence.
If a formatter function is passed, it will be called with the current
position of the sequence. You can also pass a list, and each item in the list
will be returned in sequence.
## Example with a formatter function
def user_factory do
%{
email: sequence(:email, fn n -> "me-\#{n}@foo.com" end)
}
end
## Example with a list
def user_factory do
%{
name: sequence(:name, ["Joe", "Mike", "Sarah"])
}
end
"""
@spec sequence(any, (integer -> any) | nonempty_list) :: any
def sequence(name, formatter), do: FactoryMan.Sequence.next(name, formatter)
@doc """
Generates and returns a unique sequence with options.
Currently, the only option is `:start_at` which specifies the number to
start the sequence at.
## Example
def money_factory do
%{
cents: sequence(:cents, fn n -> "\#{n}" end, start_at: 600)
}
end
"""
@spec sequence(any, (integer -> any) | nonempty_list, start_at: non_neg_integer) :: any
def sequence(name, formatter, opts), do: FactoryMan.Sequence.next(name, formatter, opts)
end