Current section

Files

Jump to
factory_man lib factory_man.ex
Raw

lib/factory_man.ex

defmodule FactoryMan do
@moduledoc """
Test data factories with automatic struct building, database insertion, and customizable hooks.
## Quick Start
First, create a base factory module in your test support directory:
File: `test/support/my_app/factory.ex`
defmodule MyApp.Factory do
use FactoryMan, repo: MyApp.Repo
end
Then create factories in a separate module that extends your base factory:
File: `test/support/my_app/factories/users.ex`
defmodule MyApp.Factories.Users do
use FactoryMan, extends: MyApp.Factory
alias MyApp.Users.User
deffactory user(params \\ %{}), struct: User do
base_params = %{username: "user-#{System.os_time()}"}
Map.merge(base_params, params)
end
end
You can also define factories directly in the base module for simple cases:
File: `test/support/my_app/factory.ex`
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
## Dependencies
FactoryMan itself has no required runtime dependencies. However, you will need to add
dependencies based on which factory functions you intend to use:
**For params-only factories** (no struct option):
- No additional dependencies required
**For struct factories** (with `struct:` option):
- `{:ecto_sql, "~> 3.0"}` - Required in your mix.exs
**For database insertion** (`insert_*!` functions):
- `{:ecto_sql, "~> 3.0"}` - Required in your mix.exs
- Database driver (e.g., `{:postgrex, ">= 0.0.0"}`) - Required for your specific database
Add these to your `mix.exs` deps:
def deps do
[
{:factory_man, "~> 0.1.0"},
{:ecto_sql, "~> 3.0"},
{:postgrex, ">= 0.0.0"} # If using PostgreSQL
]
end
## Generated Functions
**Struct factories** (with `struct:` option) generate:
- `build_user_params/0`, `build_user_params/1` - Build params map
- `build_user_struct/0`, `build_user_struct/1` - Build struct (not persisted)
- `insert_user!/0`, `insert_user!/1`, `insert_user!/2` - Insert into database
- List variants: `build_user_params_list/1`, `build_user_struct_list/1`, `insert_user_list!/1`, etc.
**Non-struct factories** (without `struct:` option) generate:
- `build_api_payload_params/0`, `build_api_payload_params/1` - Build params map only
- `build_api_payload_params_list/1`, `build_api_payload_params_list/2`
## Defining Factories
The `deffactory` macro works like defining a function. You specify the factory name and a single
parameter (e.g. `param`), with an optional default value (e.g. `%{}`):
deffactory user(params \\ %{}), struct: User do
base_params = %{username: "user-#{System.os_time()}"}
Map.merge(base_params, params)
end
You can name the parameter whatever you want:
deffactory user(attrs \\ %{}), struct: User do
base_attrs = %{username: "user-#{System.os_time()}"}
Map.merge(base_attrs, attrs)
end
**Required parameters** (no default) when no default fallback is desired:
deffactory user_from_config(config), struct: User do
# Factory won't compile without config argument
base_params = %{username: config[:username] || "default"}
Map.merge(base_params, config)
end
**Building factories on other factories:**
Call other factory functions within a factory definition to automatically create required
associations:
deffactory author(params \\ %{}), struct: Author do
base_params = %{
name: "Test Author",
# Automatically build associated user if not provided
user: params[:user] || build_user_struct()
}
Map.merge(base_params, params)
end
Extend and customize existing factories:
deffactory user(params \\ %{}), struct: User do
base_params = %{username: "user-#{System.os_time()}"}
Map.merge(base_params, params)
end
deffactory admin(params \\ %{}), struct: User do
# Reuse user factory, override role
base_params = %{role: "admin"}
params
|> build_user_params() # Get base user params
|> Map.merge(base_params) # Add admin overrides
|> Map.merge(params) # Apply caller overrides
end
## Factory Options
Options cascade from parent module to child module to individual factory, letting you configure
behavior at whichever level is right for you:
**Module-level options** (set with `use FactoryMan`):
- `:repo` - Ecto repo for database operations (required for insert functions)
- `:extends` - Parent factory module to inherit common configuration
- `:hooks` - Hooks applied to all factories in the module
**Factory-level options** (set with `deffactory`):
- `:struct` - Ecto schema struct to build (generates struct and insert functions)
- `:insert?` - Set to `false` to prevent accidental database insertion (e.g., for read-only test
data)
- `:build_struct?` - Set to `false` when you only need raw params (e.g., for API request bodies)
- `:hooks` - Additional hooks merged with module-level hooks
## Factory Inheritance
Use inheritance to avoid repeating common configuration across multiple factory modules:
defmodule MyApp.Factory do
use FactoryMan, repo: MyApp.Repo
def generate_username, do: "user-#{System.os_time()}"
end
Child factories inherit the parent's repo, hooks, and helper functions via `:extends`:
defmodule MyApp.Factories.Users do
use FactoryMan, extends: MyApp.Factory
deffactory user(params \\ %{}), struct: User do
%{username: generate_username()} |> Map.merge(params)
end
end
**Options cascade through all levels:**
Parent module options → Child module options → Individual factory options.
Later options override earlier ones, letting you customize at any level.
defmodule MyApp.Factory do
use FactoryMan,
repo: MyApp.Repo,
hooks: [after_insert: &reset_assocs/1]
end
defmodule MyApp.Factories.Users do
use FactoryMan,
extends: MyApp.Factory,
hooks: [before_build_params: &log_build/1] # Merged with parent hooks
# Inherits :repo and both hooks from MyApp.Factory
deffactory user(params \\ %{}), struct: User do
base_params = %{username: "user-#{System.os_time()}"}
Map.merge(base_params, params)
end
# Override hooks for this specific factory only
deffactory admin(params \\ %{}), struct: User, hooks: [after_insert: &promote_to_admin/1] do
base_params = %{username: "admin-#{System.os_time()}"}
Map.merge(base_params, params)
end
# Disable insert for this factory (build only)
deffactory draft_user(params \\ %{}), struct: User, insert?: false do
base_params = %{username: "draft-#{System.os_time()}"}
Map.merge(base_params, params)
end
# Disable struct building (params only)
deffactory user_config(params \\ %{}), struct: User, build_struct?: false do
base_params = %{theme: "dark", notifications: true}
Map.merge(base_params, params)
end
end
## List Factories
Create multiple records:
iex> MyApp.Factories.Users.build_user_struct_list(3)
[%User{id: nil, ...}, %User{id: nil, ...}, %User{id: nil, ...}]
iex> MyApp.Factories.Users.insert_user_list!(3)
[%User{id: 1, ...}, %User{id: 2, ...}, %User{id: 3, ...}]
# With custom params
MyApp.Factories.Users.insert_user_list!(2, %{role: "admin"}, returning: true)
Each item is evaluated independently, so timestamps and sequences generate unique values.
## Sequence Generation
Generate unique values across test runs:
Basic sequences:
deffactory user(params \\ %{}), struct: User do
base_params = %{username: sequence("user")} # user0, user1, user2...
Map.merge(base_params, params)
end
Custom formatters:
deffactory user(params \\ %{}), struct: User do
base_params = %{
email: sequence(:email, fn n -> "user\#{n}@example.com" end)
}
Map.merge(base_params, params)
end
Cyclical sequences:
deffactory user(params \\ %{}), struct: User do
base_params = %{
role: sequence(:role, ["admin", "moderator", "user"])
}
Map.merge(base_params, params)
end
Custom starting value:
deffactory order(params \\ %{}), struct: Order do
base_params = %{
order_number: sequence(:order, fn n -> "ORD-\#{n}" end, start_at: 1000)
}
Map.merge(base_params, params)
end
Reset sequences in test setup for predictable values:
setup do
FactoryMan.Sequence.reset()
:ok
end
## Lazy Evaluation
Functions in factory params are evaluated at build time:
deffactory user(params \\ %{}), struct: User do
base_params = %{
username: "user-#{System.os_time()}",
# 0-arity: called with no arguments
created_at: fn -> DateTime.utc_now() end,
# 1-arity: receives the parent factory
display_name: fn user -> "\#{user.username} (User)" end
}
Map.merge(base_params, params)
end
## Hooks
Transform data at specific stages. Every factory action has both a `before` and `after` hook,
letting you intercept and modify data at the exact point you need:
| Action | Before Hook | After Hook |
| -------------------- | ---------------------- | --------------------- |
| Build params | `:before_build_params` | `:after_build_params` |
| Build struct | `:before_build_struct` | `:after_build_struct` |
| Insert into database | `:before_insert` | `:after_insert` |
Example: Reset loaded associations after insert to match a fresh database query:
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
## Embedded Schemas
Factories for embedded schemas work like regular struct factories but without database
insertion:
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.
## Debugging
FactoryMan generates debug functions showing configured options:
iex> MyApp.Factory._factory_opts()
[repo: MyApp.Repo]
iex> MyApp.Factories.Users._user_factory_opts()
[repo: MyApp.Repo, struct: User]
"""
defmacro __using__(opts \\ []) do
quote do
import unquote(__MODULE__),
only: [deffactory: 2, deffactory: 3, sequence: 1, sequence: 2, sequence: 3]
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] || []
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)
@doc """
A debug helper function that can show all the options for the `#{inspect(__MODULE__)}`
factory module.
"""
def _factory_opts, do: @parent_factory_opts
end
end
defmacro deffactory(factory_head, opts \\ [], do: block) do
# Extract factory name, the arg AST (preserving any default), and the user's var
{factory_name, arg_ast, user_var} =
case factory_head do
{name, _, [{:\\, _, [{var, _, _}, _]} = arg_ast]} -> {name, arg_ast, Macro.var(var, nil)}
{name, _, [{var, _, _} = arg]} -> {name, arg, Macro.var(var, nil)}
end
quote bind_quoted: [
factory_name: factory_name,
arg_ast: Macro.escape(arg_ast, unquote: true),
user_var: Macro.escape(user_var, unquote: true),
opts: opts,
block: Macro.escape(block, unquote: true)
] do
parent_factory_opts = Module.get_attribute(__MODULE__, :parent_factory_opts)
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.merge(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)
build_struct? = merged_opts[:build_struct?]
hooks = merged_hooks
insert? = merged_opts[:insert?]
repo = merged_opts[:repo]
struct = merged_opts[:struct]
# Generate params builder function - builds head inline from shared arg_ast
def unquote({:"build_#{factory_name}_params", [], [arg_ast]}) 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 params list builder function
# List factory approach inspired by ExMachina's build_list/insert_list functions
# See: https://github.com/thoughtbot/ex_machina
def unquote(:"build_#{factory_name}_params_list")(count)
when is_integer(count) and count >= 0 do
unquote(:"build_#{factory_name}_params_list")(count, %{})
end
def unquote(:"build_#{factory_name}_params_list")(count, params)
when is_integer(count) and count >= 0 and is_map(params) do
Stream.repeatedly(fn -> unquote(:"build_#{factory_name}_params")(params) end)
|> Enum.take(count)
end
if struct != nil and build_struct? != false do
# Generate struct builder function - builds head inline from shared arg_ast
def unquote({:"build_#{factory_name}_struct", [], [arg_ast]}) do
unquote(user_var)
|> unquote(:"build_#{factory_name}_params")()
|> then(&FactoryMan.get_hook_handler(unquote(hooks), :before_build_struct).(&1))
|> then(&struct!(unquote(struct), &1))
|> then(&FactoryMan.get_hook_handler(unquote(hooks), :after_build_struct).(&1))
end
# Generate struct list builder function
# List factory approach inspired by ExMachina's build_list/insert_list functions
# See: https://github.com/thoughtbot/ex_machina
def unquote(:"build_#{factory_name}_struct_list")(count)
when is_integer(count) and count >= 0 do
unquote(:"build_#{factory_name}_struct_list")(count, %{})
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
is_insertable_ecto_schema_factory? =
((not is_nil(repo) and Code.ensure_compiled!(struct)) &&
function_exported?(struct, :__schema__, 1)) and struct.__schema__(:source) != nil
if is_insertable_ecto_schema_factory? and insert? != false do
# Generate struct insert functions - builds head inline from shared arg_ast
def unquote({:"insert_#{factory_name}!", [], [arg_ast]})
def unquote(:"insert_#{factory_name}!")(repo_insert_opts)
when is_list(repo_insert_opts) do
unquote(:"insert_#{factory_name}!")(%{}, repo_insert_opts)
end
def unquote(:"insert_#{factory_name}!")(unquote(user_var)) do
unquote(:"insert_#{factory_name}!")(unquote(user_var), [])
end
def unquote(:"insert_#{factory_name}!")(
unquote(user_var),
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
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
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
end
end
@doc """
Evaluate lazy attributes in a map or struct.
Functions with 0 arity are called with no arguments.
Functions with 1 arity receive the parent factory as their argument.
## Examples
iex> FactoryMan.evaluate_lazy_attributes(%{name: "test", timestamp: fn -> 12345 end})
%{name: "test", timestamp: 12345}
iex> FactoryMan.evaluate_lazy_attributes(%{first: "John", last: fn attrs -> attrs.first <> " Smith" end})
%{first: "John", last: "John Smith"}
"""
@spec evaluate_lazy_attributes(struct | map) :: struct | map
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
defp do_evaluate_lazy_attributes(attrs, parent_factory) do
attrs
|> Enum.map(fn
{k, v} when is_function(v, 1) -> {k, v.(parent_factory)}
{k, v} when is_function(v) -> {k, v.()}
{_, _} = tuple -> tuple
end)
|> Enum.into(%{})
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 """
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