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