Deduplicating authentication and authorization tests in Elixir and Phoenix using macros.
Tags: elixir, testing, phoenix, exunit
Published: 2021-10-19 11:21:51

Writing the same tests over and over again can be frustrating and error-prone work. Yet that’s what happens when writing authentication and authorization tests for controller actions in Phoenix.

Controller actions like `index`, `show`, `create`, `update`, `delete` often require authentication and authorization tests, to check whether a `User` is allowed to do a certain `action` or access a `route`. You wouldn’t want a random user editing and hijacking another user’s data, right?

In this post, I’ll present a method to **safely deduplicate authentication and authorization tests** for Phoenix controller actions, using `macros`. We’ll use Elixir’s testing library `ExUnit` to test protected controller actions with the `Phoenix` framework.

## A simple non-problematic example.

Let’s implement an example with two `routes`, one requires authentication and the other doesn’t. We need authentication tests for the authenticated route, to ensure non-authenticated users cannot access it.

The router file in `/lib/app_web/router.ex`:

```elixir
defmodule AppWeb.Router do
  use AppWeb, :router

  # Public routes.
  scope "/" do
    pipe_through :api

    post "/create", UserController, :create
  end

  # Authenticated routes.
  scope "/user/:user_id" do
    pipe_through [:api, :authenticated]

    post "/update", UserController, :update
  end
end
```

To guarantee that a non-authenticated user cannot access the authenticated route `/user/:user_id/update`, we write the following test in `/test/app_web/controllers/user_controller_test.exs`:

```elixir
defmodule AppWeb.UserControllerTest do
  use AppWeb.ConnCase

  import App.UsersFixtures

  describe "update/2" do
    setup do
      %{user: users_fixture()}
    end

    # Regular tests.
    test "with valid params, updates user", context do
      ...
    end

    # The authentication test.
    test "user is not authenticated, renders error", %{conn: conn, user: user} do
      path = Routes.user_path(conn, :update, user.user_id)

      assert conn
             |> post(path)
             |> json_response(401)
    end
  end
end
```

This test will make a post request to the `UserController` function `update/2`, which is represented in the router as the path `/user/:user_id/update`. The test then asserts that the response has a `401` HTTP status code.

This test will pass. We wrote **one test for one authenticated route**. Easy enough.

## The problem.
But what if we have multiple authenticated routes?

The updated router file in `/lib/app_web/router.ex`:

```elixir
defmodule AppWeb.Router do
  use AppWeb, :router

  # Public routes.
  scope "/", AppWeb do
    pipe_through :api

    post "/create", UserController, :create
  end

  # Authenticated routes.
  scope "/user/:user_id", AppWeb do
    pipe_through [:api, :authenticated]

    get  "/show", UserController, :show
    post "/update", UserController, :update
    post "/delete", UserController, :delete
    post "/posts/create", PostController, :create

    # Post author routes.
    scope "/posts/:post_id" do
      pipe_through :is_post_author

      post "/update", PostController, :update
      post "/delete", PostController, :delete
    end

    post "/create", TeamController, :create

    # Team member routes.
    scope "/teams/:team_id" do
      pipe_through :is_team_member

      get "/index", TeamController, :index
      get "/update", TeamController, :update
      get "/delete", TeamController, :delete
    end
  end
end
```

Yeah… That’s a lot of authentication tests. Each `route` requires almost the exact same authentication test, but each time we change only one variable; the `route`.

### The new situation.
In the updated example, a `User` can own a `Post` and, via `Team.Member`, a `Team` object.

We will now need to write authentication tests **for each authenticated route**; 10 in this example. Every time we write a new authentication test, it will be *near-identical* to the previous one.

We will also need to test that `User` is not authorized to access `Post` when `User` does not own `Post`, and that `User` is not authorized to access `Team` when `User` is not a `Team.Member`. Again, these tests will be *near-identical*.

### Error-prone.
First, writing all these tests is a lot of work. Second, it’s error-prone because you might miss a test, a configuration, forget to update the route, *to woefully start copy-pasting*… You’ll understand my concerns.

