Testing Elixir Plugs

Josh Clayton

On a Phoenix application I worked on recently, I decided to tackle a bug where we weren’t redirecting users to a sign-in page even though we were expecting conn.assigns to have current_user. This was only happening in a few different areas of the app. Plugs to the rescue.

What are Plugs?

Per the Plug GitHub page, a plug is a “specification for composable modules between web applications.”

Although some might compare Plugs to Rack middleware, they operate on the entire request and response lifecycle; in fact, Endpoints, Routers, and Controllers within Phoenix are all Plugs internally.

Writing a Small Plug

Let’s dig into a plug that has the responsibility described above. There should be two paths through the plug:

  1. The connection has a current_user and should continue
  2. The connection does not have a current_user and should stop everything and redirect the user to the sign-in page

Let’s create a new file in web/plugs/require_login.ex:

defmodule MyApp.Plugs.RequireLogin do
  import Plug.Conn

  def init(opts), do: opts

  def call(conn, _) do
    conn
  end
end

This is a minimal interface for a module plug. call is where we’ll handle letting the connection continue or halting execution; let’s flesh out the paths now:

# web/plugs/require_login.ex

def call(conn, _) do
  if conn.assigns[:current_user] do
    # everything is good
  else
    # uh-oh - ask the user to sign in
  end
end

In our “happy path” when a current_user is set, we’ll need to pass the conn through so it can continue on its merry way.

When conn.assigns[:current_user] doesn’t return a truthy value, we’ll want to redirect them to "/sign_in", where we’ll prompt them to sign in. Also note that we’re accessing current_user via []; we do so instead of conn.assigns.current_user because if current_user hasn’t been assigned, the application will raise a KeyError. Check out the Elixir documentation on the Access behaviour if you want to learn more.

# web/plugs/require_login.ex

def call(conn, _) do
  if conn.assigns[:current_user] do
    conn
  else
    conn |> Phoenix.Controller.redirect(to: "/sign_in")
  end
end

Because of the nature of Plugs, we’re letting Phoenix.Controller.redirect/2 do the heavy lifting for us.

Halt!

If we used the code above, we’d begin to see problems. As mentioned previously, because Plugs cover the entire lifecycle of the connection, calling Phoenix.Controller.redirect/2 by itself actually isn’t enough; we also need to call Plug.Conn.halt/1 to stop further execution and immediately process the redirect.

Let’s refactor a bit before we move on:

defmodule MyApp.Plugs.RequireLogin do
  import Plug.Conn

  def init(opts), do: opts

  def call(conn, _) do
    if conn.assigns[:current_user] do
      conn
    else
      conn |> redirect_to_login
    end
  end

  defp redirect_to_login(conn) do
    conn |> Phoenix.Controller.redirect(to: "/sign_in") |> halt
  end
end

Testing the Plug

Because we don’t need to build up or manage complicated state for our connection, and because our functions allow for known inputs and outputs, testing the plug is fairly painless.

Let’s start with a new test:

# test/plugs/require_login_test.exs

defmodule MyApp.Plugs.RequireLoginTest do
  use MyApp.ConnCase

  test "user is redirected when current_user is not assigned" do
    # build a connection and run the plug

    assert redirected_to(conn) == "/sign_in"
  end

  test "user passes through when current_user is assigned" do
    # build a connection, assign current_user, and run the plug

    assert conn.status != 302
  end
end

We’ll use MyApp.ConnCase, as that will give us access to various functions to interact with a connection. To get the first test passing, we’ll need to generate a connection and then run our plug.

# test/plugs/require_login_test.exs

test "user is redirected when current_user is not assigned" do
  conn = conn() |> MyApp.Plugs.RequireLogin.call(%{})

  assert redirected_to(conn) == "/sign_in"
end

We can generate a connection with Phoenix.ConnTest.conn/0 and then pipe that connection to our plug. This test should be green.

For our second test, we’ll need to introduce another step where we assign current_user.

# test/plugs/require_login_test.exs

test "user passes through when current_user is set" do
  conn =
    conn()
    |> assign(:current_user, %MyApp.User{})
    |> MyApp.Plugs.RequireLogin.call(%{})

  assert conn.status != 302
end

Whew. Finally, a bit of refactoring leaves us with:

defmodule MyApp.Plugs.RequireLoginTest do
  use MyApp.ConnCase

  test "user is redirected when current_user is not set" do
    conn = conn() |> require_login

    assert redirected_to(conn) == "/sign_in"
  end

  test "user passes through when current_user is set" do
    conn = conn() |> authenticate |> require_login

    assert not_redirected?(conn)
  end

  defp require_login(conn) do
    conn |> MyApp.Plugs.RequireLogin.call(%{})
  end

  defp authenticate(conn) do
    conn |> assign(:current_user, %MyApp.User{})
  end

  defp not_redirected?(conn) do
    conn.status != 302
  end
end

Of note is the not_redirected?/1 function here; the reason we assert that the status is “not a 302” is because Phoenix.ConnTest.conn/0 doesn’t actually set a status and leaves the value as nil. assert conn.status == nil seemed less intuitive than asserting a redirect did not occur.

Plugs and the Router

With the plug working and tested, using it in the router is almost anticlimactic.

# web/router.ex

defmodule MyApp.Router do
  use Phoenix.Router

  pipeline :browser do
    plug :accepts, ~w(html)

    # ...
  end

  # endpoints not requiring a logged in user
  scope "/", MyApp do
    pipe_through :browser

    # ... resources
  end

  # endpoints requiring a logged in user
  scope "/", MyApp do
    pipe_through [:browser, MyApp.Plugs.RequireLogin]

    # ... resources
  end
end

This introduces a new scope block where we pass a list to Phoenix.Router.pipe_through/1, and then declare all resources requiring current_user within that block. These plugs are executed sequentially, so it will first run through the :browser pipeline, and then through our plug.

Explore Other Plugs

I hope this was a helpful guide in authoring and unit-testing your own Elixir plugs; testing plugs in isolation can be daunting if you’ve never done it before. If you’re looking for other inspiration, I encourage you to look at the tests written for Plug itself to understand different approaches you can take.