Finer Control over ExMachina Factories

The latest release of ExMachina gives you much finer control over how to define passed attributes.

Published: Mar 3, 2019

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.

The Old Way

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.

Using Passed Attributes

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.

Get Started with Slab

Non-Map Factories

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.

Wrapping Up

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.

Enjoying the post? Get notified when we publish a new article.
Subscribe
Get notified when we publish a new article