In a typical enterprise production-deployed Phoenix-based application, we can see 100s and sometimes 1000s of authentication and authorization tests. That’s **>100 and >1000 possible points of failure**.

We need a solution to standardize our test-suite without giving up readability and maintainability.

## The solution: Macros.
In short, `macros` are special functions that insert a quoted expression into our application code (i.e. code that generates code). The long version of what `macros` are, and how they internally work, is a bit more complicated; but you don’t need expert knowledge to follow the rest of this post, because our implementation is relatively straightforward.

> You can read more about macros here: https://elixir-lang.org/getting-started/meta/macros.html.

In this post I will create one `macro` that generates multiple authentication tests, and implement it in the original `UserControllerTest` example.

### A macro called AuthenticationTestsMacro.
First, we create a module containing the authentication tests macro in the file `/test/support/macros/authentication_tests_macro.ex`.

```elixir
defmodule AppWeb.AuthenticationTestsMacro do
  import ExUnit.Assertions

  defmacro test_user_authentication(:post, path) do
    # Mark the code as `generated`.
    quote generated: true do
      # Make `path` available in the test blocks.
      @path unquote(path)

      # Implement the first test.
      test "user is not authenticated, renders error", %{conn: conn} do
        assert conn
               |> post(path)
               |> json_response(401)
      end

      # Implement the second test.
      test "user is banned, render error", %{conn: conn} do
        import App.UsersFixtures
        import App.Users.SessionsFixtures

        alias App.Users

        user = user_fixture()
        token = session_fixture(user)

        Users.ban_user(user, true)

        assert conn
               |> put_req_header("authorization", token)
               |> post(path)
               |> json_response(401)
      end
    end
  end
end
```

Do you recognise the first test inside the `macro`? It’s from the original example, where we tested `"user is not authenticated, renders error"` inside `UserControllerTest`.

I added a second test `"user is banned, renders error"` to the `macro`. When we call the `macro` in a test module, both tests will be executed for the given `path`.

### Let’s update UserControllerTest.

Now, we implement the `macro` in the controller test module `/test/app_web/controllers/user_controller.exs`. We can remove any authentication tests that were present; the `macro` covers these now.

```elixir
defmodule AppWeb.UserControllerTest do
  use AppWeb.ConnCase

  import App.UsersFixtures

  # Import macros.
  import AppWeb.AuthenticationTestsMacro

  describe "update/2" do
    setup do
      %{user: users_fixture()}
    end

    # Regular tests.
    test "with valid params, updates user", context do
      ...
    end

    # Implement the macro.
    path = Routes.user_path(@endpoint, :update, "user_id")

    test_user_authentication(:post, path)
  end
end
```

We imported the `macro` with `import AppWeb.AuthenticationTestMacro`.

We generated the `path` that needs to be tested for authentication, with `path = Routes.user_path(@endpoint, :update, "user_id")`.

Lastly, we call the `macro` with `test_user_authentication(:post, path)`, where `:post` is the HTTP-method we use to request the controller action.

We can now run our tests again. They will pass.

## Conclusion.
The result is a more readable, maintainable, and accurate test-suite.

Instead of writing authorization tests for each controller function, we can now implement **one or more** `macros` to test authentication and authorization for us. The `macros` will generate the code we need to test the controller functions, based on the arguments we give it.

Do you need 10 authentication tests for 10 different routes? Just implement a `macro` and pass it a different route each time.

Do you need 120 authentication and authorization tests to check whether `User` is authenticated and that `User` is a `Member` of `Team`? Just create two `macros` (one for authentication, and one for team membership) and implement them in each controller action test.

Do you want to check how your test `macros` are holding up? Just change a test in a `macro` so it fails, and watch your test-suite blow up.

It’s as easy as that. No more duplication.

***

What do you think? Is this the best way to generate authentication and authorization tests? Do you have an even better method? Let me know in the comments.

You can find a GitHub repo with a working example here: https://github.com/martinthenth/deduplicate-tests-example