Packages

A test fixtures library based on Ecto

Current section

Files

Jump to
factoid lib factoid.ex
Raw

lib/factoid.ex

defmodule Factoid do
@moduledoc ~S"""
Documentation for `Factoid`.
Factoid is a library for generating test data using factories build on Ecto.Schemas.
It's similar to [ExMachina](https://github.com/thoughtbot/ex_machina) but it is not a drop-in
replacement. Where ExMachina builds and keeps associations in the returned records, Factoid drops
the associations and keeps the associated `id` fields. This opinionated approach helps building
simpler tests.
## Installation
Add `factoid` to the list of dependencies in `mix.exs`:
```elixir
def deps do
[{:factoid, "~> 0.1"}]
end
```
## Usage
Define a factory module that uses the `Factoid` behaviour, select the repo module, and define
`build/2` functions for each factory.
```elixir
def App.Factory do
@behaviour Factoid
use Factoid, repo: App.Repo
alias App.Schemas.User
alias App.Schemas.UserAvatar
@impl Factoid
def build(factory, attrs \\ %{})
def build(:user, attrs) do
%User{
first_name: "Jane",
last_name: "Doe",
email_address: "jane-#{unique_integer()}@example.com",
}
|> Map.merge(attrs)
end
def build(:user_avatar, attrs) do
%UserAvatar{
user: build(:user)
}
|> Map.merge(attrs)
end
end
```
In your tests you can import your factory and use it to create test data.
```elixir
def App.ModuleTest do
use DataCase
import App.Factory
alias App.Repo
alias App.Schemas.User
test "creates a user" do
user_1 = insert(:user)
user_2 = insert(:user, name: "John")
assert user_1 != user_2
assert user_1 == Repo.get(User, user_1.id)
assert user_2.name == "John"
end
end
```
"""
@typedoc false
@type factory_name :: atom()
@typedoc false
@type record :: struct()
@typedoc false
@type field :: atom()
@typedoc false
@type value :: term()
@typedoc false
@type attrs :: map() | keyword()
@doc """
Builds a record.
"""
@callback build(factory_name()) :: term()
@doc """
Builds a record with attributes.
"""
@callback build(factory_name(), attrs()) :: term()
@doc false
defmacro __using__(opts) do
quote bind_quoted: [opts: opts] do
import Factoid
@repo Keyword.get(opts, :repo)
@typedoc false
@type factory_name :: atom()
@typedoc false
@type record :: struct()
@typedoc false
@type attrs :: map() | keyword()
@doc """
Inserts a record with attributes.
"""
@spec insert(factory_name(), attrs()) :: record()
def insert(factory_name, attrs \\ %{}) do
insert(@repo, factory_name, &build/2, attrs)
end
end
end
@doc """
Inserts a record with attributes.
"""
@spec insert(module(), factory_name(), fun(), attrs()) :: record()
def insert(repo, factory_name, build, attrs \\ %{})
def insert(repo, factory_name, build, attrs) when is_map(attrs) do
factory_name
|> build.(attrs)
|> repo.insert!(returning: true)
|> drop_associations()
end
def insert(repo, factory_name, build, attrs) when is_list(attrs),
do: insert(repo, factory_name, build, Map.new(attrs))
@doc """
Generates a systemically unique integer.
"""
@spec unique_integer :: non_neg_integer()
def unique_integer, do: System.unique_integer([:positive])
@doc """
Generates a UUID.
"""
@spec unique_uuid :: Ecto.UUID.t()
def unique_uuid, do: Ecto.UUID.generate()
@spec drop_associations(record()) :: record()
defp drop_associations(%{__struct__: struct} = schema) do
struct.__schema__(:associations)
|> Enum.reduce(schema, fn association, schema ->
%{schema | association => build_not_loaded(struct, association)}
end)
end
defp build_not_loaded(struct, association) do
%{
cardinality: cardinality,
field: field,
owner: owner
} = struct.__schema__(:association, association)
%Ecto.Association.NotLoaded{
__cardinality__: cardinality,
__field__: field,
__owner__: owner
}
end
end