Hey! I wrote a book!

My new book is out and it's called: Building Table Views with Phoenix LiveView.
If you like this article, you will also like the book! Check it out at PragProg!

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
  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 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: false
test "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 Twitter for more Elixir tips. Until next time!

Get notified about new posts