Organize your tests with context tags
Writing tests is the easiest way to improve your software’s maintainability and reliability. They can communicate the expected behavior of your code with your fellow developers. Yet, I would argue that only organized and easy-to-understand tests are helpful tests. Let’s have a look at an often overlooked tool for organizing your tests: Context tags.
Let’s consider the situation where you write tests for a Phoenix controller. When users log in, the controller returns their profile page. If they are admins, it returns a dedicated admin dashboard. And if they are not logged in, they can’t access the profile page and will see a 404 instead.
When you write tests for this controller, you might create the setup for each test inside the test case, like this:
test "GET /profile as user shows the user's name", %{conn: conn} do # The test setup
user = user_fixture(%{admin: false, name: "Bob"})
conn = Plug.Conn.assign(conn, :current_user, user)
# The actual test
conn = get(conn, "/profile")
assert html_response(conn, 200) =~ "Hello Bob"
end
Now, you want to add more tests for your controller. You need the test setup in each test case though. You can either copy and paste the code into each test case or move it to a private function. Another option is to move the test setup to the setup
-block, like this:
setup %{conn: conn} do user = user_fixture(%{admin: false, name: "Bob"})
conn = Plug.Conn.assign(conn, :current_user, user)
%{conn: conn}
end
test "GET /profile as user shows the user's name", %{conn: conn} do
conn = get(conn, "/profile")
assert html_response(conn, 200) =~ "Hello Bob"
end
This looks much cleaner already! The setup
-block will execute before each test case and create and assign a user. You don’t have to copy and paste the code to each test case anymore.
One issue with the shared setup
-block is that you can’t configure it for each test. It is the same for all the tests. But what if you want to test how the controller behaves for admins? That’s where context tags
come in. Let’s see how to use them.
First, let’s configure whether the user is an admin or not. Let’s update the setup block to use the context
instead of hard-coding every user property:
setup %{conn: conn} = context do %{admin: admin, name: name} = context
user = user_fixture(%{admin: admin, name: name})
# Or, simply do: user = user_fixture(context)
conn = Plug.Conn.assign(conn, :current_user, user)
%{conn: conn}
end
@tag admin: false, name: "Bob"
test "GET /profile as user shows the user's name", %{conn: conn} do
conn = get(conn, "/profile")
assert html_response(conn, 200) =~ "Hello Bob"
end
When you create the user, Ecto will only take the schema fields from any map you provide. So, you can also pass the context
-map directly to the fixture. Each test can now configure the user’s properties using the @tag
format. This is helpful once you start to add tests for admin users like this:
# Setup-block and other tests# ...
@tag admin: true
test "GET /profile as admin shows an admin dashboard", %{conn: conn} do
conn = get(conn, "/profile")
assert html_response(conn, 200) =~ "Admin dashboard"
end
The @tag
makes it easy to configure tests individually. It also puts the test setup at a more prominent spot: above each test case. This makes it easy to understand which use-case each test covers.
Now, you can extend our test cases to cover unauthenticated users as well. Let’s add a case that tests when users are not logged in and try to access the profile page, they only see a 404
. Luckily, you moved the test setup to the shared setup
-block. This way, you have to make changes only in a single place and not in every individual test case. Update the setup
-block as follows:
setup %{conn: conn} = context do conn =
if Map.get(context, :authenticated, true) do
user = user_fixture(context)
Plug.Conn.assign(conn, :current_user, user)
else
conn
end
%{conn: conn}
end
By default, the setup
-block will still create and assign a user, so you don’t need to change your existing tests. However, you can easily add another test for unauthenticated users:
@tag authenticated: falsetest "GET /profile shows 404 for unauthenticated users", %{conn: conn} do
conn = get(conn, "/profile")
assert html_response(conn, 404) =~ "Not found"
end
And that’s it! I hope you found this article useful and know how to better organize your tests now. If you found this helpful, consider following me on BlueSky for more Elixir tips. Until next time!