Elixir Test Factories - SolarisJapan/lunaris-wiki GitHub Wiki
Test Factories
Ex Machina
One dependency worth mention is Ex Machina by Thoughtbot (those behind Ruby's FactoryBot): https://github.com/thoughtbot/ex_machina.
There are however some minor inconveniences when using this dependency. Interconnected associations to certain models were somewhat difficult to create and associate in the proper order. Also dividing app models into multiple schemas under different contexts created issues as well. If the context where an association is created is not responsible for the creation of the current model, then this must all be simulated in-order with Ex Machina. Potentially a test context for schemas like those explained below will provide easier use of Ex Machina as well.
Custom Factories
One alternative is custom factories which has proven not too difficult. The following code snippet is an example factory for a Shop. Any fields within the @attributes
array will be set to a default value given by defaults/2
. This specific example also shows the possibility for the defaults to consider other attributes already built.
For example in this app a Shop belonged to a ShopGroup and a Merchant. However, a ShopGroup also belonged to a Merchant. This is perhaps not always the most normalized way to structure a DB, but was useful in this case. This would mean that errors are likely if the Shop belonged to a different Merchant than its ShopGroup.
Therefore if a Merchant or ShopGroup already exists in the built attributes, then the other model will use its id for the association between them. This also allows that it is unimportant in which order the default values are loaded as they should always be associated to one another. Even if the user manually inputs one of the values it should function properly.
The use of this factory would require a schema with changesets for each factory as well (in this example Test.Shop).
defmodule Core.Factories.ShopFactory do
alias Core.{Test, Repo, Factories}
@attributes ~w(name shop_group_id merchant_id uid)a
@doc """
Returns a Shop struct populated with default or given attributes.
"""
def build(module, attrs \\ %{}) do
struct(module, attributes(attrs))
end
@doc """
Returns a DB persisted Shop struct populated with default or given attributes.
"""
def insert(module, attrs \\ %{}) do
{:ok, shop} = struct(Test.Shop, attributes(attrs)) |> Repo.insert()
Repo.get!(module, shop.id)
end
@doc """
Returns a map populated with Shop default or given attributes.
"""
def attributes(built \\ %{}, attribs \\ @attributes)
def attributes(built, [attrib]) do
Map.put_new_lazy(built, attrib, fn -> defaults(attrib, built) end)
end
def attributes(built, [attrib | next_attribs]) do
attributes(attributes(built, [attrib]), next_attribs)
end
defp defaults(:shop_group_id, %{merchant_id: merchant_id}) do
Factories.ShopGroupFactory.insert(Test.ShopGroup, %{merchant_id: merchant_id}).id
end
defp defaults(:merchant_id, %{shop_group_id: shop_group_id}) do
Repo.get!(Test.ShopGroup, shop_group_id).merchant_id
end
defp defaults(key, _attrs) do
case key do
:name -> "shop_#{System.unique_integer([:positive])}"
:shop_group_id -> Factories.ShopGroupFactory.insert(Test.ShopGroup).id
:merchant_id -> Factories.MerchantFactory.insert(Test.Merchant).id
:uid -> UUID.uuid1()
end
end
end
Note that with the given factory above, one is expected to pass the id of an association rather than the struct itself. This is also to avoid concerns of contextualization you might face otherwise.
Areas for improvement
This factory scheme is still a work in progress. There would be less overhead in the testing scheme without needing to maintain both a contextualized schema and a test schema. As such the factory function expected to build and create models could use the same schema as used for that purpose within the app. Arguably testing is itself a context and as such is worth the overhead.
It would also be beneficial to rework the compile-time @attributes
array. This also adds a slight but inconvenient amount of maintenance for the factories. Adding a new field with a factory default requires adding that field to this array.
The factories in a project are also nearly identical to one another. The only differences are their default function and @attributes
array. Perhaps a more meta approach would extract the insert
, build
, and attributes
functions into a Factory module for less overhead.