this repo has no description
1+++
2date = 2023-11-20
3title = "How do I write Elixir tests?"
4
5[taxonomies]
6tags = [
7 "elixir",
8 "testing",
9 "programming"
10]
11+++
12
13This post was created for myself to codify some basic guides that I use while
14writing tests. If you, my dear reader, read this then there is one important
15thing for you to remember:
16
17These are **guides** not *rules*. Each code base is different deviations and are
18expected and *will* happen. Just use the thing between your ears.
19
20## `@subject` module attribute for module under test
21
22While writing test in Elixir it is not always obvious what we are testing. Just
23imagine test like:
24
25```elixir
26test "foo should frobnicate when bar" do
27 bar = pick_bar()
28
29 assert :ok == MyBehaviour.foo(MyImplementation, bar)
30end
31```
32
33It is not obvious at the first sight what we are testing. And it is pretty
34simplified example, in real world it can became even harder to notice what is
35module under test (MUT).
36
37To resolve that I came up with a simple solution. I create module attribute
38named `@subject` that points to the MUT:
39
40```elixir
41@subject MyImplementation
42
43test "foo should frobnicate when bar" do
44 bar = pick_bar()
45
46 assert :ok == MyBehaviour.foo(@subject, bar)
47end
48```
49
50Now it is more obvious what is MUT and what is just wrapper code around it.
51
52In the past I have been using `alias` with `:as` option, like:
53
54```elixir
55alias MyImplementation, as: Subject
56```
57
58However I find module attribute to be more visually outstanding and make it
59easier for me to notice `@subject` than `Subject`. But your mileage may vary.
60
61## `describe` with function name
62
63That one is pretty basic, and I have seen that it is pretty standard for people:
64when you are writing tests for module functions, then group them in `describe`
65blocks that will contain name (and arity) of the function in the name. Example:
66
67```elixir
68# Module under test
69defmodule Foo do
70 def a(x, y, z) do
71 # some code
72 end
73end
74
75# Tests
76defmodule FooTest do
77 use ExUnit.Case, async: true
78
79 @subject Foo
80
81 describe "a/3" do
82 # Some tests here
83 end
84end
85```
86
87This allows me to see what functionality I am testing.
88
89Of course that doesn't apply to the Phoenix controllers, as there we do not test
90functions, but tuples in form `{method, path}` which I then write as `METHOD
91path`, for example `POST /users`.
92
93## Avoid module mocking
94
95In Elixir we have bunch of the mocking libraries out there, but most of them
96have quite substantial issue for me - these prevent me from using `async: true`
97for my tests. This often causes substantial performance hit, as it prevents
98different modules to run in parallel (not single tests, *modules*, but that is
99probably material for another post).
100
101Instead of mocks I prefer to utilise dependency injection. Some people may argue
102that "Elixir is FP, not OOP, there is no need for DI" and they cannot be further
103from truth. DI isn't related to OOP, it just have different form, called
104function arguments. For example, if we want to have function that do something
105with time, in particular - current time. Then instead of writing:
106
107```elixir
108def my_function(a, b) do
109 do_foo(a, b, DateTime.utc_now())
110end
111```
112
113Which would require me to use mocks for `DateTime` or other workarounds to make
114tests time-independent. I would do:
115
116```elixir
117def my_function(a, b, now \\ DateTime.utc_now()) do
118 do_foo(a, b, now)
119end
120```
121
122Which still provide me the ergonomics of `my_function/2` as above, but is way
123easier to test, as I can pass the date to the function itself. This allows me to
124run this test in parallel as it will not cause other tests to do weird stuff
125because of altered `DateTime` behaviour.
126
127## Avoid `ex_machina` factories
128
129I have poor experience with tools like `ex_machina` or similar. These often
130bring whole [Banana Gorilla Jungle problem][bgj] back, just changed a little, as
131now instead of just passing data around, we create all needless structures for
132sole purpose of test, even when they aren't needed for anything.
133
134[bgj]: https://softwareengineering.stackexchange.com/q/368797
135
136Start with example from [ExMachina README](https://github.com/beam-community/ex_machina#overview):
137
138```elixir
139defmodule MyApp.Factory do
140 # with Ecto
141 use ExMachina.Ecto, repo: MyApp.Repo
142
143 # without Ecto
144 use ExMachina
145
146 def user_factory do
147 %MyApp.User{
148 name: "Jane Smith",
149 email: sequence(:email, &"email-#{&1}@example.com"),
150 role: sequence(:role, ["admin", "user", "other"]),
151 }
152 end
153
154 def article_factory do
155 title = sequence(:title, &"Use ExMachina! (Part #{&1})")
156 # derived attribute
157 slug = MyApp.Article.title_to_slug(title)
158 %MyApp.Article{
159 title: title,
160 slug: slug,
161 # associations are inserted when you call `insert`
162 author: build(:user),
163 }
164 end
165
166 # derived factory
167 def featured_article_factory do
168 struct!(
169 article_factory(),
170 %{
171 featured: true,
172 }
173 )
174 end
175
176 def comment_factory do
177 %MyApp.Comment{
178 text: "It's great!",
179 article: build(:article),
180 author: build(:user) # That line is added by me
181 }
182 end
183end
184```
185
186For start we can see a single problem there - we do not validate our factories
187against our schema changesets. Without additional tests like:
188
189```elixir
190@subject MyApp.Article
191
192test "factory conforms to changeset" do
193 changeset = @subject.changeset(%@subject{}, params_for(:article))
194
195 assert changeset.valid?
196end
197```
198
199We cannot be sure that our tests test what we want them to test. And if we pass
200custom attribute values in some tests it gets even worse, because we cannot be
201sure if these are conforming either.
202
203That mean that our tests may be moot, because we aren't testing against real
204situations, but against some predefined state.
205
206Another problem is that if we need to alter the behaviour of the factory it can
207became quite convoluted. Imagine situation when we want to test if comments by
208author of the post have some special behaviour (for example it has some
209additional CSS class to be able to mark them in CSS). That require from us to do
210some dancing around passing custom attributes:
211
212```elixir
213test "comments by author are special" do
214 post = insert(:post)
215 comment = insert(:comment, post: post, author: post.author)
216
217 # rest of the test
218end
219```
220
221And this is simplified example. In the past I needed to deal with situations
222where I was creating a lot of data to pass through custom attributes to make
223test sensible.
224
225Instead I prefer to do stuff directly in code. Instead of relying on some
226"magical" functions provided by some "magical" macros from external library I
227can use what I already have - functions in my application.
228
229Instead of:
230
231```elixir
232test "comments by author are special" do
233 post = insert(:post)
234 comment = insert(:comment, post: post, author: post.author)
235
236 # rest of the test
237end
238```
239
240Write:
241
242```elixir
243test "comments by author are special" do
244 author = MyApp.Users.create(%{
245 name: "John Doe",
246 email: "john@example.com"
247 })
248 post = MyApp.Blog.create_article(%{
249 author: author,
250 content: "Foo bar",
251 title: "Foo bar"
252 })
253 comment = MyApp.Blog.create_comment_for(article, %{
254 author: author,
255 content: "Foo bar"
256 })
257
258 # rest of the test
259end
260```
261
262It may be a little bit more verbose, but it makes tests way more readable in my
263opinion. You have all details just in place and you know what to expect. And if
264you need some piece of data in all (or almost all) tests within
265module/`describe` block, then you can always can use `setup/1` blocks. Or you
266can create function per module that will generate data for you. As long as your
267test module is self-contained and do not receive "magical" data out of thin air,
268it is ok for me. But `ex_machina` is, in my opinion, terrible idea brought from
269Rails world, that make little to no sense in Elixir.
270
271If you really need such factories, then just write your own functions that will
272use your contexts instead of relying on another library. For example:
273
274```elixir
275import ExUnit.Assertions
276
277def create_user(name, email \\ nil, attrs \\ %{}) do
278 email = email || "#{String.replace(name, " ", ".")}@example.com"
279 attrs = Map.merge(attrs, %{name: name, email: email})
280
281 assert {:ok, user} = MyApp.Users.create(attrs)
282
283 user
284end
285
286# And so on…
287```
288
289This way you do not need to check if all tests use correct validations any
290longer, as your system will do that for you.