Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Introduce build_lazy/2 #406

Closed
wants to merge 2 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 83 additions & 3 deletions lib/ex_machina.ex
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ defmodule ExMachina do
ExMachina.build(__MODULE__, factory_name, attrs)
end

def build_lazy(factory_name, attrs \\ %{}) do
ExMachina.build_lazy(__MODULE__, factory_name, attrs)
end

def build_pair(factory_name, attrs \\ %{}) do
ExMachina.build_pair(__MODULE__, factory_name, attrs)
end
Expand Down Expand Up @@ -165,8 +169,16 @@ defmodule ExMachina do


If you want full control over the factory attributes, you can define the
factory with `[factory_name]_factory/1`. Note that you will need to merge the
attributes passed if you want to emulate ExMachina's default behavior.
factory with `[factory_name]_factory/1`.

Caveats:

- ExMachina will no longer merge the attributes for your factory. If you want
to do that, you can merge the attributes with the `merge_attributes/2` helper.

- You cannot use `c:build_lazy/2` within the factory definition, since ExMachina
will no longer perform it's usual evaluation of lazy factories after the
factory creation.

## Example

Expand All @@ -192,20 +204,53 @@ defmodule ExMachina do
@doc false
def build(module, factory_name, attrs \\ %{}) do
attrs = Enum.into(attrs, %{})

function_name = build_function_name(factory_name)

cond do
factory_accepting_attributes_defined?(module, function_name) ->
attrs = evaluate_lazy_factories(attrs)
apply(module, function_name, [attrs])

factory_without_attributes_defined?(module, function_name) ->
apply(module, function_name, []) |> merge_attributes(attrs)
apply(module, function_name, [])
|> merge_attributes(attrs)
|> evaluate_lazy_factories()

true ->
raise UndefinedFactoryError, factory_name
end
end

defp evaluate_lazy_factories(%{__struct__: record} = factory) do
struct!(
record,
factory |> Map.from_struct() |> evaluate_lazy_factories()
)
end

defp evaluate_lazy_factories(attrs) when is_map(attrs) do
attrs
|> Enum.map(fn
{k, %ExMachina.InstanceTemplate{} = v} ->
{k, ExMachina.InstanceTemplate.evaluate(v)}

{k, list} when is_list(list) ->
{k, evaluate_lazy_factories_in_list(list)}

{_, _} = tuple ->
tuple
end)
|> Enum.into(%{})
end

defp evaluate_lazy_factories_in_list(list) do
Enum.map(list, fn
%ExMachina.InstanceTemplate{} = instance -> ExMachina.InstanceTemplate.evaluate(instance)
item -> item
end)
end

defp build_function_name(factory_name) do
factory_name
|> Atom.to_string()
Expand All @@ -221,6 +266,41 @@ defmodule ExMachina do
Code.ensure_loaded?(module) && function_exported?(module, function_name, 0)
end

@doc """
Builds a factory instance that won't be evaluated immediately. As such, this
function should not be used on its own, but should be combined with
`c:build/2`, `c:build_pair/2`, `c:build_list/3`, or it should be used in a
factory definition.

`build_lazy/2` is evaluated as part of one of the other functions, and it is
particularly useful when using it with `c:build_pair/2` or `c:build_list/3`.

For example, people might want to build a separate user per account.

## Example

def user_factory do
%{name: "John Doe", username: sequence("johndoe")}
end

# build/2 is eager
build_pair(:account, user: build(:user))

# so it's equivalent to this
user = build(:user)
build_pair(:account, user: user) # same user for both accounts

# to get a separate user struct per account, use build_lazy/2
build_pair(:account, user: build_lazy(:user))
"""
@callback build_lazy(factory_name :: atom) :: any
@callback build_lazy(factory_name :: atom, attrs :: keyword | map) :: any

@doc false
def build_lazy(module, factory_name, attrs \\ %{}) do
%ExMachina.InstanceTemplate{module: module, name: factory_name, attrs: attrs}
end

