My personal blog hauleth.dev
blog
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

ft: add webrings to footer

+362 -11
+4
config.toml
··· 71 71 [extra.twitter] 72 72 site = "@hauleth" 73 73 creator = "@hauleth" 74 + 75 + [[extra.webrings]] 76 + name = "Beambloggers" 77 + url = "https://beambloggers.com/"
+290
content/post/writing-tests.md
··· 1 + +++ 2 + date = 2023-11-20 3 + title = "How do I write Elixir tests?" 4 + 5 + [taxonomies] 6 + tags = [ 7 + "elixir", 8 + "testing", 9 + "programming" 10 + ] 11 + +++ 12 + 13 + This post was created for myself to codify some basic guides that I use while 14 + writing tests. If you, my dear reader, read this then there is one important 15 + thing for you to remember: 16 + 17 + These are **guides** not *rules*. Each code base is different deviations and are 18 + expected and *will* happen. Just use the thing between your ears. 19 + 20 + ## `@subject` module attribute for module under test 21 + 22 + While writing test in Elixir it is not always obvious what we are testing. Just 23 + imagine test like: 24 + 25 + ```elixir 26 + test "foo should frobnicate when bar" do 27 + bar = pick_bar() 28 + 29 + assert :ok == MyBehaviour.foo(MyImplementation, bar) 30 + end 31 + ``` 32 + 33 + It is not obvious at the first sight what we are testing. And it is pretty 34 + simplified example, in real world it can became even harder to notice what is 35 + module under test (MUT). 36 + 37 + To resolve that I came up with a simple solution. I create module attribute 38 + named `@subject` that points to the MUT: 39 + 40 + ```elixir 41 + @subject MyImplementation 42 + 43 + test "foo should frobnicate when bar" do 44 + bar = pick_bar() 45 + 46 + assert :ok == MyBehaviour.foo(@subject, bar) 47 + end 48 + ``` 49 + 50 + Now it is more obvious what is MUT and what is just wrapper code around it. 51 + 52 + In the past I have been using `alias` with `:as` option, like: 53 + 54 + ```elixir 55 + alias MyImplementation, as: Subject 56 + ``` 57 + 58 + However I find module attribute to be more visually outstanding and make it 59 + easier for me to notice `@subject` than `Subject`. But your mileage may vary. 60 + 61 + ## `describe` with function name 62 + 63 + That one is pretty basic, and I have seen that it is pretty standard for people: 64 + when you are writing tests for module functions, then group them in `describe` 65 + blocks that will contain name (and arity) of the function in the name. Example: 66 + 67 + ```elixir 68 + # Module under test 69 + defmodule Foo do 70 + def a(x, y, z) do 71 + # some code 72 + end 73 + end 74 + 75 + # Tests 76 + defmodule FooTest do 77 + use ExUnit.Case, async: true 78 + 79 + @subject Foo 80 + 81 + describe "a/3" do 82 + # Some tests here 83 + end 84 + end 85 + ``` 86 + 87 + This allows me to see what functionality I am testing. 88 + 89 + Of course that doesn't apply to the Phoenix controllers, as there we do not test 90 + functions, but tuples in form `{method, path}` which I then write as `METHOD 91 + path`, for example `POST /users`. 92 + 93 + ## Avoid module mocking 94 + 95 + In Elixir we have bunch of the mocking libraries out there, but most of them 96 + have quite substantial issue for me - these prevent me from using `async: true` 97 + for my tests. This often causes substantial performance hit, as it prevents 98 + different modules to run in parallel (not single tests, *modules*, but that is 99 + probably material for another post). 100 + 101 + Instead of mocks I prefer to utilise dependency injection. Some people may argue 102 + that "Elixir is FP, not OOP, there is no need for DI" and they cannot be further 103 + from truth. DI isn't related to OOP, it just have different form, called 104 + function arguments. For example, if we want to have function that do something 105 + with time, in particular - current time. Then instead of writing: 106 + 107 + ```elixir 108 + def my_function(a, b) do 109 + do_foo(a, b, DateTime.utc_now()) 110 + end 111 + ``` 112 + 113 + Which would require me to use mocks for `DateTime` or other workarounds to make 114 + tests time-independent. I would do: 115 + 116 + ```elixir 117 + def my_function(a, b, now \\ DateTime.utc_now()) do 118 + do_foo(a, b, now) 119 + end 120 + ``` 121 + 122 + Which still provide me the ergonomics of `my_function/2` as above, but is way 123 + easier to test, as I can pass the date to the function itself. This allows me to 124 + run this test in parallel as it will not cause other tests to do weird stuff 125 + because of altered `DateTime` behaviour. 126 + 127 + ## Avoid `ex_machina` factories 128 + 129 + I have poor experience with tools like `ex_machina` or similar. These often 130 + bring whole [Banana Gorilla Jungle problem][bgj] back, just changed a little, as 131 + now instead of just passing data around, we create all needless structures for 132 + sole purpose of test, even when they aren't needed for anything. 133 + 134 + [bgj]: https://softwareengineering.stackexchange.com/q/368797 135 + 136 + Start with example from [ExMachina README](https://github.com/beam-community/ex_machina#overview): 137 + 138 + ```elixir 139 + defmodule 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 183 + end 184 + ``` 185 + 186 + For start we can see a single problem there - we do not validate our factories 187 + against our schema changesets. Without additional tests like: 188 + 189 + ```elixir 190 + @subject MyApp.Article 191 + 192 + test "factory conforms to changeset" do 193 + changeset = @subject.changeset(%@subject{}, params_for(:article)) 194 + 195 + assert changeset.valid? 196 + end 197 + ``` 198 + 199 + We cannot be sure that our tests test what we want them to test. And if we pass 200 + custom attribute values in some tests it gets even worse, because we cannot be 201 + sure if these are conforming either. 202 + 203 + That mean that our tests may be moot, because we aren't testing against real 204 + situations, but against some predefined state. 205 + 206 + Another problem is that if we need to alter the behaviour of the factory it can 207 + became quite convoluted. Imagine situation when we want to test if comments by 208 + author of the post have some special behaviour (for example it has some 209 + additional CSS class to be able to mark them in CSS). That require from us to do 210 + some dancing around passing custom attributes: 211 + 212 + ```elixir 213 + test "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 218 + end 219 + ``` 220 + 221 + And this is simplified example. In the past I needed to deal with situations 222 + where I was creating a lot of data to pass through custom attributes to make 223 + test sensible. 224 + 225 + Instead 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 227 + can use what I already have - functions in my application. 228 + 229 + Instead of: 230 + 231 + ```elixir 232 + test "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 237 + end 238 + ``` 239 + 240 + Write: 241 + 242 + ```elixir 243 + test "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 259 + end 260 + ``` 261 + 262 + It may be a little bit more verbose, but it makes tests way more readable in my 263 + opinion. You have all details just in place and you know what to expect. And if 264 + you need some piece of data in all (or almost all) tests within 265 + module/`describe` block, then you can always can use `setup/1` blocks. Or you 266 + can create function per module that will generate data for you. As long as your 267 + test module is self-contained and do not receive "magical" data out of thin air, 268 + it is ok for me. But `ex_machina` is, in my opinion, terrible idea brought from 269 + Rails world, that make little to no sense in Elixir. 270 + 271 + If you really need such factories, then just write your own functions that will 272 + use your contexts instead of relying on another library. For example: 273 + 274 + ```elixir 275 + import ExUnit.Assertions 276 + 277 + def 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 284 + end 285 + 286 + # And so on… 287 + ``` 288 + 289 + This way you do not need to check if all tests use correct validations any 290 + longer, as your system will do that for you.
+7 -7
flake.lock
··· 5 5 "systems": "systems" 6 6 }, 7 7 "locked": { 8 - "lastModified": 1687709756, 9 - "narHash": "sha256-Y5wKlQSkgEK2weWdOu4J3riRd+kV/VCgHsqLNTTWQ/0=", 8 + "lastModified": 1705309234, 9 + "narHash": "sha256-uNRRNRKmJyCRC/8y1RqBkqWBLM034y4qN7EprSdmgyA=", 10 10 "owner": "numtide", 11 11 "repo": "flake-utils", 12 - "rev": "dbabf0ca0c0c4bce6ea5eaf65af5cb694d2082c7", 12 + "rev": "1ef2e671c3b0c19053962c07dbda38332dcebf26", 13 13 "type": "github" 14 14 }, 15 15 "original": { ··· 20 20 }, 21 21 "nixpkgs": { 22 22 "locked": { 23 - "lastModified": 1687103638, 24 - "narHash": "sha256-dwy/TK6Db5W7ivcgmcxUykhFwodIg0jrRzOFt7H5NUc=", 25 - "path": "/nix/store/p57nnwjhfvmsn75y9l6hn00pl2xv7ivm-source", 26 - "rev": "91430887645a0953568da2f3e9a3a3bb0a0378ac", 23 + "lastModified": 1704842529, 24 + "narHash": "sha256-OTeQA+F8d/Evad33JMfuXC89VMetQbsU4qcaePchGr4=", 25 + "path": "/nix/store/g16z4fs1mrbkxc4x6wm8xbrh13nc7aw4-source", 26 + "rev": "eabe8d3eface69f5bb16c18f8662a702f50c20d5", 27 27 "type": "path" 28 28 }, 29 29 "original": {
+40
sass/rings.scss
··· 1 + .rings { 2 + margin-top: 1rem; 3 + text-align: center; 4 + 5 + details > summary { 6 + list-style: none; 7 + cursor: pointer; 8 + 9 + &::before, &::after { 10 + margin: 0 .5rem; 11 + } 12 + 13 + &::before { content: '▶'; } 14 + &::after { content: '◀'; } 15 + 16 + &::-webkit-details-marker { 17 + display: none; 18 + } 19 + } 20 + 21 + details[open] { 22 + summary { 23 + &::before, &::after { 24 + content: '▼'; 25 + } 26 + 27 + margin-bottom: 1rem; 28 + } 29 + } 30 + 31 + ul { 32 + list-style: none; 33 + margin: 0; 34 + } 35 + 36 + li { 37 + margin: 0; 38 + padding: 0; 39 + } 40 + }
+1
sass/style.scss
··· 6 6 @import 'post'; 7 7 @import 'pagination'; 8 8 @import 'footer'; 9 + @import 'rings'; 9 10 10 11 :root { 11 12 --phoneWidth: (max-width: #{$phone-max-width + 1px});
+4 -4
templates/index.html
··· 1 1 {% extends "zerm/templates/index.html" %} 2 2 3 3 {% block fonts %} 4 - {% endblock %} 4 + {% endblock fonts %} 5 5 6 6 {% block rss %} 7 7 {%- if config.generate_feed -%} 8 8 <link rel="alternate" type="application/atom+xml" title="{{ config.title }} Feed" href="{{ get_url(path=config.feed_filename) | safe}}"> 9 9 {%- endif -%} 10 - {% endblock %} 10 + {% endblock rss %} 11 11 12 12 {% block og_preview %} 13 13 {{ social::og_preview() }} ··· 22 22 {%- if config.extra.webmention -%} 23 23 <link rel="webmention" href="{{ config.extra.webmention }}" > 24 24 {%- endif -%} 25 - {% endblock %} 25 + {% endblock og_preview %} 26 26 27 27 {% block copyright %} 28 28 <div class="copyright"> ··· 67 67 </ul> 68 68 </nav> 69 69 </header> 70 - {% endblock %} 70 + {% endblock header %}
+16
templates/macros/extended_footer.html
··· 1 + {% macro extended_footer() %} 2 + <div class="rings"> 3 + <details> 4 + <summary>Webrings</summary> 5 + <ul> 6 + {%- for ring in config.extra.webrings -%} 7 + <li> 8 + <a href="{{ ring.url }}/prev?referrer={{ get_url(path = "/") | safe }}">&laquo;</a> 9 + <a href="{{ ring.url }}">{{ ring.name }}</a> 10 + <a href="{{ ring.url }}/next?referrer={{ get_url(path = "/") | safe }}">&raquo;</a> 11 + </li> 12 + {%- endfor -%} 13 + </ul> 14 + </details> 15 + </div> 16 + {% endmacro extended_footer %}