Published on

Simple fixtures in ExUnit

Years back, when I moved from the Rails world to Elixir, it seemed natural to just use ExMachina in place of Factory Girl (now called Factory Bot). I didn’t question why I needed a library to set up fixtures, this was just the way to do it.

I didn’t realize that, out of the box, I had everything needed to set up fixture helpers in ExUnit. ExMachina was unnecessary and mostly just obfuscated stuff.

No validations

The biggest pain point I experienced is that none of my Ecto validations runs with ExMachina. As my codebase changed I didn’t see bugs surface from my business logic when using ExMachina fixtures. There were also cases of false positives where it wasn’t immediately obvious I needed to update the fixture. Attributes were either not set, or the context function would set attributes differently than the fixture.

You may find a way to hack it, but ultimately for tests to be accurate we need to trust that our data model is accurate. Bleeding the boundary between the data schema and the context API was a recipe for problems down the road.

Bloat

In Ruby-land build made sense since you are dealing with object state. Elixir being functional you are dealing with data transformations. Ecto lets the database deal with database decisions, so the data has to get to the database, and you end up either only using insert or combining build and Repo.insert for everything.

Lists, pairs, and params, are all very trivial to write out in tests. If it’s only a couple of lines to do something then why depend on a library for it? Much better to have concise legible code than abstract it away in a function hidden in a dependency.

Obfuscation

Defining the function organization_factory and then calling insert(:organization) is not easy to reason about. What is this :organization atom? It would have made a lot more sense with a macro like factory :organization do.

Dealing with multiple repos and using the use ExMachina.Ecto, repo: MyApp.Repo macro, you are forced to find workarounds. Like combining build and Repo.insert, because the repo is implicitly used in the insert function.

Use the context API

A better option is to just use the context functions. We already got everything we need with ExUnit and the Ecto sandbox repo. We can prevent bleeding the boundaries in our tests between the data schema and context API. Our fixtures will be kept explicit, concise, and comprehensible.

First, we set up a fixtures module calling our context functions:

# test/support/fixtures.ex
defmodule MyApp.Fixtures do
  alias MyApp.Organizations

  @organization_attrs %{name: "Example Organization"}

  def organization_fixture(attrs \\ %{}) do
    attrs = Map.merge(@organization_attrs, attrs)

    {:ok, organization} = Organizations.create_organization(attrs)

    organization
  end
end

Then import or alias the fixtures module in the MyApp.DataCase using macro:

# test/support/data_case.ex
defmodule MyApp.DataCase do
  use ExUnit.CaseTemplate

  using do
    quote do
      alias MyApp.Repo

      import Ecto
      import Ecto.Changeset
      import Ecto.Query
      import MyApp.DataCase
      import MyApp.Fixtures # or `alias MyApp.Fixtures`
    end
  end

Now we can use the fixtures in tests:

# test/my_app/organizations_test.exs
defmodule MyApp.OrganizationsTest do
  use MyApp.DataCase

  setup do
    {:ok, organization: organization_fixture()}
  end

  test "update_organization/1", %{organization: organization} do
    # ...
  end
end

This is how I do it in all projects. It’s been effortless to work with.

Drift

The benefit of this is that we’ll catch drift right away. MatchError is raised if the context function fails:

 1) test update_organization/1 (MyApp.OrganizationsTest)
     test/my_app/organizations_test.exs:8
     ** (MatchError) no match of right hand side value: {:error, #Ecto.Changeset<action: :insert, changes: %{}, errors: [description: {"can't be blank", [validation: :required]}], data: #MyApp.Organizations.Organization<>, valid?: false>}
     stacktrace:
       (my_app 0.1.0) test/support/fixtures.ex:8: MyApp.Fixtures.organization_fixture/1
       test/my_app/organizations_test.exs:6: MyApp.OrganizationsTest.__ex_unit_setup_4_0/1
       MyApp.OrganizationsTest.__ex_unit_describe_4/1

Sequences with unique constraints

In the Ecto sandbox transaction, everything is isolated to the individual test processes. That makes it easy to deal with unique constraints. We only need to store an accumulator in the test process dictionary:

# test/support/fixtures.ex
defmodule MyApp.Fixtures do
  alias MyApp.Organizations

  @organization_attrs %{name: "Example Organization"}

  def organization_fixture(attrs \\ %{}) do
    attrs = Map.merge(unique_organization_attrs(), attrs)

    {:ok, organization} = Organizations.create_organization(attrs)

    organization
  end

  defp unique_organization_attrs do
    n = Process.get(:organization_fixture, 0)
    Process.put(:organization_fixture, n + 1)

    Map.put(@organization_attrs, :email, "test-#{n}@example.com")
  end
end

Associations

Expose belong-to associations in the fixture function header:

# test/support/fixtures.ex
defmodule MyApp.Fixtures do
  # ...

  def account_fixture(organization, attrs \\ %{}) do
    attrs = Map.merge(@account_attrs, attrs)

    {:ok, account} = Organizations.create_account(organization, attrs)

    account
  end
end

In tests we will call both fixture functions:

# test/my_app/organizations_test.exs
defmodule MyApp.OrganizationsTest do
  use MyApp.DataCase

  # ...

  describe "update_account/2" do
    setup do
      organization = organization_fixture()
      account = account_fixture(organization)

      {:ok, organization: organization, account: account}
    end

    test "updates", %{account: account} do
      # ...
    end
  end
end

There’s no need to be clever about this. Don’t make account_fixture() automatically create the belong-to association. We would end up having to pass in a keyword list or optional argument when creating multiple fixtures with the same association. Save yourself the trouble. Keep it boring and explicit!

Lists, pairs, params

We can just use comprehension in the test to create the list or pairs of fixtures:

# test/my_app/organizations_test.exs
defmodule MyApp.OrganizationsTest do
  use MyApp.DataCase

  # ...

  describe "list_accounts/0" do
    setup do
      accounts =
        for _ <- 1..10 do
          account_fixture(organization_fixture())
        end

      {:ok, accounts: accounts}
    end

    test "lists", %{accounts: accounts} do
      # ...
    end
  end
end

If we need to generate params then we can expose the defaults as a function:

# test/support/fixtures.ex
defmodule MyApp.Fixtures do
  alias MyApp.Organizations

  @organization_attrs %{name: "Example Organization"}

  def organization_attrs, do: @organization_attrs

  # ...
end

We tend to believe that we should use a dependency because everybody else is using it. It’s good to take a step back and question if a dependency makes sense for your project. Rolling your own can often be the right answer if you only end up using a minimal amount of the dependency.

Hi, I'm Dan Schultzer, I write in this blog, work a lot in Elixir, maintain several open source projects, and help companies streamline their development process