We can't find the internet
Attempting to reconnect
Something went wrong!
Hang in there while we get back on track
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