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.