The latest release of ExMachina gives you much finer control over how to define passed attributes.
The most recent release of the ExMachina
Elixir package (version 2.3.0
) includes a long-awaited feature;
better control over how factories are defined. You can now accept an optional argument when writing the factories, which
is a map of all the attributes that are passed to it when build/2
, insert/2
or another ExMachina method is called.
This allows you to use conditionals or other logic in the factory that might have otherwise been called from auxiliary methods. For a large test suite, like the one we have at Slab, this makes the test code much cleaner and easier to maintain.
To understand this better, let's look at the article_factory
example in ExMachina's documentation:
def article_factory do
title = sequence(:title, &"Use Slab! (Part #{&1})")
slug = Article.title_to_slug(title)
%Article{
title: title,
slug: slug,
author: build(:user),
}
end
This factory works great until you pass a custom title. If you do, the value of slug
is still derived from the title
generated through sequence
within the factory.
insert(:article)
# => %Article{title: "Use Slab! (Part 1)", slug: "use-slab-part-1", author: "..."}
insert(:article, title: "Better Team Wikis")
# => %Article{title: "Better Team Wikis", slug: "use-slab-part-2", author: "..."}
This has usually been solved by using custom strategies in ExMachina or calling wrapper functions around the factories. But these approaches are either excessive or defeat the purpose of using ExMachina for factories in the first place because your schema-building logic now lives in multiple locations.
Even if you use another helper to avoid duplicating the code that derives these attributes, you will now have to call
build/2
on the factories first and pipe them into helper methods before finally inserting them.
def article_factory do
article =
%Article{
title: sequence(:title, &"Use Slab! (Part #{&1})",
author: build(:user),
}
build_slug(article)
end
def build_slug(%Article{} = article) do
%{ article | slug: Article.title_to_slug(article.title) }
end
build(:article, title: "Better Team Wikis")
|> build_slug()
|> insert()
# => %Article{title: "Better Team Wikis", slug: "better-team-wikis", author: "..."}
For more involved schemas, this can make the application code much more complex, lead to a lot of code repetition and can generate confusing results if one forgets to not call the helper methods.
ExMachina now allows you to define factories with a map
as an additional argument. This lets you derive values that
depend on other attributes or define other fancy logic without duplicating it in different places in the application. The
same factory would now look like this, while reliably working with insert(:article)
and other ExMachina methods:
def article_factory(attrs) do
title = attrs[:title] || sequence(:title, &"Use Slab! (Part #{&1})")
article =
%Article{
title: title,
slug: Article.title_to_slug(title),
author: build(:user),
}
merge_attributes(article, attrs)
end
Notice the new merge_attributes/2
function call at the end. Since this is a lower layer abstraction which attempts to
provide more control over how factories are built, it doesn't automatically merge the passed attributes, so they have to
be explicitly merged when defining the factory.
The new API is especially helpful when dealing with complex schemas where multiple values might be derived from other attributes, while also allowing you to lazily build associations when not present.
def history_factory(attrs) do
post = Map.get_lazy(attrs, :post, fn -> insert(:post) end)
author = Map.get_lazy(attrs, :author, fn -> post.owner end)
delta = [
%{delete: 0},
%{insert: "Hello", attributes: %{author: author.id}},
]
history =
%History{
delta: delta,
post: post,
author: author,
snapshot: post.content,
organization: post.organization,
revisions: [%{delta: delta}],
contributors: %{author.id => 1},
version_from: 1,
version_to: 2,
}
merge_attributes(history, attrs)
end
For simple schemas, you can continue to write them the old way; without the additional argument and manually merging the attributes, and they'll keep working as expected. Though it is worth pointing out that only one method must be defined per factory. If both are present, ExMachina will invoke the one which accepts the attribute map as an argument and ignore the other one.
Now that we have full control of the factories when defining them with one argument, we can also build and return non-map values. Here's an example from the documentation:
def room_number_factory(attrs) do
floor = attrs[:floor] || 1
sequence(:room_number, &(floor * 100 + &1))
end
build(:room_number)
# => 100
build(:room_number, floor: 3)
# => 301
A slightly more complex non-map factory, like the delta
array above, might look something like this:
def delta_factory(attrs) do
author = Map.get_lazy(attrs, :author, fn -> insert(:user) end)
content = sequence(:content, &"Delta Content #{&1}")
[
%{delete: 0},
%{insert: content, attributes: %{author: author.id}},
]
end
Though this is a nice-to-have, I believe you should use pure helpers instead of non-map factories unless you absolutely
need to rely on sequence/2
or other factory calls (which you can still access outside your factory module). It is also
important to note that you can't use them with Ecto
, so calling insert(:delta)
or params_for(:room_number)
will
raise an exception.
The new updates to ExMachina are definitely a welcome change, and something that I have personally been looking forward to. They've already helped reduce the complexity surrounding factories in Slab's tests and made it simpler for other engineers to use them without thinking about how they're implemented underneath or worrying if certain helper methods need to be called before inserting a record.
And though I do see the potential use-cases for non-map factories, I am not completely sold on the idea and we still prefer using pure helper functions at Slab instead. To find out more about the discussion that went into finalizing this feature, you can check out the Github Issue.