@doc """
Helper function to merge attributes into a factory that could be either a map
or a struct.
Expand Down
10 changes: 10 additions & 0 deletions lib/ex_machina/instance_template.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
defmodule ExMachina.InstanceTemplate do
@moduledoc false

defstruct [:module, :name, :attrs]

@doc false
def evaluate(%{module: module, name: name, attrs: attrs}) do
apply(module, :build, [name, attrs])
end
end
28 changes: 17 additions & 11 deletions priv/test_repo/migrations/1_migrate_all.exs
Original file line number Diff line number Diff line change
Expand Up @@ -3,23 +3,29 @@ defmodule ExMachina.TestRepo.Migrations.MigrateAll do

def change do
create table(:users) do
add :name, :string
add :admin, :boolean
add :net_worth, :decimal
add(:name, :string)
add(:admin, :boolean)
add(:net_worth, :decimal)
end

create table(:publishers) do
add(:pub_number, :string)
end

create(unique_index(:publishers, [:pub_number]))
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added this uniqueness constraint to properly test the scenario in ecto where insert_pair(:account, publisher: build(:publisher)) would raise a constraint error.


create table(:articles) do
add :title, :string
add :author_id, :integer
add :editor_id, :integer
add :publisher_id, :integer
add :visits, :decimal
add(:title, :string)
add(:author_id, :integer)
add(:editor_id, :integer)
add(:publisher_id, :integer)
add(:visits, :decimal)
end

create table(:comments) do
add :article_id, :integer
add :author, :map
add :links, {:array, :map}, default: []
add(:article_id, :integer)
add(:author, :map)
add(:links, {:array, :map}, default: [])
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Autoformatter added all the parentheses.

end
end
end
19 changes: 19 additions & 0 deletions test/ex_machina/ecto_test.exs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
defmodule ExMachina.EctoTest do
use ExMachina.EctoCase

alias ExMachina.Article
alias ExMachina.Publisher
alias ExMachina.TestFactory
alias ExMachina.User

Expand Down Expand Up @@ -65,6 +67,23 @@ defmodule ExMachina.EctoTest do
test "insert_list/3 handles the number 0" do
assert [] = TestFactory.insert_list(0, :user)
end

test "build_lazy records get evaluated with insert/2 and insert_* functions" do
assert %Article{publisher: %Publisher{}} =
TestFactory.insert(:article, publisher: TestFactory.build_lazy(:publisher))

[%Article{publisher: publisher1}, %Article{publisher: publisher2}] =
TestFactory.insert_pair(:article, publisher: TestFactory.build_lazy(:publisher))

assert publisher1 != publisher2

[publisher1, publisher2, publisher3] =
TestFactory.insert_list(3, :article, publisher: TestFactory.build_lazy(:publisher))

assert publisher1.author != publisher2.author
assert publisher2.author != publisher3.author
assert publisher3.author != publisher1.author
end
end

describe "params_for/2" do
Expand Down
99 changes: 89 additions & 10 deletions test/ex_machina_test.exs
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
defmodule ExMachinaTest do
use ExUnit.Case

defmodule FooBar do
defstruct [:name]
end

defmodule Factory do
use ExMachina

Expand All @@ -12,6 +16,20 @@ defmodule ExMachinaTest do
}
end

def profile_factory do
%{
username: sequence("username"),
user: build(:user)
}
end

def account_factory do
%{
private: true,
profile: build_lazy(:profile)
}
end

def email_factory do
%{
email: sequence(:email, &"me-#{&1}@foo.com")
Expand All @@ -24,10 +42,8 @@ defmodule ExMachinaTest do
}
end

def struct_factory do
%{
__struct__: Foo.Bar
}
def foo_bar_factory do
%FooBar{}
end

def comment_factory(attrs) do
Expand Down Expand Up @@ -94,16 +110,15 @@ defmodule ExMachinaTest do

test "build/2 raises if passing invalid keys to a struct factory" do
assert_raise KeyError, fn ->
Factory.build(:struct, doesnt_exist: true)
Factory.build(:foo_bar, doesnt_exist: true)
end
end

test "build/2 allows factories to have full control of provided arguments" do
assert Factory.build(:comment, name: "James") == %{
author: "James Doe",
username: "James-0",
name: "James"
}
comment = Factory.build(:comment, name: "James")

assert %{author: "James Doe", name: "James"} = comment
assert String.starts_with?(comment[:username], "James-")
end

test "build/2 allows custom (non-map) factories to be built" do
Expand All @@ -112,6 +127,70 @@ defmodule ExMachinaTest do
end
end

describe "build_lazy/2" do
test "build_lazy/2 returns a struct presentation of the factory to build" do
%ExMachina.InstanceTemplate{} = factory = Factory.build_lazy(:user)

assert ExMachina.InstanceTemplate.evaluate(factory) == %{
id: 3,
name: "John Doe",
admin: false
}
end

test "build_lazy/3 accepts arguments" do
%ExMachina.InstanceTemplate{} = factory = Factory.build_lazy(:user, name: "Jane Doe")

assert ExMachina.InstanceTemplate.evaluate(factory) == %{
id: 3,
name: "Jane Doe",
admin: false
}
end

test "build_lazy/2 can be used in a factory definition" do
account = Factory.build(:account)

assert %{username: _} = account.profile
end

test "build_lazy/2 can be used with struct factories" do
user = Factory.build(:user, foo_bar: Factory.build_lazy(:foo_bar))

assert %FooBar{} = user.foo_bar
end

test "build_lazy/2 is evaluated before being passed to factories with full control" do
comment = Factory.build(:comment, name: "James", user: Factory.build_lazy(:user))

assert %{id: 3, name: "John Doe", admin: false} = comment.user
end

test "build/2 recursively builds nested build_lazy/2 factories" do
lazy_profile = Factory.build_lazy(:profile, user: Factory.build_lazy(:user))
account = Factory.build(:account, profile: lazy_profile)

assert %{username: _} = account.profile
assert %{name: "John Doe", admin: false} = account.profile.user
end

test "build_list/2 recursively builds many nested build_lazy/2 factories" do
lazy_profile = Factory.build_lazy(:profile, user: Factory.build_lazy(:user))
[account1, account2] = Factory.build_pair(:account, profile: lazy_profile)

assert account1.profile.username != account2.profile.username
end

test "build_lazy/2 gets evaluated when is part of a list" do
user = Factory.build(:user, profiles: [Factory.build_lazy(:profile)])

profile = hd(user.profiles)

assert Map.has_key?(profile, :username)
assert Map.has_key?(profile, :user)
end
end

describe "build_pair/2" do
test "build_pair/2 builds 2 factories" do
records = Factory.build_pair(:user, admin: true)
Expand Down
3 changes: 2 additions & 1 deletion test/support/models/publisher.ex
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
defmodule ExMachina.Publisher do
use Ecto.Schema

schema "users" do
schema "publishers" do
field(:pub_number, :string)
end
end
4 changes: 3 additions & 1 deletion test/support/test_factory.ex
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@ defmodule ExMachina.TestFactory do
end

def publisher_factory do
%ExMachina.Publisher{}
%ExMachina.Publisher{
pub_number: sequence("PUB_23")
}
end

def article_factory do
Expand Down