Hi all,
How do you write your context tests (create_thingy(attrs)
) when there are a lot of associations that need to be created first?
Here is a contrived example. Let’s says I have a Door
model that I’d like to test the color
, material
, price
, etc…
A Door
cannot be created without having to first create a Wall
A Wall
cannot be created without having to first create a House
A House
cannot be created without having to first create a Blueprint
A Blueprint
cannot be created without having to first create a Contract
A Contract
cannot be created without having to first create a Client
A Client
cannot be created without having to first create a Prospect
and on and on…
I have a method in my Building
context that sort of looks like this:
def create_door(attrs \\ %{}) do
%Door{}
|> Door.changeset(attrs)
|> Repo.insert()
end
Ideally in my test I’d like to write something like:
{:ok, door} = create_door(%{price: 1_234, material: "wood", color: "white"})
assert door.color == "white"
However I cannot create the Door
without passing in a wall_id
and since that field cannot be null I’d need to create a Wall
. The creation process then has to go all the way up to … create_user. Surely I’ve missed something huge I can do that will still make me confident in the production code, but won’t make testing difficult.
Any tips or suggestions are greatly appreciated.
I am not very familiar with the approach in phoenix specifically, as I am pretty new to elixir. But this situation is very common in any other language.
I come from python world where this can be generally mitigated by 2 approaches:
- Data migrations. You basically create json or similar files with data, which you add to your test database before running the tests. I don’t really like this, as this is fairly fragile in my opinion
- You create factory classes (or methods|functions, etc.) that generate required objects with required parameters. So if you need a wall for a door your factory function will either get a wall as an argument of create a new one inside it. If to create a wall you need a house, it will create it inside its factory function, etc.
With second approach you will have to do a huge effort of creating this factories if your codebase is fairly big already. But they are easier to maintain as well as way more flexible for your tests )
One way to go about it is separating the door from the relationship to walls. Instead of door <- wall
have door <- wall_door -> wall
in the db. With a unique constraint on wall_door.door_id
you can make sure a way still only belongs to a single wall at most. Having done that on the db level you can do the same separation on the domain level: instead of just create_door
you’ll also have e.g. create_door_for_wall
. Once that is done you can test a door without needing a wall.
On a more concrete example. I’ve recently did contacts handling in an app, which was initially scoped to tenants. For less coupling I did introduce the concept of a contacts directory. I can easily create a directory for each tenant to use, but I don’t need a tenant, as they’re only coupled by name. I can easily create directories in tests without needing tenants and if I ever need contacts scoped to something else than tenants that’s fine as well.
One thing you can do is to define helper functions in your test_helper that you can use with ExUnit’s setup
callback.
If there’s not one in your context, you can define a test only API that handles all the setup for you:
def create_door_with_dependencies() do
user = create_user()
#...
door = Building.create_door(...)
{:ok, door: door}
end
Instead of calling Building.create_door
directly in your test you’d do something like this:
describe "some door feature when a door exists" do
setup [:create_door]
test "it does something", %{door: door} do
end
end
Or you may prefer to keep your setup functions more atomic and explicit and use context to store the dependencies:
def create_user(%{}) do
create_user()
{:ok, user: user}
end
...
def create_door(%{user: user, ...}) do
create_door(user_id: user.id, ...)
{:ok, door: door}
end
Then you just need to specify each dependency in the setup, which is pretty manageable even with the large number in your contrived example.
describe "some door feature when a door exists" do
setup [:create_user, ..., :create_door]
test "it does something", %{door: door} do
end
end