Factori

Test data without boilerplate. Always in-sync with your database schema.

defmodule MyAppTest.Factory do
  use Factori, repo: MyApp.Repo, mappings: [Factori.Mapping.Faker, Factori.Mapping.Enum]
end

user = MyAppTest.Factory.insert("users")
user.first_name # => "Lorem"
user.last_name # => "Ipsum"

Installation

In mix.exs, add the factori dependency:

def deps do
  [
    {:factori, "~> 0.1"},
  ]
end

Overview

Define your Factory module with the repo (typically in test/support).

defmodule MyAppTest.Factory do
  use Factori,
    repo: MyApp.Repo,
    mappings: [Factori.Mapping.Enum, Factori.Mapping.Embed, Factori.Mapping.Faker]
end

Initialize the module by checking out the Repo and boostraping the Factory.

Given a typical data_case.ex:

setup tags do
  setup_sandbox(tags)

  :ok
end

def setup_sandbox(tags) do
  pid = Ecto.Adapters.SQL.Sandbox.start_owner!(MyApp.Repo, shared: not tags[:async])
  on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end)
end

Add this in your test_helper.exs so that the bootstrap is only done once:

ExUnit.start()

Ecto.Adapters.SQL.Sandbox.checkout(MyApp.Repo)
MyApp.Factory.bootstrap()
Ecto.Adapters.SQL.Sandbox.checkin(MyApp.Repo)

Ecto.Adapters.SQL.Sandbox.mode(MyApp.Repo, :manual)

Usage

In a test case, just use your Factory module by referencing the table name

test "insert user" do
  user = MyApp.Factory.insert("users")
  assert user.id
end

Overrides

test "insert user with overrides" do
  user = Factory.insert("users", name: "Test")
  assert user.name === "Test"
end

Mappings

Mappings are modules or functions used to map data to columns. factori ships with a Faker integration that insert valid data from the type of the column. You can add your own mapper before Faker to override the data mapping:

defmodule MyAppTest.MappingCustom do
  @behaviour Factori.Mapping
  def match(%{name: :name}), do: "bar"
end

defmodule MyAppTest.Factory do
  use Factori,
    repo: MyApp.Repo,
    mappings: [fn %{name: :name} -> "foo" end, MappingCustom, Factori.Mapping.Faker]
end

test "mappings" do
  user = Factory.insert("users")
  assert user.name === "foo"
end

Mappings also supports transforming data. This can be useful when we want random data but with a bit more control before inserting into the database: In the example, the custom module does not implement the mapping, so the Faker one is taken. Then, the transform/2 is called to alter the data.

defmodule MyAppTest.Transform do
  @behaviour Factori.Mapping
  def transform(%{name: :password}, value), do: Bcrypt.hash_pwd_salt(value)
end

defmodule MyAppTest.Factory do
  use Factori,
    repo: MyApp.Repo,
    mappings: [Transform, Factori.Mapping.Faker]
end

test "transforms" do
  user = Factory.insert("users", password: "test123")
  assert user.password === "$2b$12$3.EX0EHSwjNewmD18Ir5A.brKyJh3.DCKzLjX96wCwovzie2I1wcW"
end

The first module to implement a matching match function will be taken, but the transform is called on every items in mappings options.

Variants

Instead of using string to reference the "raw" table names, you can use named variants:

defmodule MyAppTest.Factory do
  use Factori,
    repo: MyApp.Repo,
    mappings: [Factori.Mapping.Faker],
    variants: [{:user, "users"}]
end

MyAppTest.Factory.insert(:user)
MyAppTest.Factory.insert(:user, name: "Test")

Variants can also include overrides:

defmodule MyAppTest.Factory do
  use Factori,
    repo: MyApp.Repo,
    mappings: [Factori.Mapping.Faker],
    variants: [{:user, "users", name: "Test"}]
end

test "insert user with overrides" do
  user = Factory.insert(:user)
  assert user.name === "Test"

  user = Factory.insert(:user, name: "123")
  assert user.name === "123"
end

Null

The null? option allows for specifying a list of functions that determine whether a field should be generated with a null value, based on the column’s match. If there are no matches, the default behaviour is the nullability of the column. The use case is to have a nullable column in the database but always generate it in the factory.

defmodule MyAppTest.Factory do
  use Factori,
    repo: MyApp.Repo,
    mappings: [Factori.Mapping.Faker],
    null?: [fn %{name: :first_name} -> false end]
end

Reuse table references

By default, factori will reuse the same table reference when inserting data for the same row. This can be disabled by setting the prevent_reuse_table_references. The setting is a list of table pairs that should not reuse the same reference.

In the example, when inserting a post with a owner and an author, the same user will not be used for both columns.

defmodule MyAppTest.Factory do
  use Factori,
    repo: MyApp.Repo,
    mappings: [Factori.Mapping.Faker],
    prevent_reuse_table_references: [
      {"posts", "users"}
    ]
end

To always generate nullable value, you could have a catch-all function that returns false for every columns.

null? can also include a module that implement the null? function:

defmodule MyAppTest.NullUsers do
  @behaviour Factori.Null

  def null?(%{table_name: "users", name: :first_name}), do: false
end

Ecto and structs

defmodule MyApp.User do
  use Ecto.Schema

  schema "users" do
    field(:name, :string)
    field(:admin, :boolean)
  end
end

defmodule MyAppTest.Factory do
  use Factori,
    repo: MyApp.Repo,
    mappings: [Factori.Mapping.Faker],
    variants: [{:user, MyApp.User}, {:admin, MyApp.User, admin: true}]
end

test "insert ecto schema" do
  user = Factory.insert(:user)
  assert user.name

  admin = Factory.insert(:admin)
  assert admin.admin
end

Ecto struct can also be used directly as variant

Factory.insert(MyApp.User)

For devs running tests

Need env var, probably like this:

export DATABASE_URL=postgres://postgres@localhost/factori_test

License

factori is © 2023 Mirego and may be freely distributed under the New BSD license. See the LICENSE.md file.

About Mirego

Mirego is a team of passionate people who believe that work is a place where you can innovate and have fun. We’re a team of talented people who imagine and build beautiful Web and mobile applications. We come together to share ideas and change the world.

We also love open-source software and we try to give back to the community as much as we can.